Sortable
stimeo--sortable
Reordering that works from the keyboard and with a screen reader too.
Lets a list be reordered by pointer and by keyboard alike. With a pointer, items swap as you cross the middle of a neighbour, so where it will land is always visible. From the keyboard, Space picks an item up, the arrow keys move it one position at a time, and Space puts it down, while Esc returns it to where it started. Every step is announced, with wording such as "picked up Card A, position 2 of 5" that you can write per language. The row of handles is a single Tab stop. Only a drop that actually changed the order reports where the item came from and where it went.
Drag a row by its handle, or use the keyboard: Tab to a handle, move between handles with the arrow keys, Space to pick up, up and down to move it, Space again 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, hands each step
to the page's shared announcer (wording from the announce-* values), and
dispatches reorder on a drop that changed the position. demo.js mirrors that
reorder event into the "saved" line — the pattern a real app uses to POST the
new position — and echoes what the announcer heard into a muted line. %>
<div class="sortable-demo">
<p class="sortable-demo__hint"><%= t("components.sortable.demo.hint") %></p>
<% announce = t("components.sortable.demo.announce") %>
<div data-controller="stimeo--sortable"
data-stimeo--sortable-announce-grabbed-text-value="<%= announce[:grabbed] %>"
data-stimeo--sortable-announce-moved-text-value="<%= announce[:moved] %>"
data-stimeo--sortable-announce-dropped-text-value="<%= announce[:dropped] %>"
data-stimeo--sortable-announce-canceled-text-value="<%= announce[:canceled] %>">
<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>
</div>
<p class="sortable-demo__status" data-sortable-heard aria-hidden="true"></p>
<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-700);
}
.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]");
const heard = document.querySelector("[data-sortable-heard]");
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));
}
});
// The library carries no live region of its own: each step is handed to the
// page's shared stimeo--announcer, which is what a screen reader reads. This
// line shows the same message so the demo is followable by eye; aria-hidden on
// it keeps assistive tech from hearing the text twice.
window.addEventListener("stimeo--announcer:announce", (event) => {
if (heard) heard.textContent = event.detail.message;
});
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" |
Values
| Name | Description | Attribute |
|---|---|---|
orientation
|
The list axis (vertical default / horizontal): midpoint math and the keyboard's primary axis. |
data-stimeo--sortable-orientation-value |
announceGrabbedText
|
data-stimeo--sortable-announce-grabbed-text-value |
|
announceMovedText
|
data-stimeo--sortable-announce-moved-text-value |
|
announceDroppedText
|
data-stimeo--sortable-announce-dropped-text-value |
|
announceCanceledText
|
data-stimeo--sortable-announce-canceled-text-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. |