What's Included
index.html: the demo page and the markup to copyassets/style.css: component styles, plain CSS custom properties for coloursassets/script.js: readable, commented source with anonReadyguardRows dragged on the y axis with GSAP Draggable, lifted with a scale, a shadow and a 1px accent ring
A live gap: the rows between the row's old and new index tween aside by exactly one row pitch, so the opening under the pointer is the landing slot
A damped settle on drop, after which every row sits on a whole position with its transform cleared, not parked on a leftover offset
A complete keyboard reorder:
Spaceto lift, arrows to move,Spaceto drop,Escapeto cancel, using the same tweened movesA polite live region that announces the pick-up, every move, the drop and the cancel by name and position
On a coarse pointer the handle is the only drag start, so a finger on the row body still scrolls the page
data-densityswaps the row rhythm; the drag re-measures the pitch on the next press, so nothing needs re-initialisingA bubbling
reorderevent carrying the ordered idsRoles and states used:
<ul>/<li>list semantics, a<button>handle witharia-pressed,aria-describedbyon each handle, and arole="status" aria-live="polite"region
Quick Start
1. Add to your <head>:
Code snippet omitted: it ships with the download.
2. Add before the closing </body> tag:
Code snippet omitted: it ships with the download.
3. Add to your <body>:
Code snippet omitted: it ships with the download.
The script only ever looks for the data- attributes, so every class above is yours to rename:
| Attribute | On | What it does |
|---|---|---|
data-reorder |
the list root | Makes it a sortable list. Every list on the page with this attribute is wired up |
data-reorder-item |
a direct child of the root | A row. data-id is what the reorder event reports |
data-reorder-handle |
a <button> inside a row |
The grab handle: the keyboard entry point, and the only drag start on touch |
data-reorder-title |
an element inside a row | The row's name, used in the announcements. Without it the row's whole text is used |
data-reorder-live |
any element on the page | The live region the announcements are written into. A list prefers one inside its own wrapper, so two lists on a page can each announce into their own, and falls back to the first on the page. Optional; leave it out and the component is silent |
data-density |
the component root | comfortable (default) or compact, or any value of your own; it only re-values the row custom properties |
Rows must be direct children of the [data-reorder] element, and every row needs the same height for the pitch arithmetic to hold.
Two pieces of CSS the component depends on, if you are restyling from scratch: rows must be position: relative so the lifted one can sit above its neighbours on z-index, and no row may carry a CSS transform — GSAP reads one as a pixel y and a tween back to y: 0 never clears it. The handle wants touch-action: none so the browser does not claim the gesture.
The variant switcher, the density buttons and the .stage/.showcase-toolbar furniture in index.html are demo scaffolding, not part of the component; leave them behind.
Keyboard & Accessibility
| Key | What it does |
|---|---|
Tab |
Moves through the handles |
ArrowUp / ArrowDown |
Moves focus to the previous or next handle; while a row is lifted, moves the row itself |
Space / Enter |
Lifts the focused row, or drops the lifted one |
Home / End |
While lifted, sends the row to the top or the bottom |
Escape |
Cancels, returning the row to the position it was picked up from |
- Roles and states: the list is a plain
<ul>of<li>s, so it is announced as a list of seven items. The handle is a<button>whosearia-pressedistruefor exactly as long as the row is lifted, and whose accessible name says which row it reorders. Each handle points at the instruction line witharia-describedby. - Announcements go to a
role="status" aria-live="polite"region:"Design review grabbed. Position 2 of 7. Use the up and down arrow keys to move it, Space to drop, Escape to cancel.", then"Moved Design review to position 3 of 7.", then"Design review dropped at position 3 of 7."or"Reorder cancelled. Design review is back at position 2 of 7." - Moving a row in the DOM blurs the focused handle, so the component puts focus straight back and ignores that one blur. A blur it did not cause drops the row rather than stranding it in the lifted state.
prefers-reduced-motion: the same reordering with every duration at zero. Rows change places instantly; nothing is disabled and nothing is hidden.- Touch: on a coarse pointer only the handle starts a drag, so a swipe anywhere else on a row scrolls the page as usual.
- Without JavaScript the list is still a readable, correctly ordered list.
How It Works
Dragging, and the gap. Each row gets its own Draggable of type: "y", bounded by the list. On press the component reads the row pitch from offsetTop (layout, so transforms cannot skew it), which is why a density change needs no invalidation of its own. During the drag the target slot is Math.round(this.y / pitch) added to the row's index and clamped to the list, which is the same thing as crossing each neighbour's midpoint; when it changes, the rows between the two indices are tweened by exactly one pitch with gsap.to. That leaves an open slot under the pointer, and it is the only feedback the component needs.
The settle. Release runs the component's own FLIP: record every row's rect, wipe the transforms so the next measurement is pure layout, move the row in the DOM, measure again, then gsap.fromTo each row from the difference back to y: 0 on a power3.out, clearing the transform in onComplete. Rows therefore end on integer positions rather than on a leftover offset. The lift scale rides the same tween back to 1 so there is no pop, and Draggable.update(true) is called afterwards, or the next drag would start from the previous one's y.
The keyboard. The same FLIP helper drives the arrow keys, so a keyboard move looks exactly like a drag landing. Escape re-runs it toward the index the row was picked up from.
Events. Any committed reorder dispatches a bubbling reorder CustomEvent on the list:
Code snippet omitted: it ships with the download.
A drop back in the slot it came from changes nothing and fires nothing.
Themes
The component ships in two themes, light and dark, which are one design at two token values. Set the attribute on <body> (or on the component's own wrapper in your app):
Code snippet omitted: it ships with the download.
Every colour, radius and shadow the component uses is a custom property in the matching body[data-variant="..."] block in assets/style.css: --ground, --ground-2, --raised, --hover, --slot, --ink, --ink-2, --ink-3, --line, --line-strong, --accent, --accent-ink, --shadow, --lift-shadow, --radius, --radius-sm, --radius-xs and --font. Nothing in assets/script.js reads the theme name, so re-valuing one block is the whole of a rebrand: point --accent at your brand colour and --raised, --slot and the --ink-* ramp at your surfaces and you are done. ?variant=light in the demo's URL only sets the same attribute.
Customisation
- Row height and rhythm:
--row-min,--row-gapand--row-padon the component root, re-valued bydata-density. Add your own density by copying the[data-density="compact"]block. - The lift:
LIFT_SCALEandLIFT_TIMEat the top ofassets/script.js. - The settle and the sliding:
SETTLE_TIME/EASE_SETTLEandSHIFT_TIME/EASE_SHIFT. The settle is deliberately damped; below about 0.25s the row reads as teleporting into the slot, and a springy ease makes a commit feel like a toy. - The list carries an
is-reorderingclass for the life of a gesture and the moving row anis-dragging(pointer) oris-lifted(keyboard) class, which is where the ring and the shadow come from. - Announcement wording: the
announce(...)calls inassets/script.js.
Requirements
- GSAP 3.12+ plus Draggable (demo uses 3.15.0)
- No build step, no framework
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.