Sortable

Sortable

Drag-and-drop reordering for lists, grids and nested groups. Built on dnd-kit with Base UI composition, so every part styles through render props and data attributes.

Preview

Sortable owns a controlled array; each SortableItem identifies itself with value and drags by its SortableItemHandle. The dragged item dims in place while a portal overlay follows the pointer, and drops commit through onValueChange.

Audit color tokensIn progress
Ship elevation ladderDone
Migrate data gridIn progress
Write sortable docsTodo
const [tasks, setTasks] = React.useState(initialTasks)

<Sortable
  value={tasks}
  onValueChange={setTasks}
  getItemValue={(task) => task.id}
  className="flex w-full max-w-sm flex-col gap-2"
>
  {tasks.map((task) => (
    <SortableItem
      key={task.id}
      value={task.id}
      className="flex items-center gap-2 rounded-lg border border-border-soft bg-card px-3 py-2.5"
    >
      <SortableItemHandle
        render={<Button variant="ghost" size="icon-xs" />}
      >
        <GripVertical className="size-4" />
      </SortableItemHandle>
      <span className="flex-1 truncate">{task.title}</span>
    </SortableItem>
  ))}
</Sortable>

Installation

npx shadcn@latest add https://espresso-base-ui.vercel.app/r/sortable.json

Usage

import {
  Sortable,
  SortableItem,
  SortableItemHandle,
  SortableOverlay,
} from "@/components/ui/sortable"

Item ids come from getItemValue and must be unique strings — the component warns in development when they collide. Reorders are computed with arrayMove and handed to onValueChange; pass onMove instead to take over the state transition yourself.

Strategies

strategy matches the layout: "vertical" (default) for columns, "horizontal" for rows, and "grid" for two-dimensional reordering — here inside a four-column CSS grid.

Tile 1
Tile 2
Tile 3
Tile 4
Tile 5
Tile 6
Tile 7
Tile 8
<Sortable
  value={tiles}
  onValueChange={setTiles}
  getItemValue={(tile) => tile.id}
  strategy="grid"
  className="grid w-full max-w-xs grid-cols-4 gap-2"
>
  {tiles.map((tile) => (
    <SortableItem key={tile.id} value={tile.id} className="rounded-lg">
      <SortableItemHandle className="flex aspect-square items-center justify-center rounded-lg border border-border-soft bg-card text-sm">
        {tile.label}
      </SortableItemHandle>
    </SortableItem>
  ))}
</Sortable>

Nested groups

Sortables nest freely: an outer list reorders group containers while an inner Sortable inside each container reorders its own values. Handles keep the levels independent — each handle only drags the item whose context it sits in, so a child drag never moves its parent.

Colors

White
Black

Sizes

Small
Large
<Sortable value={groups} onValueChange={setGroups} getItemValue={(g) => g.id}>
  {groups.map((group) => (
    <SortableItem key={group.id} value={group.id}>
      <SortableItemHandle>…</SortableItemHandle>
      <h3>{group.name}</h3>
      <Sortable
        value={group.values}
        onValueChange={(values) => reorderValues(group.id, values)}
        getItemValue={(v) => v.id}
      >
        {group.values.map((value) => (
          <SortableItem key={value.id} value={value.id}>
            <SortableItemHandle>…</SortableItemHandle>
            {value.value}
          </SortableItem>
        ))}
      </Sortable>
    </SortableItem>
  ))}
</Sortable>

API reference

Sortable

PropTypeDefaultDescription
valueT[]–Controlled array of items in display order.
onValueChange(value: T[]) => void–Receives the reordered array after a drop.
getItemValue(item: T) => string–Maps an item to its unique id — must match the SortableItem value.
strategy"vertical" | "horizontal" | "grid""vertical"Sorting strategy matching the layout direction.
onMove({ event, activeIndex, overIndex }) => void–Replaces the built-in arrayMove commit for custom state transitions.
onValueCommit(value, meta) => void–Fires after a commit with { event, activeIndex, overIndex, previousValue }.
collisionDetectionCollisionDetectionclosestCenterdnd-kit collision algorithm; closestCenter keeps large items reliable in both directions.
modifiersModifiers–dnd-kit modifiers (e.g. restrictToVerticalAxis), also applied to the overlay.
onDragStart / onDragEnd / onDragCancel(event) => void–Raw dnd-kit lifecycle callbacks.

SortableItem

PropTypeDefaultDescription
valuestring–Unique id matching getItemValue for this item.
disabledbooleanfalseDims the item, disables its handle and pins it in place.
renderReactElement–Base UI render prop — merge the sortable behavior onto any element or component.
ComponentDescription
SortableRoot container (data-slot="sortable") providing the DndContext; sets data-dragging while a drag is live.
SortableItemOne draggable entry (data-slot="sortable-item") with data-value, data-dragging and data-disabled state attributes.
SortableItemHandleThe drag grip (data-slot="sortable-item-handle") carrying the pointer and keyboard listeners; grab/grabbing cursors built in.
SortableOverlayOptional custom overlay portal — render-prop children receive the active item id. Without it, the active item is cloned automatically.

Accessibility & sensors

Pointer drags activate after 10px of travel (250ms hold on touch), so taps and clicks inside items keep working. Handles are keyboard operable via dnd-kit: focus a handle, press Space or Enter to lift, move with the arrow keys and drop with Space/Enter or cancel with Escape, with positions announced to screen readers. Every part exposes data-slot plus drag-state data attributes for styling.

Search documentation

Search the component docs