Sortable
The foundational drag-and-drop reorder primitive — a zero-dependency engine that makes any list, 2D grid, or cross-container board reorderable by pointer and keyboard.
@garn/ui/sortableOn this page
Default
A single vertical list — whole-item drag, keyboard-operable, with the shared instructions node.
Handle
A list with a grip handle + interactive row content (badge, menu button) — only the handle drags, so the button stays clickable.
Horizontal
Horizontal reorder — a row of chips, ←/→ keyboard.
Board
A Kanban board — Sortable.Group of columns + a Sortable.Overlay; cards move between columns.
Multi Item
Multi-item drag — click to select, then drag one selected item to move the whole set (count badge).
Grid
A wrapping 2D grid (orientation=grid) — closest-center projection, spatial ←/→ ↑/↓ keyboard, drop indicator.
Drop Indicator
The built-in insertion line (dropIndicator) + onDragOver reporting the live target slot.
Scrollable
A tall list in a fixed-height scroll container — edge auto-scroll drags past the fold; long-press on touch.
Headless
The useSortable hook wired onto a consumer's own <ul>/<li> markup (the List/Tree integration pattern).
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add sortable- Registry
sortable- Deps
@radix-ui/react-slotlucide-react- Registry deps
utilsuse-controllable-stateuse-announcedrag-gestureuse-isomorphic-layout-effect
Import
import { Sortable.Item, Sortable.Handle, Sortable.Overlay, Sortable.Group } from "@garn/ui/sortable";Anatomy
The parts this component is built from — compose them to assemble it.
Sortable.ItemRequired[data-slot="sortable-item"]A value-keyed reorderable cell. When it holds no handle it IS the drag activator (a role=button with an accessible name + keyboard); with a nested Sortable.Handle the row body stays free. Handle detection walks the JSX children — a handle rendered inside one of your own components is invisible to the walk, so declare it with the hasHandle prop. Reflects data-dragging / data-placeholder / data-drop-edge / data-orientation / data-disabled, and (multi-item) shows a count badge (sortable-drag-count).
Sortable.Handle[data-slot="sortable-handle"]The optional drag affordance — a real <button> (default name "Drag to reorder"). When present, only it starts a drag, so interactive row content (buttons, links, inputs) stays clickable and semantic <li>/<tr> rows keep their role.
Sortable.Overlay[data-slot="sortable-overlay"]A portal ghost that follows the pointer, render-prop (activeValue) => node. Clip-free — required for a Board so the dragged item can escape a column's overflow; when present the source item hides.
Sortable.Group[data-slot="sortable-group"]A Board coordinator — hoists ONE drag machine across the child Sortable columns so an item can move between them. Pair with a Sortable.Overlay.
Props
The component's public props and their types.
activationDistance- Description
- Pointer travel (px) before a mouse/pen drag begins — guards clicks.
- Type
number- Default
4
announcements- Description
- Override the screen-reader announcements (pick-up / move / drop / cancel); merged over the defaults, so pass only the messages you want to change.
- Type
Partial<SortableAnnouncements>
asChild- Description
- Render as the child element (merging props) instead of a `div`.
- Type
boolean- Default
false
autoScroll- Description
- Auto-scroll the nearest scroll container when dragging near its edge.
- Type
boolean- Default
true
collision- Description
- Drop-index projection: `pointer` (1D main-axis) or `closest-center` (2D reading order).
- Type
"closest-center" | "pointer"- Default
"pointer"
defaultValue- Description
- Ordered item keys (uncontrolled initial).
- Type
string[]
disabled- Description
- Turn dragging off entirely — items stay in place and lose their activator.
- Type
boolean- Default
false
dropIndicator- Description
- Show a built-in insertion line at the drop position.
- Type
boolean- Default
false
getItemLabel- Description
- Map an item key to the label screen readers announce.
- Type
(value: string) => string
id- Description
- Container id — auto-generated; set it when composing a Board by hand.
- Type
string
lockAxis- Description
- Constrain the self-translate follow to the main axis.
- Type
boolean- Default
true
onDragCancel- Description
- Fires when a drag is cancelled (Escape / pointer cancel) and the item returns.
- Type
(e: { value: string; }) => void
onDragEnd- Description
- Fires when a drag commits — the reorder, with the moved value and its from/to.
- Type
(e: ReorderMeta) => void
onDragMove- Description
- Fires for every drag point: rAF-throttled moves, auto-scroll re-projections, and a synchronous final point at the drop.
- Type
(e: DragMoveEvent) => void
onDragOver- Description
- Fires when the projected drop slot (container/index) changes.
- Type
(e: DragOverEvent) => void
onDragStart- Description
- Fires when a drag lifts an item.
- Type
(e: { value: string; }) => void
onValueChange- Description
- Fires with the next order and what moved.
- Type
(value: string[], meta: ReorderMeta) => void
orientation- Description
- Layout + axis: `vertical`, `horizontal`, or a wrapping 2D `grid`.
- Type
"grid" | "horizontal" | "vertical"- Default
"vertical"
selectedValues- Description
- Selected item values — dragging one moves the whole set (multi-item drag).
- Type
string[]
touchDelay- Description
- Long-press (ms) before a *touch* drag begins — a quick swipe scrolls instead.
- Type
number- Default
140
value- Description
- Ordered item keys (controlled).
- Type
string[]
Plus 275 inherited native <div> attributes.
Sortable.Item
asChild- Description
- Render as the child element (merging props) instead of a `div`.
- Type
boolean- Default
false
disabled- Description
- Make this item non-draggable (a pinned/locked row).
- Type
boolean- Default
false
hasHandle- Description
- Declare that a `Sortable.Handle` lives somewhere in this item's rendered output. The automatic detection only walks the JSX you pass as children — a handle rendered *inside* one of your own components is invisible to it, which would silently make the whole item the activator.
- Type
boolean
valueRequired- Description
- The item's stable key — its identity in the ordered `value`.
- Type
string
Plus 280 inherited native HTML attributes.
Sortable.Handle
asChild- Description
- Render as the child element (merging props) instead of a `button`.
- Type
boolean- Default
false
label- Description
- Accessible name for the icon-only handle.
- Type
string- Default
"Drag to reorder"
Plus 290 inherited native HTML attributes.
Sortable.Overlay
childrenRequired- Description
- Render the ghost for the active key (clip-free; follows the pointer).
- Type
(activeValue: string) => React.ReactNode
className- Description
- No description.
- Type
string
Sortable.Group
activationDistance- Description
- Pointer travel (px) before a mouse/pen drag begins.
- Type
number- Default
4
announcements- Description
- Override the screen-reader announcements; merged over the defaults.
- Type
Partial<SortableAnnouncements>
asChild- Description
- Render as the child element (merging props) instead of a `div`.
- Type
boolean- Default
false
autoScroll- Description
- Auto-scroll the nearest scroll container when dragging near its edge.
- Type
boolean- Default
true
collision- Description
- Drop-index projection: `pointer` (1D) or `closest-center` (2D reading order).
- Type
"closest-center" | "pointer"- Default
"pointer"
disabled- Description
- Turn dragging off across every column.
- Type
boolean- Default
false
dropIndicator- Description
- Show a built-in insertion line at the drop position.
- Type
boolean- Default
false
getItemLabel- Description
- Map an item key to the label screen readers announce.
- Type
(value: string) => string
lockAxis- Description
- Constrain the self-translate follow to the main axis.
- Type
boolean- Default
true
onDragCancel- Description
- Fires when a drag is cancelled and the item returns.
- Type
(e: { value: string; }) => void
onDragEnd- Description
- Fires when a drag commits — the reorder, with the moved value and its from/to (across columns).
- Type
(e: ReorderMeta) => void
onDragMove- Description
- Fires for every drag point: rAF-throttled moves, auto-scroll re-projections, and a synchronous final point at the drop.
- Type
(e: DragMoveEvent) => void
onDragOver- Description
- Fires when the projected drop slot (container/index) changes.
- Type
(e: DragOverEvent) => void
onDragStart- Description
- Fires when a drag lifts an item in any column.
- Type
(e: { value: string; }) => void
orientation- Description
- Layout + axis for the columns' items: `vertical`, `horizontal`, or `grid`.
- Type
"grid" | "horizontal" | "vertical"- Default
"vertical"
selectedValues- Description
- Selected item values — dragging one moves the whole set (multi-item drag).
- Type
string[]
touchDelay- Description
- Long-press (ms) before a touch drag begins.
- Type
number- Default
140
Plus 277 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="sortable"]States
| Selector | State | Description |
|---|---|---|
[data-dragging] | dragging | The item currently being dragged (or, on the container/group, that a drag is in flight) — elevated, follows the pointer. |
[data-placeholder] | placeholder | The source item, hidden while an overlay ghost shows instead (Board / clip-free drags), or a non-grabbed member of a multi-item drag. |
[data-drop-edge] | drop-edge | The item at the projected insertion boundary (start / end) — drives the built-in drop indicator when `dropIndicator` is on. |
[data-dropping] | dropping | The single commit frame after a drop — items snap to their final slots instead of animating a stale offset (the drop-glitch guard). |
[data-disabled] | disabled | A non-draggable container or item. |
| Property | Token | Tier |
|---|---|---|
itemGap | --garn-gap-stack | semantic · air |
columnGap | --garn-gap-inline | semantic · air |
motion | --garn-motion-fast | semantic · air |
easing | --garn-ease-standard | semantic |
indicator | --garn-brand-solid | semantic |
When to use
Making a collection reorderable by drag (and keyboard): a to-do / priority list, a Kanban board that moves cards between columns, a tile/image grid, a settings list of pinned items. Use it directly with <Sortable> + <Sortable.Item>, or wire the useSortable hook into a component that owns its own rows.
Reach for something else when
- You just need to render a list or tree without reordering (use List / Tree). Reordering a paginated / server-sorted table where order isn't user-owned. Generic drag from one arbitrary place to another, or external file drop (Sortable is for reordering known items, not arbitrary drag sources). A single fixed pair of swappable panes (use Resizable).
Overview
Sortable is garn's foundational drag-and-drop — the accessible reorder engine everything else composes. It has zero runtime dependency: a Pointer-Events + keyboard machine that ships in the registry as source. Reach for it to make a collection reorderable — a single vertical or horizontal list, a wrapping 2D grid, or a Kanban board where items move between columns. A Sortable wraps items keyed by value; drag by the whole item, or add a Sortable.Handle so interactive row content stays clickable. It is accessible by default — a full keyboard drag mode (lift → move → drop / cancel) and live-region announcements come for free, not as an opt-in. Consumers that already own their rows (List's reorderable, Tree's drag-reparent) wire in the same engine through the headless useSortable hook.
Guidelines
One Sortable = one list; a Sortable.Group = a Board. A standalone Sortable owns its ordered value and the drag machine; wrapping several in a Sortable.Group hoists one machine across columns so items move between them (add a Sortable.Overlay so the ghost escapes each column's clipping).
Drag the whole item, or add a handle. A bare Sortable.Item is the activator — good for simple rows. When the row has buttons/links/inputs, add a Sortable.Handle so only the grip drags; on semantic <li>/<tr>/treeitem rows a handle is required (a role=button on the row breaks their semantics).
Own your data. Reorder is controlled via value/onValueChange (or uncontrolled defaultValue); apply the reported order to your state. In a Board, each column owns its own value.
Reach for the built-ins before a recipe. orientation="grid" for 2D; selectedValues for multi-item drag; dropIndicator for an insertion line; autoScroll (on by default) for long lists; touchDelay long-press for mobile. The keyboard describedby wires itself up — the root injects the shared instructions node on mount, so there's nothing to render for it; just don't strip the focus ring or silence the announcements.
Best practices
- Control order with
value/onValueChange(ordefaultValue) and apply the reported order to your data. - Add a
Sortable.Handlewhen the row has interactive content, and always on semantic <li>/<tr>/treeitem rows. - Override the four screen-reader messages via
announcementsfor domain language (e.g. "Moved to Done") — the shared keyboard-instructions node is injected by the root, so there's nothing to render for the describedby. - Use a
Sortable.Group+Sortable.Overlayfor a Board;orientation="grid"for a 2D grid;selectedValuesfor multi-item drag. - Leave
autoScrollon for long lists and settouchDelay(long-press) for touch — a swipe should still scroll.
- Don't put
role="button"on a semantic <li>/<tr>/treeitem to make it draggable — it breaks list/grid/tree semantics; drag via a Sortable.Handle instead. - Don't build a Board without a Sortable.Overlay — the dragged card gets clipped by a column's overflow.
- Don't lock touch scrolling — the whole-item touch sensor is long-press so the list still scrolls; don't set touch-action:none on rows yourself.
- Don't silence the live-region announcements or remove the focus ring — the keyboard drag model depends on both.
Troubleshooting
A row's button/link isn't clickable, or clicking it starts a drag.
Cause. The bare Sortable.Item is the drag activator, so an interactive child conflicts with the drag (and the activation guard blocks the drag when you press a child button).
Fix. Add a <Sortable.Handle> to the row — only the handle starts a drag, and the rest of the row (buttons, links, inputs) stays interactive.
Wiring Sortable onto a semantic <li>/<tr>/treeitem breaks its list/grid/tree accessibility (axe flags role=button on li).
Cause. The bare-item activator adds role=button, which is invalid on <li>/<tr>/treeitem.
Fix. Use the useSortable hook with a handle: spread getItemProps(id, { handle: true }) on the row (ref + drag data only, NOT role) and getHandleProps(id) on a grip button. This is exactly how List.DragHandle / Tree.DragHandle work.
The whole row drags (role=button, double tab stop) even though a handle is rendered — the handle lives inside your own component.
Cause. Handle auto-detection walks the JSX passed as Sortable.Item children; a Sortable.Handle rendered inside one of your components never appears in that JSX, so the item can't see it.
Fix. Declare it: <Sortable.Item hasHandle> (or Board.Card hasHandle). The walk stays as the fallback for directly-nested handles.
In a Board, the dragged card gets clipped when it leaves a column.
Cause. Cross-container drags need a portal ghost; a column's overflow clips the self-translated item.
Fix. Render a <Sortable.Overlay>{(value) => <Card id={value}/>}</Sortable.Overlay> inside the Sortable.Group.
The list can't be scrolled by touch on mobile.
Cause. Setting touch-action:none on rows, or expecting an immediate touch drag.
Fix. Don't set touch-action yourself — the whole-item touch sensor is a long-press (touchDelay, default 140ms), so a quick swipe scrolls and a hold picks up. Use a handle for immediate touch drag.
Dragging one of several selected items only moves that one.
Cause. The selection isn't passed to Sortable.
Fix. Pass the selected keys as selectedValues — grabbing a selected item then drags the whole set contiguously to the drop.
Releasing a drag outside the list (past its edge, or over empty space) still moves the item instead of cancelling.
Cause. A pointer release always commits to the last projected slot — sortable holds the item at the nearest edge as you drag past the ends, so letting go there drops it into that position by design (it never leaves a drag half-finished).
Fix. To abandon a drag, press Escape (or fire a pointer-cancel) — that restores the item to its original position. Dropping, even outside the bounds, is a commit.
You can't find a Sortable.Instructions component to render, or wonder how each activator's aria-describedby resolves.
Cause. There is no Sortable.Instructions part — the keyboard-instructions node is not something you render.
Fix. Render nothing for it: the Sortable root injects one shared visually-hidden node (id garn-sortable-instructions) into document.body on mount, and every activator's aria-describedby points at it. To customize the spoken pick-up/move/drop/cancel text, pass announcements.
Accessibility
- ARIA APG
- Drag-and-Drop (keyboard-operable reorder)
- Focus
- The drag activator (a Sortable.Handle, or the bare Sortable.Item) is a focusable button; focus stays on the moved item after a keyboard drop or cancel. Don't strip the focus ring.
Accessibility requirements
The drag activator must have an accessible name (Sortable.Handle defaults to "Drag to reorder"; override via `label`, or name the item).
when a Sortable.Handle (or a bare-item activator) is icon-only
No consumer markup is needed: the Sortable root injects one shared visually-hidden keyboard-instructions node (id `garn-sortable-instructions`) into document.body on mount — idempotent by id — and every activator's aria-describedby points at it. Don't remove that node or hand-render a duplicate; to change the spoken text, override `announcements`.
when any Sortable (or the useSortable hook) is rendered
Keyboard
| SpaceEnter | On the drag activator: pick the item up; press again to drop it at the chosen position. |
| ArrowUpArrowDown | Move the lifted item (main axis of a vertical list / a whole row in a grid / between columns in a horizontal Board). |
| ArrowLeftArrowRight | Move the lifted item along the horizontal axis / one cell in a grid / between Board columns (RTL-aware). |
| HomeEnd | Send the lifted item to the first / last position. |
| Escape | Cancel the drag and restore the item to its original position. Works for pointer drags too — a window-level listener for the drag's duration, so it cancels even when the activator isn't focused (Safari doesn't focus buttons on mousedown). |
- The drag activator — a dedicated Sortable.Handle button, or the Sortable.Item itself when it has no handle — is a real button with an accessible name, aria-roledescription="sortable", and aria-describedby pointing at a shared visually-hidden keyboard-instructions node that the Sortable root injects into document.body on mount (id `garn-sortable-instructions`, idempotent by id) — consumers render nothing for it. Composing a handle keeps semantic <li>/<tr>/treeitem rows intact (a role=button on the row would break their list/grid/tree semantics), so headless integrations (List, Tree) drag via a handle.
- Keyboard drag mode: Space or Enter on the activator lifts the item; the arrow keys move it (↑/↓ for a vertical list, ←/→ for horizontal, the cross-axis hops between columns in a Board, spatial in a grid); Home/End jump to the first/last position; Space or Enter drops and commits; Escape cancels and restores. Focus follows the item throughout.
- A polite aria-live region announces pick-up, each position change, drop, and cancel ("Picked up X. Position 2 of 5." … "Dropped X. Position 3 of 5."). The four messages are overridable via `announcements` for domain language (e.g. "Moved to Done"). Multi-item drags announce the count.
- Motion is not required to operate: the dragged item follows the pointer with no transition (the drag itself), sibling gaps smooth under `--garn-motion-fast` and collapse to instant under prefers-reduced-motion; on touch a long-press (default 140ms) starts a drag so a swipe still scrolls the list.