Skip to content

Sortable list

A vertical list reordered by drag handle or keyboard.

Status
Stable
Category
Editors
Adoption
Not used yet
import { SortableList } from "@oration/canon/components/sortable-list";
packages/canon/src/components/sortable-list.tsx

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.

When not to use

  • For moving records between stages. Use Kanban
  • For sorting a table by a column. That's a sort control, not a drag. Use Data grid
  • For a list whose order is alphabetical or by date. Sort it for people instead.
  • For choosing a few items from many. Use Checkbox

Show the order that matters

When the position decides behavior, show it: number the rows or say first, then in the description, so the result of a drag is readable.

The Hairline-and-Lift Rule

A row being dragged lifts with the hover shadow on a 10px corner. Rows at rest stay flat on their card, split by hairlines.

Anatomy#

  • ReceivedInvoice is in the inbox
  • Needs approvalWaiting on an approver
  • PaidIncluded in a payment run
  1. List. SortableList renders a <ul>. Style it with className: a card with divide-y, or a gap between free-standing rows.
  2. Handle. A 24px button with a 14px grip in Faint Slate. Only the handle starts a drag.
  3. Row content. Whatever renderItem returns, with the handle placed where you put {handle}.
  4. 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#

Rest
Hover
Focus visible
Grabbing
Priya Raman
Row at rest
Priya Raman
Row while dragged
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>    );}
States
StateTreatment
RestHandle in Faint Slate with a grab cursor.
Handle hoverWell Gray fill and ink grip.
Handle focus visibleA 3px Focus Indigo ring at 40%.
Draggingdragging 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 liftedAfter Space on the handle, the row moves one slot per arrow key until it's dropped.

Behavior#

  • The list is controlled. On drop, onReorder gets 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.
  • renderItem receives handle (place it in the row), dragging and index (for numbering).
  • There is no drag overlay: the row itself translates, with dnd-kit's slide transition on its neighbors.

Do and don't#

  1. 1Jordan Lee
  2. 2Priya Raman
  3. 3Maya Okafor
Do. Number the rows when the order decides who approves first.
  • Jordan Lee
  • Priya Raman
  • Maya Okafor
Don't. Leave a meaningful order unlabeled. People can drag, but can't tell what the drag changed.
Do. Put inputs and buttons beside the handle and keep the handle the only drag target.
Don't. Make the whole row draggable when it holds inputs. Selecting text starts a drag.

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's aria-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.
Keyboard interactions
KeysAction
TabMoves to the next handle or control.
SpacePicks up the row, or drops it.
EnterPicks up or drops the row.
↑↓Moves the lifted row one slot.
EscCancels and puts the row back.

Design tokens#

Design tokens
TokenUsed for
--subtle-foregroundFaint Slate grip at rest
--mutedHandle hover fill
--ringHandle focus ring at 40%
shadow-border-hoverThe lifted row (set by you)
--radius-md8px handle corners

API reference#

SortableList

A sortable <ul>. Generic over the item type T.

Props of SortableList
PropTypeDefaultDescription
itemsRequiredT[]No defaultRows in order.
getIdRequired(item: T) => stringNo defaultA stable id per row.
onReorderRequired(items: T[]) => voidNo defaultCalled with the new order on drop.
renderItemRequired(item: T, state: { handle: ReactNode; dragging: boolean; index: number }) => ReactNodeNo defaultRenders a row. Place handle inside it.
classNamestringNo defaultApplied 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.