Drag to Reorder Sortable List

£5

A vertical list that reorders by dragging: the row lifts and follows the pointer while the rows it crosses slide out of the way, so the open gap is always the slot it will land in.

Draggable intermediate
4 more details
draggablereorderkeyboard-navigationmicro-interaction
Drag to Reorder Sortable List - UI component preview

This element reads better at your own screen size than in the frame below:

About this component

A sortable list where the gap is the interface. Press a row and it lifts a little, picks up a shadow and an accent ring, and follows the pointer on the y axis; as it crosses each neighbour's midpoint that neighbour slides out of the way, so there is always an open slot under the cursor showing exactly where the row will land. Let go and it settles into that slot on a short damped ease, no bounce, and every row finishes on a whole position with no transform left on it.

Read the full effect overview

The same moves are on the keyboard. Focus a handle, press Space to pick the row up, move it with the arrow keys, Space to drop or Escape to put it back where it started, with each move announced through a live region: "Moved Design review to position 3 of 7." On a touch screen the handle is the only drag start, so a finger anywhere else on the row still scrolls the page.

The list emits a reorder event carrying the ordered ids, so persisting the new order is one listener. One HTML file, one stylesheet, one script, vanilla JavaScript with GSAP and the Draggable plugin, no framework. Light and dark themes come from one set of custom properties switched by a single data-variant attribute, and a data-density attribute swaps the row rhythm without re-initialising anything.

What's included

9 items
  • Draggable rows locked to the y axis, lifted with a scale, a shadow and a 1px accent ring
  • The rows the drag crosses slide aside, so the open gap tracks the pointer
  • A damped settle on release, and every row ends on an integer position with its transform cleared
  • Full keyboard reorder: Space to lift, arrows to move, Space to drop, Escape to cancel back to the original slot
  • A polite live region that announces every move, drop and cancel by name and position
  • Touch-aware: the handle starts the drag on coarse pointers so the row body still scrolls the page
  • data-density switches the row rhythm and the drag re-measures itself on the next press
  • A bubbling reorder event carrying the ordered ids, the moved id and its old and new index
  • Light and dark themes on one set of custom properties

Perfect for

5 use cases
  • Board and pipeline settings where the stage order is the configuration
  • Checklists, playbooks and onboarding steps a user arranges themselves
  • Dashboard and sidebar layouts where panels are ordered by hand
  • Playlists, queues and priority lists in an admin or product surface
  • Form builders and table column pickers that need an accessible reorder

How it works

4 sections

Dragging, and the gap that follows the pointer

The lifted row travels with the pointer on its own axis while its neighbours make room: the row pitch is read fresh on every press, so a density or viewport change needs nothing extra, and whenever the pointer crosses a neighbour's midpoint that neighbour slides one pitch aside, leaving an open slot under the cursor at all times.

The settle, and why nothing is left transformed

On release the component measures where every row is, moves the dragged row in the DOM, measures again, and eases each row from the difference into its new place with a damped settle. The lift scale rides the same movement back to rest, so there is no pop at the end, and every row finishes on a whole pixel with no leftover transform, which is what keeps a second drag starting from the truth.

The keyboard model

The handle is a real button with a pressed state, so picking a row up is something a screen reader can report. Space or Enter lifts it, the arrow keys move it through the same settle the drag uses, Home and End send it to either end, Space drops it and Escape returns it to where it started. Each move writes a sentence into a polite live region, and the handle keeps focus across the move so an arrow press never silently drops the row.

Touch and reduced motion

On a coarse pointer only the handle starts a drag, so a swipe on the row body is a page scroll; under reduced motion every move happens at once, which keeps the reordering working while removing the travel. The two conditions are complementary pairs, so every device matches something and the component always initialises.

Plugins Draggable
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Paid UI element

Purchase to unlock the code.

Buying Drag to Reorder Sortable List opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects.

£5 Standard license, unlimited projects

Browse UI elements

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source with an onReady guard

  • Rows 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: Space to lift, arrows to move, Space to drop, Escape to cancel, using the same tweened moves

  • A 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-density swaps the row rhythm; the drag re-measures the pitch on the next press, so nothing needs re-initialising

  • A bubbling reorder event carrying the ordered ids

  • Roles and states used: <ul> / <li> list semantics, a <button> handle with aria-pressed, aria-describedby on each handle, and a role="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> whose aria-pressed is true for exactly as long as the row is lifted, and whose accessible name says which row it reorders. Each handle points at the instruction line with aria-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-gap and --row-pad on the component root, re-valued by data-density. Add your own density by copying the [data-density="compact"] block.
  • The lift: LIFT_SCALE and LIFT_TIME at the top of assets/script.js.
  • The settle and the sliding: SETTLE_TIME / EASE_SETTLE and SHIFT_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-reordering class for the life of a gesture and the moving row an is-dragging (pointer) or is-lifted (keyboard) class, which is where the ring and the shadow come from.
  • Announcement wording: the announce(...) calls in assets/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.

Your cart

Your cart is empty

The Vault £99

Everything in the catalogue, plus everything we release next.