Reorderable list
ProA list a person puts in order: by dragging an item's handle, from the keyboard (Space or Enter on the handle picks it up, the arrow keys move it, Space or Enter drops it, Escape puts it back), or from the menu beside the handle (Move up, Move down, Move to top, Move to bottom, Move to another group), for people who don't drag or use the arrow keys. With groups, items also move between groups: left and right from the keyboard, following the reading direction.
Every step is announced ("Billing, position 3 of 8 in Next", "Dropped", "Cancelled: Billing back at position 2"), and focus stays on the moved item's handle after a drop: the item is drawn in its new place, not drawn again. A move is only a preview until it's dropped, so your onReorder or onMove is called once, with where it ended. Items your app adds or removes are announced too, and when a removed item held focus, focus goes to the next item's handle. When your app moves an item while a control inside it has focus (a card's own Undo) and that control isn't drawn again, focus goes to the item's handle in its new place.
A group's heading can be drawn by groupHeader (a count, a limit) in place of its label, and groups can fold to their heading with collapsed and onCollapsedChange (a "Collapse {group}" button with aria-expanded). A folded group is still a place to move to, from the menu and the arrow keys: the item being moved is drawn in it while it is there, and an item dropped there is announced as anywhere else, with focus on the group's heading.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0 and 4.2.0: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/reorderable-list
Brings in: bidi, dropdown-menu, heading-level, heading-scope, layer, locale, locked, motion, tokens, use-announcer, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { ReorderableList, type ReorderableGroup } from "@/components/candor/reorderable-list"
type Check = { id: string; name: string; owner: string }
const checks: Check[] = [
{ id: "lint", name: "Lint", owner: "Build agent" },
{ id: "types", name: "Type check", owner: "Build agent" },
{ id: "unit", name: "Unit tests", owner: "Test agent" },
{ id: "e2e", name: "End-to-end tests", owner: "Test agent" },
]
/** One list: the order the checks run in. Drag a handle, or press Space on it and use the arrow keys, or use the menu. */
function Order() {
const [items, setItems] = React.useState(checks)
return (
<ReorderableList
label="Checks, in the order they run"
items={items}
keyOf={(c) => c.id}
labelOf={(c) => c.name}
onReorder={setItems}
renderItem={(c, { index }) => (
<div className="flex items-baseline gap-2 px-1 text-sm">
<span className="w-4 text-xs tabular-nums text-muted-foreground">{index + 1}</span>
<span className="font-medium">{c.name}</span>
<span className="ms-auto text-xs text-muted-foreground">{c.owner}</span>
</div>
)}
className="max-w-md"
/>
)
}
/**
* Groups: checks move between Required and Optional, with the left and right arrow keys or the menu. Each heading shows
* its count, and a group folds to its heading; a folded group can still be moved into from the menu.
*/
function Groups() {
const [collapsed, setCollapsed] = React.useState<string[]>([])
const [groups, setGroups] = React.useState<ReorderableGroup<Check>[]>([
{ id: "required", label: "Required before merge", items: checks.slice(0, 3) },
{ id: "optional", label: "Optional", items: checks.slice(3) },
])
const move = (key: string, to: string, index: number) =>
setGroups((all) => {
const item = all.flatMap((g) => g.items).find((c) => c.id === key)
if (!item) return all
return all.map((g) => {
const rest = g.items.filter((c) => c.id !== key)
return g.id === to ? { ...g, items: [...rest.slice(0, index), item, ...rest.slice(index)] } : { ...g, items: rest }
})
})
return (
<ReorderableList
label="Merge checks"
items={[]}
groups={groups}
keyOf={(c) => c.id}
labelOf={(c) => c.name}
onReorder={() => {}}
onMove={move}
groupHeader={(g, { count }) => (
<span className="flex items-baseline gap-1.5">
{g.label}
<span className="tabular-nums text-faint-foreground">{count}</span>
</span>
)}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
renderItem={(c) => <span className="px-1 text-sm">{c.name}</span>}
className="max-w-xl"
/>
)
}
export default function Example() {
return (
<div className="flex flex-col gap-8">
<Order />
<Groups />
</div>
)
}<ReorderableList>
| Prop | Type | Default | Description |
|---|---|---|---|
| itemsrequired | T[] | — | The items, in order. With `groups`, the groups hold the items and this is not read: pass `[]`. |
| keyOfrequired | (item: T) => string | — | A stable key for an item, used to keep it in place while it moves. |
| labelOfrequired | (item: T) => string | — | What an item is called, for its handle ("Move Billing") and the announcements. Drawn and read as text. |
| renderItemrequired | (item: T, state: { grabbed: boolean; index: number }) => React.ReactNode | — | Draws an item's content beside its handle; `grabbed` is true while it is being moved, `index` is where it is. |
| onReorderrequired | (items: T[]) => void | — | Called with the items in their new order when a move is dropped (without `groups`). |
| labelrequired | string | — | What the list is, for screen readers: "Steps", "Columns shown". |
| groups | ReorderableGroup<T>[] | undefined | — | Groups an item can move between, each with its own items. Moves within and between them go to `onMove`. |
| onMove | ((key: string, toGroup: string, index: number) => void) | undefined | — | With `groups`: called when a move is dropped, with the item's key, the group it went to and its index there. |
| locked | string | null | undefined | — | Why the list can't be reordered now. Every handle and menu stays reachable and says why. `null` or left out: unlocked. |
| groupHeader | ((group: ReorderableGroup<T>, state: ReorderableGroupState) => React.ReactNode) | undefined | — | With `groups`: draws a group's heading in place of its label text (its name, a count, a limit). The list is then named by the group's label. |
| collapsed | string[] | undefined | — | With `groups`: the ids of the groups folded to their heading. They are still places to move to. |
| onCollapsedChange | ((collapsed: string[]) => void) | undefined | — | With `groups`: called with the new folded ids when the person folds or opens a group. Given, each heading has a "Collapse {group}" button. |
| headingLevel | HeadingLevel | undefined | — | With `groups`: the level of each group's heading, so the list fits under the page's own headings. Defaults to one below the nearest `HeadingScope`, else 2 (`<h2>`). A heading in an item sits one below its group's. |
| className | string | undefined | — | Classes for the root, merged with Candor's. |
Strings
The fixed words, in English by default. Change any of them under reorderableList in <LocaleProvider strings={{ … }}>; the table is exported as reorderableListStrings.
| Key | English |
|---|---|
| handle | Move {label} |
| menu | Ways to move {label} |
| moveUp | Move up |
| moveDown | Move down |
| moveTop | Move to top |
| moveBottom | Move to bottom |
| moveTo | Move to {group} |
| atTop | It’s already first. |
| atBottom | It’s already last. |
| grabbed | {label} picked up, position {position} of {count}. Arrow keys move it, Space drops it, Escape puts it back. |
| grabbedIn | {label} picked up, position {position} of {count} in {group}. Arrow keys move it, Space drops it, Escape puts it back. |
| at | {label}, position {position} of {count} |
| atIn | {label}, position {position} of {count} in {group} |
| dropped | Dropped: {label}, position {position} of {count} |
| droppedIn | Dropped: {label}, position {position} of {count} in {group} |
| cancelled | Cancelled: {label} back at position {position} |
| cancelledIn | Cancelled: {label} back at position {position} in {group} |
| added | {label} added |
| removed | {label} removed |
| addedMany | { one: "{count} item added", other: "{count} items added" } |
| removedMany | { one: "{count} item removed", other: "{count} items removed" } |
| emptyGroup | Nothing here yet. |
| collapse | Collapse {group} |
Motion
| Moment | Family | What it does |
|---|---|---|
| grab | pop, scale | An item picked up is framed, the frame popping in round it; a drawing (`swell`, `lift`, `tilt`, `ghost`) changes how. |
| item | pop, scale | An item your app adds after the list first drew pops in, and one it removes pops out from its place. |
| settle | move | After a move, the items that changed place slide from where they were. Under reduced motion they are simply in their new places. |
Set with --cd-motion-reorderable-list-<moment>-* in your brand file; the families are in the design reference.
Types
- ReorderableGroup
- A group of a ReorderableList: its id, its name (drawn as text) and its items, in order.
{ id: string; label: string; items: T[] }- ReorderableGroupState
- What a group's heading is drawn with: how many items it holds, and whether it is folded.
{ count: number; collapsed: boolean }
Types from other items
The props above take these. Each is defined by another item; import it from the path shown.
- HeadingLevel from @/lib/candor/heading-level
- A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6