# Drag to Reorder Sortable List

> 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.

Canonical: https://gsapvault.com/ui-elements/drag-reorder-sortable-list
Live demo: https://gsapvault.com/demos/drag-reorder-sortable-list/index.html

| Property | Value |
|----------|-------|
| Type | ui-element |
| Tier | paid |
| Price | £5 |
| Difficulty | intermediate |
| Plugins | Draggable |
| Techniques | draggable, reorder, keyboard-navigation, micro-interaction |
| Uses Lenis | No |

## Overview

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.

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.

## Features

- 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

## 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

## Vibe-Code Ready Setup

This UI element includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

### 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.

## Documentation

How this UI element works and how it goes into a page. The reference you use once you own the files (worked examples, events, the programmatic API, the class list) ships with the download.

### 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

## What You Get

- `index.html`: working demo page
- `assets/script.js`: commented, readable source
- `assets/style.css`: effect styles
- `README.md`: full documentation with examples and framework integration notes
- `START-HERE-AI.md`: product-specific copy-paste prompt for AI-assisted setup
- `LICENSE.txt`: standard license terms
- Lifetime updates: re-download anytime from your library

## Get the Code

This is a premium UI element. The standard license costs £5 one-time and covers unlimited personal and commercial projects with no attribution required. The only restrictions: no redistribution of the code itself and no competing effect libraries.

- [Buy Drag to Reorder Sortable List](https://gsapvault.com/ui-elements/drag-reorder-sortable-list)
- [The Vault (£99 one-time, best value): everything in the catalogue, including future releases](https://gsapvault.com/effects)

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
