Phase: Real-World Patterns Project: "Teamwork" -- a collaborative task board Previous: Chapter 22 introduced modal dialogs with HTMX and _hyperscript for open/close behaviour. This chapter collects production-ready _hyperscript recipes that you will reach for again and again.
By the end of this chapter you will be able to:
- Build a live character counter using _hyperscript element-scoped variables.
- Implement an accordion where only one section is open at a time.
- Create a clipboard-copy button with success/error feedback.
- Build drag-and-drop reorder using HTML drag events and _hyperscript.
- Combine multiple _hyperscript behaviours on a single element without conflicts.
- Apply the decision criteria for "use _hyperscript" vs "write JavaScript."
Appendix B is the grammar book. It covers every command, every variable scope, every targeting expression. This chapter is the cookbook.
The difference matters. Knowing that _hyperscript has a put command and a set
command is like knowing that flour and butter exist. What you actually need is the
recipe for pie crust. You need to see the ingredients combined, in order, with the
right proportions, producing something that works.
Each pattern in this chapter meets three criteria:
- Short. Between one and fifteen lines of _hyperscript. If it takes more, you should write JavaScript instead.
- Self-contained. The _hyperscript lives on the element it affects. No external script files, no global state, no coordination between distant elements (except where the pattern explicitly calls for it, like drag-and-drop).
- Production-tested. These are not toy examples. Every pattern handles edge cases -- what happens if the copy fails, what happens if the user drags outside the list, what happens if two animations overlap.
We will build five patterns:
| # | Pattern | Lines | Key _hyperscript concepts |
|---|---|---|---|
| 1 | Live character counter | 6 | on input, element variables, put, if/else |
| 2 | Exclusive accordion | 3 | on click, remove from siblings, toggle |
| 3 | Clipboard copy | 5 | on click, call, add/remove, wait |
| 4 | Drag-and-drop reorder | ~15 | on dragstart/dragover/drop, DOM manipulation |
| 5 | Multi-behaviour task card | ~12 | Multiple on handlers on one element |
After the patterns, we will discuss when to stop using _hyperscript and extract to JavaScript -- because that threshold exists, and crossing it is the right call sometimes.
The task description textarea in Teamwork has a 200-character limit (enforced
server-side with attribute.maxlength(200)). A live character counter gives the
user immediate feedback as they type.
Here is the _hyperscript:
on input
put my value's length into #char-count's textContent
if my value's length > 180
add .warning to #char-count
else
remove .warning from #char-count
end
Let us walk through it line by line.
Line 1: on input
The input event fires on every keystroke, paste, cut, or autofill change. It is
more reliable than keyup because it catches all value changes, including
right-click paste and drag-and-drop text into the field. Using input instead of
keyup is a deliberate choice -- keyup misses non-keyboard edits.
Line 2: put my value's length into #char-count's textContent
This is a single expression with several parts:
my-- refers to the element that owns the_attribute (the textarea).my value-- the textarea's.valueproperty (the text the user typed).my value's length-- the.lengthproperty of that string.#char-count-- a CSS selector targeting the element withid="char-count".#char-count's textContent-- the.textContentproperty of that element.put ... into ...-- assigns the left side to the right side.
The result: every time the user types, the character count element updates to show the current length.
Lines 3-6: if ... add .warning ... else ... remove .warning ... end
When the length exceeds 180 (90% of the 200-character limit), the counter gains a
.warning class. Below 180, the class is removed. The .warning class in your CSS
might turn the text red or change the background -- the specifics are up to you.
The if/else/end structure is mandatory. _hyperscript does not use curly braces
or indentation for blocks. The end keyword closes the conditional.
Why not use toggle? Because toggle would flip the class on every keystroke
regardless of the current length. If the user is at 185 characters and presses a
key, toggle would remove .warning (wrong). The explicit if/else checks the
condition every time and applies the correct state.
Here is the HTML in context:
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
name="description"
maxlength="200"
_="on input
put my value's length into #char-count's textContent
if my value's length > 180
add .warning to #char-count
else
remove .warning from #char-count
end">
</textarea>
<span id="char-count" class="char-counter">0</span>
<span class="char-limit">/ 200</span>
</div>And in Gleam with Lustre:
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("description")],
[element.text("Description")],
),
html.textarea(
[
attribute.id("description"),
attribute.name("description"),
attribute.maxlength(200),
attribute.attribute("_", "on input
put my value's length into #char-count's textContent
if my value's length > 180
add .warning to #char-count
else
remove .warning from #char-count
end"),
],
"",
),
html.span(
[attribute.id("char-count"), attribute.class("char-counter")],
[element.text("0")],
),
html.span(
[attribute.class("char-limit")],
[element.text("/ 200")],
),
])Notice the use of attribute.attribute("_", "..."). This is the pattern for all
_hyperscript in Gleam. The first argument is the attribute name (the literal
underscore character), and the second is the _hyperscript code as a plain string.
There is no special Gleam library needed -- _hyperscript is just an HTML attribute.
The CSS for the counter:
.char-counter {
font-size: 0.85rem;
color: #718096;
transition: color 0.2s ease;
}
.char-counter.warning {
color: #e53e3e;
font-weight: 600;
}The transition property on .char-counter makes the colour change smooth rather
than jarring. This is a CSS concern, not a _hyperscript concern -- keep
presentation in stylesheets and behaviour in _hyperscript.
Edge case: pre-filled textareas. If the textarea has a default value (e.g.
during inline editing from Chapter 21), the counter will show "0" until the user
types. To fix this, add an init handler:
init
put my value's length into #char-count's textContent
on input
put my value's length into #char-count's textContent
if my value's length > 180
add .warning to #char-count
else
remove .warning from #char-count
end
The init keyword runs once when _hyperscript initializes the element -- on page
load or after an HTMX swap. It sets the counter to the correct value immediately.
The Teamwork board has a settings panel with collapsible sections: "General," "Notifications," "Permissions." The business requirement is that only one section can be open at a time. Opening one closes the others.
This is one of _hyperscript's best use cases because the alternative in vanilla JavaScript is surprisingly verbose. You need to query siblings, iterate, remove classes, and then toggle the clicked one. In _hyperscript:
on click
remove .open from .accordion-section in closest .accordion
toggle .open on me
Three lines. Let us break them down.
Line 1: on click
The click handler. Each accordion section header has this _hyperscript.
Line 2: remove .open from .accordion-section in closest .accordion
This is the key line. Read it right to left:
closest .accordion-- walk up the DOM from the current element until you find an ancestor with class.accordion. This is the accordion container..accordion-section in closest .accordion-- find all descendants of that container that have class.accordion-section. These are all the sections, including the one that was just clicked.remove .open from ...-- remove the.openclass from every one of those sections.
The result: all sections close.
Line 3: toggle .open on me
Now toggle .open on the clicked section. If it was closed before (the normal
case), toggling opens it. If it was open before (we just closed it in line 2),
toggling adds the class back -- so clicking an open section keeps it open.
Wait, there is a subtlety. Line 2 removes .open from all sections, including
the one we just clicked. Then line 3 toggles .open on that same element. Since
line 2 already removed it, the toggle adds it back. So clicking a closed section
opens it (correct). But what about clicking an already open section? Line 2
removes .open. Line 3 toggles, which adds it back. That means clicking an open
section keeps it open -- not ideal.
If you want clicking an open section to close it, use add with a condition
instead:
on click
if I match .open
remove .open from me
else
remove .open from .accordion-section in closest .accordion
add .open to me
end
This is six lines instead of three, but the behaviour is more intuitive. The three-line version is fine when you always want exactly one section open.
Here is the full HTML structure:
<div class="accordion">
<div class="accordion-section">
<button class="accordion-header"
_="on click
remove .open from .accordion-section in closest .accordion
toggle .open on closest .accordion-section">
General
</button>
<div class="accordion-body">
<p>Board name, description, visibility settings.</p>
</div>
</div>
<div class="accordion-section">
<button class="accordion-header"
_="on click
remove .open from .accordion-section in closest .accordion
toggle .open on closest .accordion-section">
Notifications
</button>
<div class="accordion-body">
<p>Email notifications, Slack integration, digest frequency.</p>
</div>
</div>
<div class="accordion-section">
<button class="accordion-header"
_="on click
remove .open from .accordion-section in closest .accordion
toggle .open on closest .accordion-section">
Permissions
</button>
<div class="accordion-body">
<p>Role assignments, invite links, guest access.</p>
</div>
</div>
</div>Notice that the _hyperscript is on the <button> (the header), but we toggle
.open on closest .accordion-section (the parent). This is because the CSS
rules for showing/hiding the body are on the section element:
.accordion-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion-section.open .accordion-body {
max-height: 500px;
}The max-height transition gives a smooth expand/collapse animation. Using
max-height instead of height is a CSS trick -- you cannot transition from
height: 0 to height: auto, but you can transition from max-height: 0 to
max-height: 500px (or whatever value is large enough for your content).
In Gleam, the accordion section becomes a reusable function:
fn accordion_section(
title: String,
content: element.Element(t),
) -> element.Element(t) {
html.div([attribute.class("accordion-section")], [
html.button(
[
attribute.class("accordion-header"),
attribute.attribute("_", "on click
remove .open from .accordion-section in closest .accordion
toggle .open on closest .accordion-section"),
],
[element.text(title)],
),
html.div(
[attribute.class("accordion-body")],
[content],
),
])
}And the settings panel:
fn settings_panel() -> element.Element(t) {
html.div([attribute.class("accordion")], [
accordion_section(
"General",
html.p([], [element.text("Board name, description, visibility settings.")]),
),
accordion_section(
"Notifications",
html.p([], [element.text(
"Email notifications, Slack integration, digest frequency.",
)]),
),
accordion_section(
"Permissions",
html.p([], [element.text(
"Role assignments, invite links, guest access.",
)]),
),
])
}The _hyperscript lives inside the accordion_section function. Every section gets
the same behaviour automatically. No JavaScript file to maintain, no event
delegation to wire up.
Every task on the Teamwork board has a shareable URL. You want a button that copies the URL to the clipboard with a single click, shows a brief "Copied!" confirmation, and handles errors gracefully.
Here is the _hyperscript:
on click
call navigator.clipboard.writeText(my dataset.url)
add .copied to me
wait 2s
remove .copied from me
Line by line:
Line 1: on click
The click event fires the copy logic.
Line 2: call navigator.clipboard.writeText(my dataset.url)
This is JavaScript interop. The call command invokes a JavaScript expression.
navigator.clipboard.writeText() is the modern Clipboard API -- it writes text to
the system clipboard and returns a Promise.
my dataset.url reads the data-url attribute from the button element. In HTML,
data-* attributes are accessible via the dataset property. So
<button data-url="/tasks/42"> gives my dataset.url the value "/tasks/42".
_hyperscript is Promise-aware. When a call expression returns a Promise,
_hyperscript automatically awaits it before proceeding to the next line. You do
not need to write then or await -- it just works.
Line 3: add .copied to me
After the text is copied, add the .copied class to the button. Your CSS can use
this to change the button's appearance -- green background, checkmark icon, whatever
signals success.
Line 4: wait 2s
Pause execution for two seconds. The button stays in the "copied" state.
Line 5: remove .copied from me
Revert to the default appearance.
The result is a smooth copy-confirm-reset cycle, all in five lines.
Error handling. The navigator.clipboard.writeText() call can fail -- the user
might deny permission, or the page might not be in a secure context (clipboard API
requires HTTPS or localhost). To handle this, wrap the call:
on click
call navigator.clipboard.writeText(my dataset.url)
catch e
add .copy-error to me
put 'Failed to copy' into me
wait 2s
remove .copy-error from me
put 'Copy Link' into me
halt
end
add .copied to me
put 'Copied!' into me
wait 2s
remove .copied from me
put 'Copy Link' into me
The catch block handles the rejection. If the copy fails, the button shows
"Failed to copy" with an error style, waits two seconds, and reverts. The halt
command stops execution so the success path does not run.
Here is the HTML:
<button class="btn btn-small copy-link-btn"
data-url="/tasks/42"
_="on click
call navigator.clipboard.writeText(my dataset.url)
catch e
add .copy-error to me
put 'Failed to copy' into me
wait 2s
remove .copy-error from me
put 'Copy Link' into me
halt
end
add .copied to me
put 'Copied!' into me
wait 2s
remove .copied from me
put 'Copy Link' into me">
Copy Link
</button>And in Gleam:
fn copy_link_button(task_id: String) -> element.Element(t) {
let url = "/tasks/" <> task_id
html.button(
[
attribute.class("btn btn-small copy-link-btn"),
attribute.attribute("data-url", url),
attribute.attribute("_", "on click
call navigator.clipboard.writeText(my dataset.url)
catch e
add .copy-error to me
put 'Failed to copy' into me
wait 2s
remove .copy-error from me
put 'Copy Link' into me
halt
end
add .copied to me
put 'Copied!' into me
wait 2s
remove .copied from me
put 'Copy Link' into me"),
],
[element.text("Copy Link")],
)
}The CSS:
.copy-link-btn {
transition: background-color 0.2s ease, color 0.2s ease;
}
.copy-link-btn.copied {
background-color: #48bb78;
color: white;
}
.copy-link-btn.copy-error {
background-color: #e53e3e;
color: white;
}A note on full URLs. The data-url attribute above contains a relative path
(/tasks/42). For a shareable link, you probably want the full URL. You can
construct it on the server:
let full_url = "https://teamwork.example.com/tasks/" <> task_idOr build it client-side in the _hyperscript:
call navigator.clipboard.writeText(window.location.origin + my dataset.url)
The window.location.origin expression gives you https://teamwork.example.com,
and you concatenate the relative path. Either approach works -- the server-side
version is simpler if you have access to the base URL in your configuration.
This is the most complex pattern in the chapter, but it is also the most rewarding. Users can drag tasks to reorder them within a list, and the new order persists to the server.
Drag-and-drop in HTML uses four native events:
| Event | Fires on | When |
|---|---|---|
dragstart |
Dragged element | User starts dragging |
dragover |
Drop target | Dragged element is over a valid drop target |
dragenter |
Drop target | Dragged element enters a drop target |
drop |
Drop target | User releases the dragged element |
The dragover event is special: by default, elements do not accept drops. You must
call event.preventDefault() on dragover to signal that the element accepts a
drop. Without this, the drop event will never fire.
Here is the _hyperscript for a single draggable task item:
on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list
This is the longest _hyperscript in the chapter. Let us trace through it.
on dragstart: When the user starts dragging, we store a reference to the
dragged element in window.draggedEl (a global variable, using the window
object directly). We also add a .dragging class so CSS can dim the element or
show a ghost effect.
Why a global variable? Because the dragover, dragenter, and drop events fire
on different elements -- the ones under the cursor, not the one being dragged. We
need a way to access the dragged element from those handlers. A global is the
simplest approach. In _hyperscript, you could also use $draggedEl (the $ prefix
scopes to window), but window.draggedEl is more explicit and easier to read.
on dragover: We call event.preventDefault() to signal that this element
accepts drops. Without this line, the browser will reject the drop and the drop
event will never fire. This is a quirk of the HTML Drag and Drop API that trips
up everyone the first time.
on dragenter: This is where the visual reorder happens. When the dragged
element enters another task item:
- We check that the target is not the dragged element itself (
me is not window.draggedEl) and not a placeholder (I do not match .drag-placeholder). - We compare vertical positions using
getBoundingClientRect().top. If the target is above the dragged element, we insert the dragged element before the target. If below, we insert it after. me.parentElement.insertBefore(window.draggedEl, me)is standard DOM API -- it moves the dragged element to a new position in the list.
The DOM reorder happens in real time as the user drags. The list visually updates on
every dragenter, giving immediate feedback.
on drop: When the user releases:
event.preventDefault()completes the drop (another required call).- Remove the
.draggingclass. send reorder to closest .task-listdispatches a customreorderevent to the task list container. This is where we trigger the server sync.
The task list container listens for the reorder event and sends the new order to
the server:
on reorder
set ids to []
for item in .task-item in me
call ids.push(item.dataset.id)
end
set #reorder-input's value to ids.join(',')
send submit to #reorder-form
This handler:
- Creates an empty array
ids. - Iterates over every
.task-itemin the list (now in the new visual order). - Pushes each item's
data-idattribute into the array. - Joins the IDs into a comma-separated string and sets it as the value of a hidden input.
- Triggers a form submit, which fires an HTMX request.
The hidden form looks like this:
<form id="reorder-form"
hx-post="/tasks/reorder"
hx-swap="none">
<input type="hidden" id="reorder-input" name="order" value="">
</form>hx-swap="none" means we do not need to update the DOM from the response -- the
DOM is already in the correct order from the drag. We just need to persist the order
on the server.
Here is the complete HTML for a draggable task list:
<div class="task-list" id="task-list"
_="on reorder
set ids to []
for item in .task-item in me
call ids.push(item.dataset.id)
end
set #reorder-input's value to ids.join(',')
send submit to #reorder-form">
<div class="task-item" draggable="true" data-id="1"
_="on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list">
<span>Buy groceries</span>
</div>
<!-- More task items with the same _hyperscript... -->
</div>
<form id="reorder-form"
hx-post="/tasks/reorder"
hx-swap="none">
<input type="hidden" id="reorder-input" name="order" value="">
</form>In Gleam, the draggable task item becomes a function:
const drag_hyperscript = "on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list"
fn draggable_task_item(
task: Task,
) -> element.Element(t) {
html.div(
[
attribute.class("task-item"),
attribute.attribute("draggable", "true"),
attribute.attribute("data-id", task.id),
attribute.attribute("_", drag_hyperscript),
],
[
html.span([], [element.text(task.title)]),
copy_link_button(task.id),
],
)
}Notice that we extracted the _hyperscript string into a const. When the same
_hyperscript code repeats across many elements, putting it in a constant avoids
duplication and makes updates easier. In Gleam, const values must be literal
strings, which is exactly what we have here.
The task list container:
const reorder_hyperscript = "on reorder
set ids to []
for item in .task-item in me
call ids.push(item.dataset.id)
end
set #reorder-input's value to ids.join(',')
send submit to #reorder-form"
fn task_list(tasks: List(Task)) -> element.Element(t) {
html.div(
[
attribute.id("task-list"),
attribute.class("task-list"),
attribute.attribute("_", reorder_hyperscript),
],
list.map(tasks, draggable_task_item),
)
}The hidden reorder form:
fn reorder_form() -> element.Element(t) {
html.form(
[
attribute.id("reorder-form"),
hx.post("/tasks/reorder"),
hx.swap(hx.SwapNone),
],
[
html.input([
attribute.type_("hidden"),
attribute.id("reorder-input"),
attribute.name("order"),
attribute.value(""),
]),
],
)
}And the server handler:
fn reorder_tasks(req: wisp.Request, ctx: Context) -> wisp.Response {
use form_data <- wisp.require_form(req)
let order_string =
list.key_find(form_data.values, "order")
|> result.unwrap("")
// Split "3,1,2,5,4" into ["3", "1", "2", "5", "4"]
let ids = string.split(order_string, ",")
// Update positions in the database.
// Each ID gets a position equal to its index in the list.
list.index_map(ids, fn(id, index) {
actor.send(ctx.tasks, SetTaskPosition(id, index))
})
wisp.html_response("", 204)
}The server receives a comma-separated list of task IDs in the new order. It splits the string and updates each task's position in the database. The response is 204 No Content -- there is nothing to swap because the DOM is already correct.
The CSS for drag feedback:
.task-item {
cursor: grab;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.task-item.dragging {
opacity: 0.4;
transform: scale(0.98);
}
.task-item:active {
cursor: grabbing;
}The .dragging class dims the element while it is being dragged. The cursor
changes from grab to grabbing on mouse down. These are small touches that make
the interaction feel polished.
Edge case: dragend. What if the user drags an element outside the list and
releases? The drop event will not fire (because there is no valid drop target).
The element will be stuck with the .dragging class. Add a dragend handler to
clean up:
on dragend
remove .dragging from me
This fires whenever the drag operation ends, whether the drop was successful or not. Add it to the task item's _hyperscript alongside the other handlers.
_hyperscript is excellent for the patterns we have covered so far. But there is a threshold where it stops being the right tool. Knowing where that threshold is will save you from writing _hyperscript that should have been JavaScript.
Signs you should switch to JavaScript:
-
More than 15-20 lines. If the _hyperscript for a single element exceeds fifteen or twenty lines, the code is hard to read, hard to debug, and hard to maintain. The drag-and-drop pattern above is close to the limit.
-
Complex data transformations. _hyperscript has no
map,filter, orreduce. If you need to transform an array or process structured data, use JavaScript. _hyperscript'sforloop can iterate, but it cannot produce a new array or compute an aggregate in a readable way. -
Third-party API integration. If you are calling multiple methods on a complex JavaScript object (e.g. a charting library, a WebSocket client, a file upload manager), writing those calls in _hyperscript quickly becomes awkward. The
callcommand works for single function calls, but chaining multiple calls with error handling is verbose. -
Shared logic. If two or more elements need the same complex behaviour, a JavaScript function is easier to share and test than duplicating _hyperscript strings. (_hyperscript does have
installandbehaviorfor reuse, but for complex logic, JavaScript modules are a better fit.) -
You need a debugger. _hyperscript does not integrate with browser dev tools. You cannot set breakpoints in _hyperscript code, inspect variable values mid-execution, or step through logic. If you are debugging complex behaviour, JavaScript gives you the full power of the browser's debugger.
The extraction pattern:
When you hit the threshold, the migration path is straightforward:
- Write a JavaScript function in a separate file (e.g.
priv/static/js/reorder.js). - Include the script in your layout.
- Replace the _hyperscript with a single
callto your function:
Before (_hyperscript):
<div _="on dragstart set window.draggedEl to me ... (15+ lines)">After (JavaScript + _hyperscript bridge):
<div _="on dragstart call handleDragStart(me, event)
on dragover call handleDragOver(event)
on dragenter call handleDragEnter(me, event)
on drop call handleDrop(me, event)">The _hyperscript still handles the wiring -- listening for events and dispatching to JavaScript functions. The logic lives in JavaScript where it can be debugged, tested, and shared.
This is a clean separation:
- _hyperscript: event wiring, class toggling, simple feedback.
- JavaScript: complex logic, data transformation, third-party integration.
- HTMX: server communication, DOM swapping.
- Server (Gleam): business logic, validation, persistence.
Each layer does what it does best.
Decision flowchart:
Is the behaviour purely client-side?
├── No → Use HTMX (server round-trip)
└── Yes
├── < 10 lines of logic?
│ └── Yes → Use _hyperscript
├── 10-20 lines?
│ └── Maybe → _hyperscript if straightforward, JS if complex
└── > 20 lines?
└── Use JavaScript
A single element can have multiple _hyperscript behaviours. The _ attribute is a
single string, but you can include as many on handlers as you need, separated by
newlines:
on click ...
on mouseenter ...
on mouseleave ...
on htmx:afterSwap ...
_hyperscript processes all handlers independently. An on click handler does not
interfere with an on mouseenter handler. They share the element's variable scope
(:variables), which is useful for coordination but requires care to avoid naming
collisions.
Example: a task card with three behaviours.
on mouseenter add .hovered to me
on mouseleave remove .hovered from me
on click toggle .selected on me
Three independent behaviours, zero conflicts. The mouseenter/mouseleave pair handles hover styling. The click handler toggles selection. They coexist because they respond to different events and manipulate different classes.
Conflict scenario: what if two handlers manipulate the same class?
on click add .active to me
on htmx:afterSwap remove .active from me
This works correctly because the events fire at different times. The click handler
adds .active when the user clicks. The HTMX handler removes it when the swap
completes. They cooperate rather than conflict.
The real danger is two handlers on the same event that contradict each other:
on click add .open to me
on click remove .open from me
Both fire on click. One adds the class, the other removes it. The result depends on
execution order (which is the order they appear in the _ attribute). In this case,
the class would be added and then immediately removed -- the element would never
appear open. Use a single on click toggle .open on me instead.
The install keyword for reuse.
If you find yourself copying the same _hyperscript across many elements,
_hyperscript provides the install keyword to define reusable behaviours:
<script type="text/hyperscript">
behavior Hoverable
on mouseenter add .hovered to me
on mouseleave remove .hovered from me
end
</script>Then install the behaviour on any element:
<div _="install Hoverable">Task 1</div>
<div _="install Hoverable">Task 2</div>
<div _="install Hoverable">Task 3</div>You can install multiple behaviours:
<div _="install Hoverable install Draggable">Task 1</div>And combine installed behaviours with inline handlers:
<div _="install Hoverable
on click toggle .selected on me">
Task 1
</div>In Gleam, the behaviour definition goes in the layout's head as a script block:
html.script(
[attribute.type_("text/hyperscript")],
"behavior Hoverable
on mouseenter add .hovered to me
on mouseleave remove .hovered from me
end
behavior Draggable
on dragstart set window.draggedEl to me then add .dragging to me
on dragend remove .dragging from me
end",
)And elements install the behaviours:
html.div(
[
attribute.class("task-item"),
attribute.attribute("_", "install Hoverable install Draggable"),
],
[element.text(task.title)],
)Behaviours are especially useful when the _hyperscript code is longer than a few
lines. Instead of repeating ten lines of drag-and-drop code on every task item, you
define a Draggable behaviour once and install it everywhere.
When to use install vs const strings:
install(behaviour): when the same behaviour applies to many elements and you want a semantic name. The behaviour is defined in a<script>block and installed by name. It reads well:install Draggable.- Const string: when you are rendering elements from a Gleam function and the
_hyperscript is already in a variable. The const string is simpler -- no extra
<script>block needed. But it lacks the semantic naming.
Both approaches work. Use whichever fits your project's style.
We are going to enhance the Teamwork task board with all five patterns from the theory section. Each step builds on the previous one, and by the end, a single task card will have a character counter on its edit form, a copy-link button, and drag-to-reorder -- all coexisting without conflicts.
The task creation form currently has a title field. We are adding a description field with a 200-character limit and a live counter.
fn add_task_form() -> element.Element(t) {
html.div([attribute.id("form-container")], [
html.form(
[
hx.post("/tasks"),
hx.target(hx.Selector("#task-list")),
hx.swap(hx.Beforeend),
],
[
// Title field
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("title")],
[element.text("Task title")],
),
html.input([
attribute.type_("text"),
attribute.name("title"),
attribute.id("title"),
attribute.placeholder("What needs to be done?"),
]),
]),
// Description field with character counter
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("description")],
[element.text("Description (optional)")],
),
html.textarea(
[
attribute.id("description"),
attribute.name("description"),
attribute.maxlength(200),
attribute.placeholder("Add details..."),
attribute.attribute("_", "init
put my value's length into #char-count's textContent
on input
put my value's length into #char-count's textContent
if my value's length > 180
add .warning to #char-count
else
remove .warning from #char-count
end"),
],
"",
),
html.div([attribute.class("char-counter-row")], [
html.span(
[attribute.id("char-count"), attribute.class("char-counter")],
[element.text("0")],
),
html.span(
[attribute.class("char-limit")],
[element.text("/ 200")],
),
]),
]),
// Submit
html.button(
[attribute.type_("submit"), attribute.class("btn")],
[element.text("Add Task")],
),
],
),
])
}The key details:
attribute.maxlength(200)-- anInt, not aString. This is the Gleam API. The browser enforces the limit, and the _hyperscript counter provides visual feedback.attribute.attribute("_", "...")-- the _hyperscript code as a plain string. This is how every_attribute is set in Lustre.- The
initblock sets the counter on page load. Theon inputblock updates it on every change. - The counter and limit are in a wrapper div with
class="char-counter-row"for layout (flexbox, aligned right).
The CSS additions:
.char-counter-row {
display: flex;
justify-content: flex-end;
gap: 0;
margin-top: 0.25rem;
}
.char-counter {
font-size: 0.85rem;
color: #718096;
transition: color 0.2s ease;
}
.char-counter.warning {
color: #e53e3e;
font-weight: 600;
}
.char-limit {
font-size: 0.85rem;
color: #a0aec0;
}The board settings page uses the exclusive accordion pattern. Each section contains a form that submits to the server with HTMX.
fn settings_page() -> element.Element(t) {
html.div([attribute.class("settings-page")], [
html.h2([], [element.text("Board Settings")]),
html.div([attribute.class("accordion")], [
// General section
accordion_section(
"General",
html.form(
[
hx.post("/settings/general"),
hx.target(hx.Selector("#settings-feedback")),
hx.swap(hx.InnerHTML),
],
[
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("board-name")],
[element.text("Board name")],
),
html.input([
attribute.type_("text"),
attribute.name("board_name"),
attribute.id("board-name"),
attribute.value("Teamwork"),
]),
]),
html.button(
[attribute.type_("submit"), attribute.class("btn")],
[element.text("Save")],
),
],
),
),
// Notifications section
accordion_section(
"Notifications",
html.form(
[
hx.post("/settings/notifications"),
hx.target(hx.Selector("#settings-feedback")),
hx.swap(hx.InnerHTML),
],
[
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("email-notify")],
[element.text("Email notifications")],
),
html.select(
[attribute.name("email_notify"), attribute.id("email-notify")],
[
html.option(
[attribute.value("all")],
"All activity",
),
html.option(
[attribute.value("mentions")],
"Mentions only",
),
html.option(
[attribute.value("none")],
"None",
),
],
),
]),
html.button(
[attribute.type_("submit"), attribute.class("btn")],
[element.text("Save")],
),
],
),
),
// Permissions section
accordion_section(
"Permissions",
html.div([], [
html.p([], [element.text(
"Manage roles and invite links for this board.",
)]),
html.a(
[
attribute.href("/settings/permissions"),
attribute.class("btn"),
],
[element.text("Manage Permissions")],
),
]),
),
]),
// Feedback area for HTMX responses
html.div([attribute.id("settings-feedback")], []),
])
}The accordion_section function was defined in the theory section. Each section's
content is different -- one has a text input form, one has a select dropdown, and
one has a link. The accordion behaviour is identical across all of them because it
lives in the reusable accordion_section function.
The HTMX forms inside the accordion submit to different endpoints but target the
same feedback area (#settings-feedback). This is a clean pattern: the accordion
handles the open/close UI with _hyperscript, and the forms handle server
communication with HTMX.
Every task item gets a copy-link button. We update the task item renderer to include it:
fn task_item(task: Task) -> element.Element(t) {
html.div(
[
attribute.id("task-" <> task.id),
attribute.class("task-item"),
attribute.attribute("data-id", task.id),
],
[
html.div([attribute.class("task-content")], [
html.span(
[attribute.class("task-title")],
[element.text(task.title)],
),
case task.description {
"" -> element.none()
desc ->
html.p(
[attribute.class("task-description")],
[element.text(desc)],
)
},
]),
html.div([attribute.class("task-actions")], [
// Copy link button
copy_link_button(task.id),
// Delete button
html.button(
[
attribute.class("btn btn-danger btn-small"),
hx.delete("/tasks/" <> task.id),
hx.target(hx.Selector("#task-" <> task.id)),
hx.swap(hx.OuterHTML),
],
[element.text("Delete")],
),
]),
],
)
}The copy_link_button function was defined in section 1.4. It uses
attribute.attribute("data-url", url) to store the URL and _hyperscript to
handle the copy, feedback, and error states.
Notice the use of element.none() for the conditional description. If the task
has no description (empty string), element.none() renders nothing. This is the
correct Lustre API for conditional rendering -- no if expression that returns
html.text("") (which would insert an empty text node), but element.none()
which inserts nothing at all.
Now we upgrade the task list to support drag-and-drop reordering. This requires changes to the task item renderer, the task list container, and the addition of the hidden reorder form.
First, update the task item to be draggable. We combine the drag handlers with the existing task content and actions:
const drag_hyperscript = "on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list
on dragend
remove .dragging from me"
fn draggable_task_item(task: Task) -> element.Element(t) {
html.div(
[
attribute.id("task-" <> task.id),
attribute.class("task-item"),
attribute.attribute("draggable", "true"),
attribute.attribute("data-id", task.id),
attribute.attribute("_", drag_hyperscript),
],
[
// Drag handle
html.span(
[attribute.class("drag-handle")],
[element.text(":::")],
),
// Task content
html.div([attribute.class("task-content")], [
html.span(
[attribute.class("task-title")],
[element.text(task.title)],
),
]),
// Task actions
html.div([attribute.class("task-actions")], [
copy_link_button(task.id),
html.button(
[
attribute.class("btn btn-danger btn-small"),
hx.delete("/tasks/" <> task.id),
hx.target(hx.Selector("#task-" <> task.id)),
hx.swap(hx.OuterHTML),
],
[element.text("Delete")],
),
]),
],
)
}Key points:
attribute.attribute("draggable", "true")-- this is the HTML drag-and-drop API activation. The value must be the string"true", not a boolean. In Lustre, we useattribute.attribute()(not a typed helper) becausedraggableis not in Lustre's standard attribute set.- The drag handle (
:::) is a visual affordance. Users see it and understand that dragging is possible. You could use a grip icon SVG instead. - The
drag_hyperscriptconst includes theon dragendcleanup handler from the edge case discussion in section 1.5.
Now the task list container with the reorder listener:
fn task_list_view(tasks: List(Task)) -> element.Element(t) {
html.div([], [
html.div(
[
attribute.id("task-list"),
attribute.class("task-list"),
attribute.attribute("_", "on reorder
set ids to []
for item in .task-item in me
call ids.push(item.dataset.id)
end
set #reorder-input's value to ids.join(',')
send submit to #reorder-form"),
],
list.map(tasks, draggable_task_item),
),
// Hidden form for persisting reorder
html.form(
[
attribute.id("reorder-form"),
hx.post("/tasks/reorder"),
hx.swap(hx.SwapNone),
],
[
html.input([
attribute.type_("hidden"),
attribute.id("reorder-input"),
attribute.name("order"),
attribute.value(""),
]),
],
),
])
}The reorder route handler:
fn reorder_tasks(req: wisp.Request, ctx: Context) -> wisp.Response {
use form_data <- wisp.require_form(req)
let order_string =
list.key_find(form_data.values, "order")
|> result.unwrap("")
let ids = string.split(order_string, ",")
// Update each task's position in the store
list.index_map(ids, fn(id, index) {
actor.send(ctx.tasks, SetTaskPosition(id, index))
})
// No content -- the DOM is already in the correct order
wisp.html_response("", 204)
}Add the route to the router:
["tasks", "reorder"] -> {
use <- wisp.require_method(req, http.Post)
reorder_tasks(req, ctx)
}The CSS for drag handles and drag state:
.drag-handle {
cursor: grab;
color: #a0aec0;
font-weight: bold;
letter-spacing: 2px;
user-select: none;
padding: 0 0.5rem;
}
.drag-handle:active {
cursor: grabbing;
}
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
margin-bottom: 0.5rem;
background: white;
transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.task-item.dragging {
opacity: 0.4;
transform: scale(0.98);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.task-content {
flex: 1;
}
.task-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}Now let us combine everything. A fully-featured task card has:
- Drag-and-drop for reordering.
- Copy-link for sharing.
- Hover highlight for visual feedback.
- Inline edit trigger (from Chapter 21).
- Delete via HTMX.
Five distinct behaviours on a single element. The _hyperscript handles the first three (plus inline edit triggering). HTMX handles the delete. They coexist because they respond to different events and manipulate different properties.
Here is the full task card:
const multi_behavior_hyperscript = "on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list
on dragend
remove .dragging from me
on mouseenter
add .hovered to me
on mouseleave
remove .hovered from me"
fn full_task_card(task: Task) -> element.Element(t) {
html.div(
[
attribute.id("task-" <> task.id),
attribute.class("task-item"),
attribute.attribute("draggable", "true"),
attribute.attribute("data-id", task.id),
attribute.attribute("_", multi_behavior_hyperscript),
],
[
// Drag handle
html.span(
[attribute.class("drag-handle")],
[element.text(":::")],
),
// Task content (double-click to inline edit, from Chapter 21)
html.div(
[
attribute.class("task-content"),
hx.get("/tasks/" <> task.id <> "/edit"),
hx.trigger([hx.custom("dblclick")]),
hx.target(hx.Selector("#task-" <> task.id)),
hx.swap(hx.OuterHTML),
],
[
html.span(
[attribute.class("task-title")],
[element.text(task.title)],
),
case task.description {
"" -> element.none()
desc ->
html.p(
[attribute.class("task-description")],
[element.text(desc)],
)
},
],
),
// Actions
html.div([attribute.class("task-actions")], [
copy_link_button(task.id),
html.button(
[
attribute.class("btn btn-danger btn-small"),
hx.delete("/tasks/" <> task.id),
hx.target(hx.Selector("#task-" <> task.id)),
hx.swap(hx.OuterHTML),
],
[element.text("Delete")],
),
]),
],
)
}Let us count the behaviours:
- Drag-and-drop:
on dragstart,on dragover,on dragenter,on drop,on dragend-- five handlers in the _hyperscript. - Hover highlight:
on mouseenter,on mouseleave-- two handlers. - Copy-link: on the child button element (separate
_attribute). - Inline edit:
hx-getwithhx-trigger="dblclick"on the content div -- an HTMX attribute, not _hyperscript. - Delete:
hx-deleteon the delete button -- another HTMX attribute.
The _hyperscript behaviours (1 and 2) live on the outer task card div. The copy-link behaviour (3) lives on its own button. The HTMX behaviours (4 and 5) live on the content div and the delete button, respectively.
There are zero conflicts because:
- Each behaviour responds to different events.
- Each behaviour manipulates different classes or targets different elements.
- The _hyperscript handlers do not interfere with HTMX attributes.
- HTMX and _hyperscript are designed to coexist -- _hyperscript reinitializes after every HTMX swap.
The only coordination point is the drag handle. When the user grabs the drag handle, the drag events fire. When they double-click the task content, the inline edit triggers. These are spatially separated (handle on the left, content in the middle), so the user's intent is clear.
Here is the complete code for all five patterns, integrated into the Teamwork application.
import gleam/http
import gleam/int
import gleam/list
import gleam/otp/actor
import gleam/result
import gleam/string
import lustre/attribute.{attribute}
import lustre/element
import lustre/element/html
import wisp.{type Request, type Response}
import hx
import teamwork/context.{type Context}
import teamwork/task.{type Task, Task, SetTaskPosition}
// ── _hyperscript Constants ──────────────────────────────────────────────
const char_counter_hs = "init
put my value's length into #char-count's textContent
on input
put my value's length into #char-count's textContent
if my value's length > 180
add .warning to #char-count
else
remove .warning from #char-count
end"
const accordion_header_hs = "on click
remove .open from .accordion-section in closest .accordion
toggle .open on closest .accordion-section"
const drag_item_hs = "on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list
on dragend
remove .dragging from me
on mouseenter
add .hovered to me
on mouseleave
remove .hovered from me"
const reorder_listener_hs = "on reorder
set ids to []
for item in .task-item in me
call ids.push(item.dataset.id)
end
set #reorder-input's value to ids.join(',')
send submit to #reorder-form"
const copy_btn_hs = "on click
call navigator.clipboard.writeText(my dataset.url)
catch e
add .copy-error to me
put 'Failed to copy' into me
wait 2s
remove .copy-error from me
put 'Copy Link' into me
halt
end
add .copied to me
put 'Copied!' into me
wait 2s
remove .copied from me
put 'Copy Link' into me"
// ── Layout ──────────────────────────────────────────────────────────────
fn layout(content: element.Element(t)) -> Response {
let page =
html.html([], [
html.head([], [
html.title([], "Teamwork -- Task Board"),
// HTMX
html.script(
[attribute.src("https://unpkg.com/htmx.org@2.0.8")],
"",
),
// _hyperscript
html.script(
[attribute.src("https://unpkg.com/hyperscript.org@0.9.14")],
"",
),
// Stylesheets
html.link([
attribute.rel("stylesheet"),
attribute.href("/static/css/style.css"),
]),
]),
html.body([], [
html.div([attribute.class("container")], [
html.h1([], [element.text("Teamwork Task Board")]),
content,
]),
]),
])
let body = element.to_document_string(page)
wisp.html_response(body, 200)
}
// ── Components ──────────────────────────────────────────────────────────
fn copy_link_button(task_id: String) -> element.Element(t) {
let url = "/tasks/" <> task_id
html.button(
[
attribute.class("btn btn-small copy-link-btn"),
attribute.attribute("data-url", url),
attribute.attribute("_", copy_btn_hs),
],
[element.text("Copy Link")],
)
}
fn accordion_section(
title: String,
content: element.Element(t),
) -> element.Element(t) {
html.div([attribute.class("accordion-section")], [
html.button(
[
attribute.class("accordion-header"),
attribute.attribute("_", accordion_header_hs),
],
[element.text(title)],
),
html.div(
[attribute.class("accordion-body")],
[content],
),
])
}
fn task_card(task: Task) -> element.Element(t) {
html.div(
[
attribute.id("task-" <> task.id),
attribute.class("task-item"),
attribute.attribute("draggable", "true"),
attribute.attribute("data-id", task.id),
attribute.attribute("_", drag_item_hs),
],
[
// Drag handle
html.span(
[attribute.class("drag-handle")],
[element.text(":::")],
),
// Task content (double-click to inline edit)
html.div(
[
attribute.class("task-content"),
hx.get("/tasks/" <> task.id <> "/edit"),
hx.trigger([hx.custom("dblclick")]),
hx.target(hx.Selector("#task-" <> task.id)),
hx.swap(hx.OuterHTML),
],
[
html.span(
[attribute.class("task-title")],
[element.text(task.title)],
),
case task.description {
"" -> element.none()
desc ->
html.p(
[attribute.class("task-description")],
[element.text(desc)],
)
},
],
),
// Actions
html.div([attribute.class("task-actions")], [
copy_link_button(task.id),
html.button(
[
attribute.class("btn btn-danger btn-small"),
hx.delete("/tasks/" <> task.id),
hx.target(hx.Selector("#task-" <> task.id)),
hx.swap(hx.OuterHTML),
],
[element.text("Delete")],
),
]),
],
)
}
// ── Forms ───────────────────────────────────────────────────────────────
fn add_task_form() -> element.Element(t) {
html.div([attribute.id("form-container")], [
html.form(
[
hx.post("/tasks"),
hx.target(hx.Selector("#task-list")),
hx.swap(hx.Beforeend),
],
[
// Title
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("title")],
[element.text("Task title")],
),
html.input([
attribute.type_("text"),
attribute.name("title"),
attribute.id("title"),
attribute.placeholder("What needs to be done?"),
]),
]),
// Description with character counter
html.div([attribute.class("form-group")], [
html.label(
[attribute.for("description")],
[element.text("Description (optional)")],
),
html.textarea(
[
attribute.id("description"),
attribute.name("description"),
attribute.maxlength(200),
attribute.placeholder("Add details..."),
attribute.attribute("_", char_counter_hs),
],
"",
),
html.div([attribute.class("char-counter-row")], [
html.span(
[attribute.id("char-count"), attribute.class("char-counter")],
[element.text("0")],
),
html.span(
[attribute.class("char-limit")],
[element.text("/ 200")],
),
]),
]),
// Submit
html.button(
[attribute.type_("submit"), attribute.class("btn")],
[element.text("Add Task")],
),
],
),
])
}
fn reorder_form() -> element.Element(t) {
html.form(
[
attribute.id("reorder-form"),
hx.post("/tasks/reorder"),
hx.swap(hx.SwapNone),
],
[
html.input([
attribute.type_("hidden"),
attribute.id("reorder-input"),
attribute.name("order"),
attribute.value(""),
]),
],
)
}
// ── Pages ───────────────────────────────────────────────────────────────
fn home_page(ctx: Context) -> Response {
let tasks = actor.call(ctx.tasks, 1000, task.GetTasks)
let content =
html.div([], [
add_task_form(),
html.div(
[
attribute.id("task-list"),
attribute.class("task-list"),
attribute.attribute("_", reorder_listener_hs),
],
list.map(tasks, task_card),
),
reorder_form(),
])
layout(content)
}
fn settings_page() -> Response {
let content =
html.div([attribute.class("settings-page")], [
html.h2([], [element.text("Board Settings")]),
html.div([attribute.class("accordion")], [
accordion_section(
"General",
html.p([], [element.text(
"Board name, description, visibility settings.",
)]),
),
accordion_section(
"Notifications",
html.p([], [element.text(
"Email notifications, Slack integration, digest frequency.",
)]),
),
accordion_section(
"Permissions",
html.p([], [element.text(
"Role assignments, invite links, guest access.",
)]),
),
]),
])
layout(content)
}
// ── Handlers ────────────────────────────────────────────────────────────
fn create_task(req: Request, ctx: Context) -> Response {
use form_data <- wisp.require_form(req)
let title =
list.key_find(form_data.values, "title")
|> result.unwrap("")
let description =
list.key_find(form_data.values, "description")
|> result.unwrap("")
let id = int.to_string(int.random(100_000))
let new_task = Task(id: id, title: title, description: description, done: False, position: 0)
actor.send(ctx.tasks, task.AddTask(new_task))
let card = task_card(new_task)
wisp.html_response(element.to_string(card), 201)
|> wisp.set_header("HX-Trigger", "taskAdded")
}
fn delete_task(id: String, ctx: Context) -> Response {
actor.send(ctx.tasks, task.DeleteTask(id))
wisp.html_response("", 200)
|> wisp.set_header("HX-Trigger", "taskAdded")
}
fn reorder_tasks(req: Request, ctx: Context) -> Response {
use form_data <- wisp.require_form(req)
let order_string =
list.key_find(form_data.values, "order")
|> result.unwrap("")
let ids = string.split(order_string, ",")
list.index_map(ids, fn(id, index) {
actor.send(ctx.tasks, SetTaskPosition(id, index))
})
wisp.html_response("", 204)
}
// ── Router ──────────────────────────────────────────────────────────────
pub fn handle_request(req: Request, ctx: Context) -> Response {
use <- wisp.serve_static(req, under: "/static", from: "priv/static")
case wisp.path_segments(req) {
[] -> home_page(ctx)
["tasks"] -> {
use <- wisp.require_method(req, http.Post)
create_task(req, ctx)
}
["tasks", "reorder"] -> {
use <- wisp.require_method(req, http.Post)
reorder_tasks(req, ctx)
}
["tasks", id] -> {
use <- wisp.require_method(req, http.Delete)
delete_task(id, ctx)
}
["settings"] -> settings_page()
_ -> wisp.not_found()
}
}/* ── Character Counter ── */
.char-counter-row {
display: flex;
justify-content: flex-end;
gap: 0;
margin-top: 0.25rem;
}
.char-counter {
font-size: 0.85rem;
color: #718096;
transition: color 0.2s ease;
}
.char-counter.warning {
color: #e53e3e;
font-weight: 600;
}
.char-limit {
font-size: 0.85rem;
color: #a0aec0;
}
/* ── Accordion ── */
.accordion {
border: 1px solid #e2e8f0;
border-radius: 6px;
overflow: hidden;
}
.accordion-section + .accordion-section {
border-top: 1px solid #e2e8f0;
}
.accordion-header {
display: block;
width: 100%;
padding: 0.85rem 1rem;
background: #f7fafc;
border: none;
text-align: left;
font-size: 1rem;
font-weight: 600;
color: #2d3748;
cursor: pointer;
transition: background-color 0.15s ease;
}
.accordion-header:hover {
background: #edf2f7;
}
.accordion-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
padding: 0 1rem;
}
.accordion-section.open .accordion-body {
max-height: 500px;
padding: 1rem;
}
/* ── Clipboard Copy ── */
.copy-link-btn {
transition: background-color 0.2s ease, color 0.2s ease;
}
.copy-link-btn.copied {
background-color: #48bb78;
color: white;
}
.copy-link-btn.copy-error {
background-color: #e53e3e;
color: white;
}
/* ── Drag and Drop ── */
.drag-handle {
cursor: grab;
color: #a0aec0;
font-weight: bold;
letter-spacing: 2px;
user-select: none;
padding: 0 0.5rem;
}
.drag-handle:active {
cursor: grabbing;
}
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
margin-bottom: 0.5rem;
background: white;
transition: opacity 0.2s ease, transform 0.15s ease, box-shadow 0.15s ease;
}
.task-item.dragging {
opacity: 0.4;
transform: scale(0.98);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.task-item.hovered {
background: #f7fafc;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.task-content {
flex: 1;
cursor: default;
}
.task-title {
font-weight: 500;
}
.task-description {
font-size: 0.85rem;
color: #718096;
margin-top: 0.25rem;
}
.task-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* ── Settings ── */
.settings-page {
max-width: 600px;
}Extend the character counter so that it changes colour in three stages:
- 0-150 characters: default grey colour (no class).
- 151-180 characters: add a
.cautionclass (yellow/orange). - 181-200 characters: add a
.warningclass (red).
You will need to extend the if/else to an if/else if/else chain. The
_hyperscript syntax for this is:
if condition1
...
else if condition2
...
else
...
end
Make sure to remove both .caution and .warning in the "safe" branch (0-150),
so that the counter resets correctly when the user deletes text.
Modify the accordion so that the first section starts open when the page loads. You will need two things:
- Add the
.openclass to the firstaccordion-sectionin the Gleam code. - Ensure the _hyperscript still works correctly -- clicking the first section should close it, and clicking another should open that one and close the first.
Test by loading the page (first section should be open), clicking the second section (first should close, second should open), and clicking the second section again (to verify it toggles correctly).
Modify the clipboard copy button so that it copies the full URL (including the
domain) instead of just the relative path. Use the _hyperscript expression
window.location.origin to construct the full URL at copy time:
call navigator.clipboard.writeText(window.location.origin + my dataset.url)
Verify by clicking "Copy Link" and pasting the result. It should be something like
http://localhost:8000/tasks/42 instead of just /tasks/42.
Enhance the drag-and-drop pattern so that when the user drags a task, a visual
"drop zone" indicator appears between items. Add a .drop-indicator class to the
target element during dragenter, and remove it on dragleave and drop.
The CSS for the indicator:
.task-item.drop-indicator {
border-top: 3px solid #4a6fa5;
}You will need to add:
- An
on dragenteraddition:add .drop-indicator to me. - An
on dragleavehandler:remove .drop-indicator from me. - Cleanup in
on drop:remove .drop-indicator from .task-item in closest .task-list.
The drag-and-drop _hyperscript is getting long. Extract it into a named
behavior Draggable in a <script type="text/hyperscript"> block. Then
replace the inline _hyperscript on each task item with
install Draggable.
Add the behaviour definition to the layout's <head>:
html.script(
[attribute.type_("text/hyperscript")],
"behavior Draggable
...
end",
)Verify that drag-and-drop still works after the extraction. The behaviour should be functionally identical to the inline version.
Try each exercise on your own before reading these hints.
The three-stage counter uses nested conditions. Remove both classes in the safe branch to handle the user deleting text:
on input
put my value's length into #char-count's textContent
if my value's length > 180
remove .caution from #char-count
add .warning to #char-count
else if my value's length > 150
remove .warning from #char-count
add .caution to #char-count
else
remove .warning from #char-count
remove .caution from #char-count
end
Each branch explicitly removes the class it does not want. This prevents the
scenario where a user types past 180, gets .warning, deletes back to 160, and
still has .warning lingering because only .caution was added.
In Gleam, pass a boolean parameter to the accordion_section function:
fn accordion_section(
title: String,
content: element.Element(t),
open: Bool,
) -> element.Element(t) {
let section_class = case open {
True -> "accordion-section open"
False -> "accordion-section"
}
html.div([attribute.class(section_class)], [
// ... same as before
])
}Then call it with True for the first section:
accordion_section("General", general_content, True),
accordion_section("Notifications", notif_content, False),
accordion_section("Permissions", perms_content, False),The _hyperscript does not need to change. The toggle .open command works
correctly regardless of the initial state -- if the first section starts with
.open, clicking another section removes .open from all sections (including
the first) and then toggles it on the clicked one.
The change is in the call expression. Instead of:
call navigator.clipboard.writeText(my dataset.url)
Use:
call navigator.clipboard.writeText(window.location.origin + my dataset.url)
The + operator in _hyperscript delegates to JavaScript's +, which concatenates
strings. window.location.origin returns "http://localhost:8000" in development,
and your actual domain in production.
Add the visual indicator classes. The key insight is that you need to clean up
the indicator on both dragleave (cursor leaves the element) and drop (item
is dropped). The dragleave handler goes on each task item:
on dragleave
remove .drop-indicator from me
And in the on drop handler, clean up all indicators in the list:
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
remove .drop-indicator from .task-item in closest .task-list
send reorder to closest .task-list
The on dragenter handler adds the indicator:
on dragenter
add .drop-indicator to me
if me is not window.draggedEl ...
Put the indicator addition before the position-swap logic so the visual feedback appears immediately.
The behaviour definition wraps the existing _hyperscript in a named block:
<script type="text/hyperscript">
behavior Draggable
on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list
on dragend
remove .dragging from me
end
</script>Then each task item becomes:
attribute.attribute("_", "install Draggable
on mouseenter add .hovered to me
on mouseleave remove .hovered from me"),The install keyword adds the behaviour, and you can still add inline handlers
after it. The hover behaviour is short enough to stay inline.
In Gleam, add the <script> block to the layout:
html.script(
[attribute.type_("text/hyperscript")],
"behavior Draggable
on dragstart
set window.draggedEl to me
add .dragging to me
on dragover
call event.preventDefault()
on dragenter
if me is not window.draggedEl and I do not match .drag-placeholder
if me.getBoundingClientRect().top < window.draggedEl.getBoundingClientRect().top
call me.parentElement.insertBefore(window.draggedEl, me)
else
call me.parentElement.insertBefore(window.draggedEl, me.nextSibling)
end
end
on drop
call event.preventDefault()
remove .dragging from window.draggedEl
send reorder to closest .task-list
on dragend
remove .dragging from me
end",
)-
_hyperscript recipes are 1-15 lines. Every pattern in this chapter fits in a single
_attribute. If your _hyperscript exceeds fifteen lines, consider extracting the logic to JavaScript and using _hyperscript only for event wiring. -
Element-scoped variables (
:variable) are component state. The character counter and clipboard-copy patterns both use the element itself as the source of truth. _hyperscript's:prefix stores data directly on the DOM element, surviving across event firings without global state. -
put ... into ...is the workhorse command. It updates text content, input values, innerHTML, and variables. It reads left-to-right: "put this value into that target." Most patterns use it at least once. -
CSS does the animation; _hyperscript toggles the class. The accordion animates with
max-heighttransitions. The drag feedback usesopacityandtransformtransitions. _hyperscript's job is to add and remove classes at the right time -- CSS handles the visual effect. Keep this separation clean. -
callis the JavaScript escape hatch. The clipboard API, DOM manipulation (insertBefore), andgetBoundingClientRect()are all JavaScript interop viacall. When _hyperscript lacks a native command for something,calllets you reach into JavaScript without leaving the_attribute. -
Multiple
onhandlers coexist without conflicts. A single element can have handlers fordragstart,dragover,dragenter,drop,dragend,mouseenter,mouseleave, and more. They share the element's:variablesbut operate independently on different events. Conflicts only arise when two handlers for the same event contradict each other. -
Named behaviours (
install) reduce duplication. When the same _hyperscript applies to many elements, define abehaviorin a script block andinstallit. This keeps the inline_attributes short and gives the behaviour a semantic name. -
_hyperscript and HTMX complement each other. _hyperscript handles client-side concerns (class toggling, drag feedback, clipboard copy). HTMX handles server communication (inline edit, delete, reorder persistence). They coexist on the same element without interference because _hyperscript reinitializes after every HTMX swap.
-
Know when to stop. The decision flowchart is simple: purely client-side, under fifteen lines, no complex data transformation -- use _hyperscript. Otherwise, use JavaScript. The drag-and-drop pattern in this chapter is near the upper bound of what _hyperscript should handle. Beyond that, extract to a JavaScript module and bridge with
call.
In Chapter 24 -- Dynamic and Dependent Forms, we will build forms where one
field's value determines the options in another. Think cascading dropdowns: selecting
a project loads its task categories, selecting a category loads its priority levels.
This pattern uses hx-trigger="change" and hx-include to send partial form
state to the server, which returns updated HTML for the dependent fields. It is one
of the most common patterns in real-world applications, and HTMX handles it with
zero JavaScript.