Sortable
stimeo--sortable
Single-list reorder that is genuinely keyboard- and screen-reader-accessible.
The stimeo--sortable controller is the accessible drag-and-drop reorder — the flagship consumer of the pointer-drag primitive. The composition is markup-level: pointer-drag on every item emits the normalized drag signal with its built-in keyboard alternative, roving on the list keeps the handles a single Tab stop, and sortable interprets the signal. With a pointer, the item live-moves whenever the pointer crosses a sibling's midpoint. With the keyboard, Space grabs, each arrow press moves the item one position, Space drops, and Escape restores the pickup position — every step announced through the status live region ("Grabbed Card A, position 2 of 5"), localizable via data-* templates. A drop that changed the position dispatches reorder with { item, from, to } (zero-based) — the hook a real app uses to persist the order (and a Turbo Stream broadcast makes it collaborative). The real DOM nodes move, so the visual order never diverges from the reading order. Multi-list boards, nested trees, edge auto-scroll, and virtualized lists are outside this free single-list scope.
Drag a row by its handle, or focus a handle (Tab, then arrows) and press Space, move with ArrowUp/ArrowDown, Space to drop — Esc cancels.
- Write the release notes
- Fix the login redirect
- Review the design draft
- Update the dependencies
Keyboard
| Key | Action |
|---|---|
| Space / Enter | Grab the focused handle's item, or drop it (dispatching reorder if the position changed). |
| ↑ / ↓ | While grabbed, move the item one position (vertical orientation; clamped at the ends). |
| Esc | Cancel — the item returns to its pickup position. |
<%# sortable: markup-level composition — pointer-drag on each item emits the drag
signal (with its keyboard alternative), roving keeps the handles one Tab stop,
and sortable interprets the signal: it live-reorders the DOM, announces each
step through the status live region (localized via the data-* templates), and
dispatches reorder on a drop that changed the position. demo.js only mirrors
that reorder event into the "saved" line — the pattern a real app uses to POST
the new position. %>
<div class="sortable-demo">
<p class="sortable-demo__hint"><%= t("components.sortable.demo.hint") %></p>
<div data-controller="stimeo--sortable">
<ul class="sortable-demo__list" data-stimeo--sortable-target="list"
data-controller="stimeo--roving"
data-stimeo--roving-orientation-value="vertical"
aria-label="<%= t('components.sortable.demo.list_label') %>">
<% t("components.sortable.demo.items").each do |label| %>
<li class="sortable-demo__item" data-stimeo--sortable-target="item"
data-stimeo--sortable-name="<%= label %>"
data-controller="stimeo--pointer-drag"
data-stimeo--pointer-drag-axis-value="y">
<button type="button" class="sortable-demo__handle"
aria-label="<%= t('components.sortable.demo.handle_label', name: label) %>"
data-stimeo--pointer-drag-target="handle"
data-stimeo--roving-target="item">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<circle cx="9" cy="6" r="1.5"></circle><circle cx="15" cy="6" r="1.5"></circle>
<circle cx="9" cy="12" r="1.5"></circle><circle cx="15" cy="12" r="1.5"></circle>
<circle cx="9" cy="18" r="1.5"></circle><circle cx="15" cy="18" r="1.5"></circle>
</svg>
</button>
<span class="sortable-demo__label"><%= label %></span>
</li>
<% end %>
</ul>
<p class="sortable-demo__status" role="status"
data-stimeo--sortable-target="status"
data-grabbed="<%= t("components.sortable.demo.announce.grabbed") %>"
data-moved="<%= t("components.sortable.demo.announce.moved") %>"
data-dropped="<%= t("components.sortable.demo.announce.dropped") %>"
data-canceled="<%= t("components.sortable.demo.announce.canceled") %>"></p>
</div>
<p class="sortable-demo__saved" data-sortable-saved aria-live="polite"
data-saved-template="<%= t("components.sortable.demo.saved_template") %>"></p>
</div>
/*
* Presentation-only styles for the sortable demo. The library moves the real DOM
* nodes and flips the data hooks; this CSS lays the rows out and makes the two
* transient states visible — data-dragging / data-grabbed on the item (from
* pointer-drag) highlight the row being moved.
*/
.sortable-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.sortable-demo__hint {
margin: 0;
color: var(--muted);
}
.sortable-demo__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
min-width: 16rem;
}
.sortable-demo__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
}
/* The row in flight: pointer drag (data-dragging) or keyboard grab (data-grabbed). */
.sortable-demo__item[data-dragging],
.sortable-demo__item[data-grabbed] {
border-color: var(--accent);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.sortable-demo__handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
color: var(--muted);
cursor: grab;
}
.sortable-demo__handle:hover {
color: var(--accent);
}
.sortable-demo__handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.sortable-demo__item[data-dragging] .sortable-demo__handle {
cursor: grabbing;
}
.sortable-demo__handle .demo-icon {
width: 1rem;
height: 1rem;
}
.sortable-demo__status,
.sortable-demo__saved {
margin: 0;
min-height: 1.25rem;
font-size: 0.9rem;
color: var(--muted);
}
// sortable dispatches reorder only when a drop changed the position — the hook a
// real app uses to persist (POST the new index, or requestSubmit a form). This
// demo mirrors it into the "saved" line instead; copy comes from the localized
// data-* template (values only, never words).
const sortable = document.querySelector("[data-controller~='stimeo--sortable']");
const saved = document.querySelector("[data-sortable-saved]");
sortable?.addEventListener("stimeo--sortable:reorder", (event) => {
const { from, to } = event.detail;
const template = saved?.dataset.savedTemplate || "{from} -> {to}";
if (saved) {
saved.textContent = template
.replace("{from}", String(from + 1))
.replace("{to}", String(to + 1));
}
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--sortable"
Targets
| Name | Description | Attribute |
|---|---|---|
list
|
The reorder container. Optional — without it the controller element is the container. | data-stimeo--sortable-target="list" |
item
required
|
A reorderable item. Give each one a pointer-drag controller (its axis matching orientation). |
data-stimeo--sortable-target="item" |
status
|
The live region announcing each step. Author role="status" (or aria-live); localize via the data-grabbed / data-moved / data-dropped / data-canceled templates. |
data-stimeo--sortable-target="status" |
Values
| Name | Description | Attribute |
|---|---|---|
orientation
|
The list axis (vertical default / horizontal): midpoint math and the keyboard's primary axis. |
data-stimeo--sortable-orientation-value |
Events
| Name | Description | Event |
|---|---|---|
reorder
|
Fires with { item, from, to } (zero-based) on a drop that changed the position — subscribe to persist. |
stimeo--sortable:reorder |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-sortable-dragging |
Controller element | Present while a reorder session is live (pointer or keyboard). |
data-dragging / data-grabbed |
Item (from pointer-drag) | The item in flight — the consumer's CSS hook for highlighting the moving row. |