Sortable list
A vertical list reordered by drag handle or keyboard.
Approval chain
Invoices over $25,000 go to each approver in this order.
- 1JLJordan LeeAP manager
- 2PRPriya RamanController
- 3MOMaya OkaforVP of Revenue
- 4ABAisha BelloCFO
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { SortableList } from "@oration/canon/components/sortable-list";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { XIcon } from "lucide-react";import * as React from "react";export function Hero() { const [approvers, setApprovers] = React.useState([ { id: "jl", name: "Jordan Lee", initials: "JL", role: "AP manager" }, { id: "pr", name: "Priya Raman", initials: "PR", role: "Controller" }, { id: "mo", name: "Maya Okafor", initials: "MO", role: "VP of Revenue", }, { id: "ab", name: "Aisha Bello", initials: "AB", role: "CFO" }, ]); return ( <section aria-labelledby="approval-chain-title" className="w-full max-w-md rounded-xl bg-card p-4 shadow-border" > <h3 id="approval-chain-title" className="text-sm font-semibold"> Approval chain </h3> <p className="mb-3 text-13 text-muted-foreground"> Invoices over $25,000 go to each approver in this order. </p> <SortableList items={approvers} getId={(approver) => approver.id} onReorder={(next) => { setApprovers(next); toast.add({ title: "Approval order updated", description: next .map((a) => a.name.split(" ")[0]) .join(", then "), }); }} className="gap-1" renderItem={(approver, { handle, dragging, index }) => ( <div className={cn( "flex items-center gap-2 rounded-[10px] bg-card py-1.5 pr-1 pl-1", dragging && "shadow-border-hover", )} > {handle} <span className="w-4 text-right text-xs text-muted-foreground tabular-nums"> {index + 1} </span> <Avatar size="sm"> <AvatarFallback>{approver.initials}</AvatarFallback> </Avatar> <span className="min-w-0 flex-1"> <span className="block truncate text-13 font-medium"> {approver.name} </span> <span className="block truncate text-xs text-muted-foreground"> {approver.role} </span> </span> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove ${approver.name}`} onClick={() => setApprovers((current) => current.filter( (a) => a.id !== approver.id, ), ) } /> } > <XIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Remove</TooltipContent> </Tooltip> </div> )} /> </section> );}Usage#
Sortable list is a vertical list that people reorder by dragging a handle or with the keyboard, built on dnd-kit. In Oration it orders the things whose order changes behavior: ticket statuses in a workflow, custom attributes on a form, an approval chain. It hands each row a ready-made handle and leaves the row's look to you. The mistake is making a list sortable when its order means nothing; if nothing reads the order, don't offer the drag.
When to use
- When the order is a setting: approval steps, workflow statuses, the fields on a form, fallback rules.
- For short lists, up to about twenty rows, that fit on screen while dragging.
- For rows that are also editable, such as an input with a remove button, where only the handle starts a drag.
Show the order that matters
The Hairline-and-Lift Rule
Anatomy#
- ReceivedInvoice is in the inbox
- Needs approvalWaiting on an approver
- PaidIncluded in a payment run
- List.
SortableListrenders a<ul>. Style it withclassName: a card withdivide-y, or a gap between free-standing rows. - Handle. A 24px button with a 14px grip in Faint Slate. Only the handle starts a drag.
- Row content. Whatever
renderItemreturns, with the handle placed where you put{handle}. - Dragged row. The row itself moves (no overlay), raised above its neighbors. Lift it with
dragging.
Examples#
Divided card
The workflow statuses recipe: className="divide-y divide-border rounded-xl bg-card shadow-border" on the list, and a surface with a 10px corner and hairline lift on the row while dragging.
- ReceivedInvoice is in the inbox
- CodedGL codes and cost centers set
- Needs approvalWaiting on an approver
- ScheduledIn an upcoming payment run
- PaidRemittance sent to the supplier
import { SortableList } from "@oration/canon/components/sortable-list";import { Tag, type TagColor } from "@oration/canon/components/tag";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function DividedCard() { const [statuses, setStatuses] = React.useState< { id: string; name: string; color: TagColor; description: string }[] >([ { id: "received", name: "Received", color: "blue", description: "Invoice is in the inbox", }, { id: "coded", name: "Coded", color: "violet", description: "GL codes and cost centers set", }, { id: "approval", name: "Needs approval", color: "amber", description: "Waiting on an approver", }, { id: "scheduled", name: "Scheduled", color: "teal", description: "In an upcoming payment run", }, { id: "paid", name: "Paid", color: "green", description: "Remittance sent to the supplier", }, ]); return ( <div className="w-full max-w-lg"> <SortableList items={statuses} getId={(status) => status.id} onReorder={setStatuses} className="divide-y divide-border rounded-xl bg-card shadow-border" renderItem={(status, { handle, dragging }) => ( <div className={cn( "flex items-center gap-2 bg-card px-2 py-2 sm:px-3", dragging && "rounded-[10px] shadow-border", )} > {handle} <Tag color={status.color} dot> {status.name} </Tag> <span className="min-w-0 truncate text-13 text-muted-foreground"> {status.description} </span> </div> )} /> </div> );}Editable rows
Inputs and a remove button beside the handle. Only the handle drags, so typing and selecting text work as usual.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { SortableList } from "@oration/canon/components/sortable-list";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { cn } from "@oration/canon/lib/utils";import { PlusIcon, XIcon } from "lucide-react";import * as React from "react";export function EditableRows() { const [fields, setFields] = React.useState([ { id: "f1", label: "PO number" }, { id: "f2", label: "Cost center" }, { id: "f3", label: "Project code" }, ]); return ( <div className="flex w-full max-w-md flex-col gap-2"> <SortableList items={fields} getId={(field) => field.id} onReorder={setFields} className="gap-2" renderItem={(field, { handle, dragging, index }) => ( <div className={cn( "flex items-center gap-2 rounded-[10px] bg-background", dragging && "shadow-border-hover", )} > {handle} <Input aria-label={`Field ${index + 1} name`} value={field.label} onChange={(event) => setFields((current) => current.map((f) => f.id === field.id ? { ...f, label: event.target.value, } : f, ), ) } /> <Tooltip> <TooltipTrigger render={ <Button type="button" variant="ghost" size="icon" aria-label={`Remove ${field.label || `field ${index + 1}`}`} onClick={() => setFields((current) => current.filter( (f) => f.id !== field.id, ), ) } /> } > <XIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Remove</TooltipContent> </Tooltip> </div> )} /> <Button type="button" variant="ghost" size="sm" className="self-start" onClick={() => setFields((current) => [ ...current, { id: `f${Date.now()}`, label: "" }, ]) } > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Add field </Button> </div> );}States#
import { cn } from "@oration/canon/lib/utils";import { GripVerticalIcon } from "lucide-react";export function StatesMatrix() { return ( <div className="flex flex-col items-center gap-6"> <div className="flex flex-wrap justify-center gap-6"> {handleStates.map((state) => ( <div key={state.label} className="flex flex-col items-center gap-2" > <span className={cn( "inline-flex size-6 items-center justify-center rounded-md text-subtle-foreground", state.className, )} > <GripVerticalIcon aria-hidden="true" className="size-3.5" /> </span> <span className="text-xs text-muted-foreground"> {state.label} </span> </div> ))} </div> <div className="flex flex-wrap justify-center gap-4"> {[ { label: "Row at rest", className: "" }, { label: "Row while dragged", className: "shadow-border-hover", }, ].map((row) => ( <div key={row.label} className="flex flex-col items-center gap-2" > <div className={cn( "flex w-56 items-center gap-2 rounded-[10px] bg-card px-2 py-2", row.className, )} > <GripVerticalIcon aria-hidden="true" className="size-3.5 text-subtle-foreground" /> <span className="text-13">Priya Raman</span> </div> <span className="text-xs text-muted-foreground"> {row.label} </span> </div> ))} </div> </div> );}| State | Treatment |
|---|---|
| Rest | Handle in Faint Slate with a grab cursor. |
| Handle hover | Well Gray fill and ink grip. |
| Handle focus visible | A 3px Focus Indigo ring at 40%. |
| Dragging | dragging is true for the moving row: give it a surface and the hover lift. The cursor turns to grabbing and other rows slide aside. |
| Keyboard lifted | After Space on the handle, the row moves one slot per arrow key until it's dropped. |
Behavior#
- The list is controlled. On drop,
onReordergets the reordered array; store it. Dropping in place does nothing. - A pointer drag starts after 4px of movement, so a click on the handle doesn't start one.
- Movement is locked to the vertical axis, and the row snaps to the nearest slot by its center.
renderItemreceiveshandle(place it in the row),draggingandindex(for numbering).- There is no drag overlay: the row itself translates, with dnd-kit's slide transition on its neighbors.
Do and don't#
- 1Jordan Lee
- 2Priya Raman
- 3Maya Okafor
- Jordan Lee
- Priya Raman
- Maya Okafor
Content#
- The section description says what the order does: Invoices go to each approver in this order.
- Name remove buttons after the row: Remove Priya Raman, Remove Cost center.
- Confirm a reorder that changes behavior in a toast that states the new order.
Accessibility#
- Each handle is a real
<button>with dnd-kit'saria-roledescription="sortable"and instructions, so it can be picked up from the keyboard. - Every handle is named Drag to reorder and there's no way to change it, so screen readers hear the same name on every row; see known gaps.
- dnd-kit announces pick up, move and drop with item ids, not names.
- Dragging isn't the only way to reorder for keyboard users, but pointer users without fine control have no Move up or Move down alternative unless you add one.
- Rows are
<li>items in a<ul>, so the count is announced.
| Keys | Action |
|---|---|
| Tab | Moves to the next handle or control. |
| Space | Picks up the row, or drops it. |
| Enter | Picks up or drops the row. |
| ↑↓ | Moves the lifted row one slot. |
| Esc | Cancels and puts the row back. |
Design tokens#
| Token | Used for |
|---|---|
--subtle-foreground | Faint Slate grip at rest |
--muted | Handle hover fill |
--ring | Handle focus ring at 40% |
shadow-border-hover | The lifted row (set by you) |
--radius-md | 8px handle corners |
API reference#
SortableList
A sortable <ul>. Generic over the item type T.
| Prop | Type | Default | Description |
|---|---|---|---|
itemsRequired | T[] | No default | Rows in order. |
getIdRequired | (item: T) => string | No default | A stable id per row. |
onReorderRequired | (items: T[]) => void | No default | Called with the new order on drop. |
renderItemRequired | (item: T, state: { handle: ReactNode; dragging: boolean; index: number }) => ReactNode | No default | Renders a row. Place handle inside it. |
className | string | No default | Applied to the <ul>, merged after flex flex-col. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The handle's aria-label is fixed at Drag to reorder. It can't be named after its row, so a screen reader hears identical buttons.
Announcements are dnd-kit's defaults and read row ids, not names.
There's no single-pointer alternative to dragging (such as Move up and Move down in a row menu), which WCAG 2.5.7 asks for.
The neighbors' slide transition and the dragged row's movement aren't reduced for reduced-motion users.
There's no way to disable sorting for one row, such as a fixed first status.