Sortable
Drag-to-reorder primitive built on @dnd-kit/sortable. Compound API — orchestrator + per-item wrapper + optional drag handle. Composes with any layout primitive.
Installation
import { Sortable } from "@gradeui/ui"Usage — vertical list
Wrap a Stack of items. Drag any row to reorder; live preview below.
With a drag handle
When the row body needs to stay clickable (an inner button, a link, a checkbox), scope drag activation to a Sortable.Handle grip. Click the handle to drag; click the row Body for normal interaction.
Horizontal strip
For tab reordering and similar single-row arrangements use strategy="horizontal" + a Row.
2D grid
Photo grids, asset libraries, dashboard tiles — anything 2D wraps with strategy="grid".
Sortable props
| Prop | Type | Default | Description |
|---|---|---|---|
| values | (string | number)[] | — | Required. Ordered list of unique ids; source of truth for the order. |
| onReorder | (next: (string | number)[]) => void | — | Fires with the full new order after a drag that changed it. |
| strategy | "vertical" | "horizontal" | "grid" | "vertical" | Match the layout your items render in. Drives dnd-kit's sort strategy. |
| disabled | boolean | false | Disable drag on every item without rebuilding the tree. |
Sortable.Item props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | number | — | Must match one of the parent values. Identity, not React key. |
| asChild | boolean | false | Render as the child element via Slot. |
| disabled | boolean | false | Disable drag for this item only. |
Sortable.Handle props
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | false | Render as the child element via Slot. Common pattern: wrap a ghost icon Button. |
Accessibility
- Keyboard-driven via dnd-kit's KeyboardSensor — Tab to focus an item, Space to lift, arrows to move, Space to drop, Esc to cancel.
- Sortable.Handle renders as a
role="button"witharia-label="Drag to reorder". - PointerSensor activation distance is 4px so single-click interactions inside items pass through to their handlers.
When NOT to use
Cross-container drag (e.g. kanban “drag from To Do to Done”) isn't covered by v1 — a planned Sortable.Group will wire one DndContext above multiple Sortable columns. Until then, hand-roll with raw @dnd-kit/core for cross-container cases. For non-reorder drag scenarios (drag onto a target, drag-and-drop file zones, draggable canvas nodes), use the raw library too — Sortable specifically models the “rearrange a list” case.
Sidecar
The Markdown sidecar Studio (and the Grade MCP server, when it ships) reads to understand this component — frontmatter, when- to-use guidance, and canonical examples. Authored once at packages/ui/components/ui/sortable.md and shipped inside the published @gradeui/ui tarball.
---
name: Sortable
import: "@gradeui/ui"
subcomponents: [Sortable.Item, Sortable.Handle]
props:
- Sortable: values: (string | number)[] — ordered list of unique ids; the source of truth for the order
- Sortable: onReorder?: (next: (string | number)[]) => void — fires with the new order after a drag that changed it
- Sortable: strategy?: "vertical" | "horizontal" | "grid" (default "vertical") — match the layout your items render in
- Sortable: disabled?: boolean — disable drag on every item
- Sortable.Item: value: string | number — must match one entry in the parent `values` array (identity, not React key)
- Sortable.Item: asChild?: boolean — render as the child element via Radix Slot
- Sortable.Item: disabled?: boolean — disable drag for this item only
- Sortable.Handle: asChild?: boolean — wrap a Button / icon as the drag grip
when_to_use: Drag-to-reorder lists, kanban-column reordering, sortable shelves, tab strips the user can rearrange. Pairs with any layout primitive — Stack for vertical lists, Row for horizontal strips, Grid for 2D card walls. For cross-container drag (drag a card from one column to another) hand-roll DndContext at the page level — Sortable v1 covers single-list reorder; Sortable.Group for cross-container is a planned follow-up. Reach for raw `@dnd-kit/core` if you need custom collision detection, drag overlays with arbitrary chrome, or non-list use cases (kanban swimlanes, draggable canvas nodes).
composes_with: [Stack (vertical lists), Row (horizontal strips), Grid (2D card walls), Card (typical item content), Button (as Sortable.Handle asChild)]
aliases: [sortable, reorder, drag and drop, dnd, draggable list, sortable list, kanban, drag to reorder, drag-drop, dragdroplist, drag handle, react native draggable flatlist]
---
```jsx
const [items, setItems] = React.useState([
{ id: "a", title: "First" },
{ id: "b", title: "Second" },
{ id: "c", title: "Third" },
]);
<Sortable values={items.map(i => i.id)} onReorder={(ids) => {
setItems(ids.map(id => items.find(i => i.id === id)!));
}}>
<Stack gap="sm">
{items.map((item) => (
<Sortable.Item key={item.id} value={item.id}>
<Card>
<CardContent className="p-3">{item.title}</CardContent>
</Card>
</Sortable.Item>
))}
</Stack>
</Sortable>
```
```jsx
// With a drag handle — only the grip activates drag; the rest of the
// row stays clickable for child Buttons / links.
<Sortable values={ids} onReorder={setIds} strategy="vertical">
<Stack gap="sm">
{items.map((item) => (
<Sortable.Item key={item.id} value={item.id}>
<Card>
<Row gap="sm" align="center" className="p-3">
<Sortable.Handle asChild>
<Button variant="ghost" size="icon">
<GripVertical className="h-4 w-4" />
</Button>
</Sortable.Handle>
<span className="flex-1">{item.title}</span>
<Button size="sm">Edit</Button>
</Row>
</Card>
</Sortable.Item>
))}
</Stack>
</Sortable>
```
```jsx
// Horizontal tab strip — strategy="horizontal" + Row instead of Stack.
<Sortable values={tabIds} onReorder={setTabIds} strategy="horizontal">
<Row gap="xs">
{tabs.map((tab) => (
<Sortable.Item key={tab.id} value={tab.id}>
<Badge>{tab.label}</Badge>
</Sortable.Item>
))}
</Row>
</Sortable>
```
```jsx
// 2D card grid — strategy="grid".
<Sortable values={photoIds} onReorder={setPhotoIds} strategy="grid">
<Grid cols="3" gap="md">
{photos.map((p) => (
<Sortable.Item key={p.id} value={p.id}>
<MediaSurface aspect="square" alt={p.alt} />
</Sortable.Item>
))}
</Grid>
</Sortable>
```
### Anti-patterns
DO NOT add a `sortable` prop to Stack / Row / Grid — those primitives stay pure. Wrap them in `<Sortable>` to mark a collection sortable. Mixing layout and reorder concerns into one component balloons each primitive's contract for a feature 95% of stacks don't use, and loses you cross-layout consistency (one Sortable wrapping a Grid works exactly like one wrapping a Stack).
DO NOT use `key` as the sortable identity. `<Sortable.Item value={item.id}>` is the source of truth — `key={item.id}` is also fine for React's reconciler but `value` is what dnd-kit reads. They usually match; if they don't, drag-end will operate on the wrong row.
DO NOT try to mutate children directly to reorder. Sortable's data model is `state → children`. Reorder fires `onReorder(newValues)`; you update state; React re-renders children in the new order. Trying to read children's keys + reorder them imperatively fights React.
DO NOT wrap clickable items (Card with onClick, Button-bearing rows) without thinking about drag-vs-click conflict. The PointerSensor has a 4px activation distance so single clicks pass through, but if the row's primary affordance is "click to open detail," consider a `<Sortable.Handle>` so the user clicks the body for detail and drags only the grip.
DO NOT use Sortable for cross-container drag in v1. A single `<Sortable>` is one DndContext; the kanban "drag from To Do to Done" case needs one DndContext above multiple SortableContexts. Until `<Sortable.Group>` lands, that pattern needs hand-rolled `@dnd-kit/core` at the page level. Single-list, single-grid, single-strip reorder all work.