Display
Sortable List
A drag-to-reorder list for settings, playlists, or priority queues, built on dnd-kit with a fully keyboard-operable fallback.
Preview in your theme
Loading preview…
- Dependencies
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, lucide-react
- Registry dependencies
- utils, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sortable-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SortableList" component using
@dnd-kit/core, @dnd-kit/sortable, and @dnd-kit/utilities (drag-and-drop must
go through dnd-kit, not a hand-rolled pointer/drag implementation).
Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — usePrefersReducedMotion() switches off the slide of
the rows making room. If rebuilding without it: a useSyncExternalStore over
matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and
a server snapshot of false.
- export interface SortableListItem { id: string; content: React.ReactNode }
- export interface SortableListProps { items: SortableListItem[]; onReorder:
(items: SortableListItem[]) => void; handle?: boolean; disabled?: boolean;
getItemLabel?: (item: SortableListItem) => string; className?: string }
plus the rest of React.HTMLAttributes<HTMLUListElement>.
- getItemLabel is the plain-text name spoken in the screen-reader
announcements. Default: `content` when it is a string, otherwise the id —
pass it whenever content is rich JSX.
- Controlled component: it never owns order state itself. Every drag that
actually changes order calls onReorder with the full, already-reordered
array — the consumer just setState with it.
- handle defaults to true (only a grip icon is draggable); false makes the
entire row draggable instead.
Behavior
- Wrap the list in <DndContext> with two sensors: PointerSensor with
activationConstraint: { distance: 4 } (a few pixels of travel before a drag
starts, so clicking interactive content inside a row doesn't misfire a
drag), and KeyboardSensor with coordinateGetter:
sortableKeyboardCoordinates. That keyboard sensor is what makes the whole
list operable without a mouse: focus the draggable node, press Space to
pick it up, arrow keys to move it up/down the list, Space again to drop, Esc
to cancel and snap back.
- Wrap the rows in <SortableContext items={ids} strategy=
{verticalListSortingStrategy}>. Each row calls useSortable({ id, disabled,
transition }) and applies CSS.Transform.toString(transform) + transition as
inline style, so rows slide out of the way live while something is dragged
over them. Under prefers-reduced-motion pass transition: null — the rows
jump straight to their new slots, and dragging still works (undefined keeps
dnd-kit's default 200ms slide).
- On DndContext's onDragEnd: bail if there's no drop target or the item was
dropped on itself; otherwise resolve both indices from the current items
array and call arrayMove(items, oldIndex, newIndex), then onReorder(result).
Never mutate items in place.
- While a row is being dragged (isDragging from useSortable) drop its opacity,
raise it above its neighbors (relative z-10), and add a shadow so it reads
as "lifted" instead of just moving.
- disabled disables sorting for every row (and the SortableContext itself)
without unmounting anything — the grip (or row, in handle={false} mode)
just stops responding and shows a not-allowed affordance.
- Pass DndContext accessibility={{ announcements }} built from getItemLabel
and 1-based positions: "Picked up Alpha, position 1 of 5." / "Alpha moved to
position 3 of 5." / "Alpha dropped at position 3 of 5." / "Reordering
cancelled. Alpha is back at position 1 of 5." The stock announcements read
raw ids ("Picked up draggable item task-3"). `items` only changes on drop, so
during a drag the index of `over` is exactly where the item would land. Keep
using dnd-kit's own live region — don't write a second one.
- Every grip is named after its item: aria-labelledby points at an sr-only
"Reorder" inside the button plus the row's content wrapper, so a screen
reader hears "Reorder Alpha" rather than the same "Drag to reorder" on
every row.
- handle={false}: dnd-kit's attributes (role="button", tabIndex 0,
aria-roledescription) and listeners go on an inner full-width box that
carries the row padding and a focus-visible ring — never on the <li>, which
stays a plain listitem so the ul > li list semantics survive.
Rendering & styling
- Semantic tokens only: rows are bg-card border rounded-lg px-3 py-2.5 flex
items-center gap-2; the grip icon (lucide-react GripVertical) is
text-muted-foreground, cursor-grab, active:cursor-grabbing, with a
focus-visible:ring-2 focus-visible:ring-ring ring for keyboard users.
Nothing here has a hardcoded hex/oklch color.
- cn() merges the consumer's className onto the root <ul>; forwardRef the
root so consumers can measure/scroll to it.
- Dragging itself is a direct-manipulation gesture, so the dragged row still
follows the pointer under prefers-reduced-motion; only the neighbours' slide
(useSortable's transition) is switched off.
Customization levers
- Orientation: swap verticalListSortingStrategy for
horizontalListSortingStrategy (+ a flex-row list) for a horizontal chip/tab
reorder.
- Multi-container upgrade path: this component is single-list; promoting it
to cross-list dragging (kanban-style) means tracking which container an id
belongs to and handling onDragOver, not just onDragEnd — treat that as a
different, bigger component.
- Handle icon: swap GripVertical for any lucide icon, or drop the handle
entirely (handle={false}) to make full rows draggable.
- Motion: the drop transition duration/easing comes straight from
useSortable's transition option — pass { duration, easing } to shorten it
for a snappier feel or lengthen it for a heavier one; keep null under
reduced motion.
- Announcements: the four strings live in one useMemo — translate them, or
add the neighbour's label ("after Beta") if positions alone are too abstract
for your list.Concepts
- Pointer activation constraint —
PointerSensorrequires ~4px of travel before a drag starts, so a plain click on the handle (or a whole draggable row) doesn't accidentally trigger a reorder. - Keyboard-sortable —
KeyboardSensor+sortableKeyboardCoordinatesmake the list fully operable without a mouse: focus,Spaceto pick up, arrow keys to move,Spaceto drop,Escto cancel. - Controlled reorder, not internal state — the component never owns order;
onDragEndcomputes the full reordered array witharrayMoveand hands it toonReorder, so the consumer's state is always the single source of truth. - Drag handle vs. whole-row drag —
handletoggles whether only a grip icon is draggable (safer when rows contain other interactive content) or the entire row is (simpler, denser lists). - Named announcements — custom
DndContextannouncements speak the item's label and its 1-based position on pickup, move, drop and cancel, where dnd-kit's defaults would read raw ids; each drag handle is named after its row's text too, so every "Reorder" button says which item it moves.