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…

"use client"

import * as React from "react"
import { GripVertical } from "lucide-react"
import {
  closestCenter,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core"
import {

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
- 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 constraintPointerSensor 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-sortableKeyboardSensor + 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 draghandle toggles 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 DndContext accessibility announcements narrate pickup, move, and drop to screen readers without any extra wiring.

On This Page