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.
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.jsonUsage
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.
<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
Sizes
<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
| Prop | Type | Default | Description |
|---|---|---|---|
| value | T[] | – | 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 }. |
| collisionDetection | CollisionDetection | closestCenter | dnd-kit collision algorithm; closestCenter keeps large items reliable in both directions. |
| modifiers | Modifiers | – | dnd-kit modifiers (e.g. restrictToVerticalAxis), also applied to the overlay. |
| onDragStart / onDragEnd / onDragCancel | (event) => void | – | Raw dnd-kit lifecycle callbacks. |
SortableItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | – | Unique id matching getItemValue for this item. |
| disabled | boolean | false | Dims the item, disables its handle and pins it in place. |
| render | ReactElement | – | Base UI render prop — merge the sortable behavior onto any element or component. |
| Component | Description |
|---|---|
| Sortable | Root container (data-slot="sortable") providing the DndContext; sets data-dragging while a drag is live. |
| SortableItem | One draggable entry (data-slot="sortable-item") with data-value, data-dragging and data-disabled state attributes. |
| SortableItemHandle | The drag grip (data-slot="sortable-item-handle") carrying the pointer and keyboard listeners; grab/grabbing cursors built in. |
| SortableOverlay | Optional 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.