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…
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
- export interface SortableListItem { id: string; content: React.ReactNode }
- export interface SortableListProps { items: SortableListItem[]; onReorder:
(items: SortableListItem[]) => void; handle?: boolean; disabled?: boolean;
className?: string } plus the rest of React.HTMLAttributes<HTMLUListElement>.
- 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 })
and applies CSS.Transform.toString(transform) + transition as inline style,
so rows slide out of the way live while something is dragged over them.
- 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.
- Rely on DndContext's built-in accessibility.announcements (the default ones
dnd-kit ships) to narrate pickup/move/drop to screen readers — don't write
a custom live region for this.
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 with no ambient looping
animation, so there's nothing to gate behind prefers-reduced-motion beyond
keeping the drop transition short.
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 value — shorten it for a snappier feel or lengthen
it for a heavier one.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). - Live announcements — dnd-kit's default
DndContextaccessibility announcements narrate pickup, move, and drop to screen readers without any extra wiring.