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
"use client"

import * as React from "react"
import { GripVertical } from "lucide-react"
import {
  type Announcements,
  closestCenter,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  type UniqueIdentifier,
  useSensor,
  useSensors,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sortable-list.json

Prompt

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 — PointerSensor requires ~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 + sortableKeyboardCoordinates make the list fully operable without a mouse: focus, Space to pick up, arrow keys to move, Space to drop, Esc to cancel.
  • Controlled reorder, not internal state — the component never owns order; onDragEnd computes the full reordered array with arrayMove and hands it to onReorder, so the consumer's state is always the single source of truth.
  • Drag handle vs. whole-row drag — handle toggles 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 DndContext announcements 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.

On This Page