List reorder
Free · MITDrag-to-reorder list with a grip handle, a lifted shadow on the dragged row, neighbours that spring into place, and Alt+Arrow keyboard moves announced to screen readers.
Installation
$ npx shadcn@latest add https://goodui.dev/r/list-reorder.jsonThe file lands in components/goodui/list-reorder.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/list-reorder works too.
Usage
import * as React from "react"
import { ListReorder } from "@/components/goodui/list-reorder"
type Service = { id: string; name: string; minutes: number }
export function ServiceOrder() {
const [services, setServices] = React.useState<Service[]>([
{ id: "cut", name: "Haircut", minutes: 45 },
{ id: "color", name: "Full color", minutes: 120 },
{ id: "blow", name: "Blow-dry", minutes: 30 },
{ id: "beard", name: "Beard trim", minutes: 20 },
])
return (
<ListReorder
label="Services order on the booking page"
items={services}
onReorder={setServices}
getKey={(s) => s.id}
getLabel={(s) => s.name}
renderItem={(s, { index }) => (
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-medium">
{index + 1}. {s.name}
</span>
<span className="text-xs text-muted-foreground">{s.minutes} min</span>
</div>
)}
/>
)
}When to use
- Letting people set an order that matters: services on a booking page, staff priority, pipeline stages.
- Short lists (up to a few dozen rows) where dragging is faster than typing a position number.
- When the order must also be changeable from the keyboard, with screen reader announcements.
- Sorting data by a column (date, amount): sort the array and show it in table-row-presence.
- Dragging between columns or into nested groups (kanban, trees): this list reorders only within itself, vertically.
- Lists that only change by new items arriving: use animated-list.
| If you need | Use |
|---|---|
| Rows with their own buttons or inputs | handle="start" (default) or handle="end", so only the grip starts a drag |
| Simple rows where the whole row should be grabbable | handle="none": each row becomes focusable and moves with the arrow keys |
Examples
Grip on the end, translated announcements
<ListReorder
items={stages}
onReorder={setStages}
getKey={(s) => s.id}
getLabel={(s) => s.title}
handle="end"
labels={{
handle: (label, pos, count) => `${label} neu anordnen, Position ${pos} von ${count}`,
moved: (label, pos, count) => `${label} auf Position ${pos} von ${count} verschoben`,
}}
renderItem={(s) => <span className="text-sm">{s.title}</span>}
/>Whole row draggable
<ListReorder
handle="none"
items={staff}
onReorder={setStaff}
getKey={(m) => m.id}
getLabel={(m) => m.name}
renderItem={(m) => <span className="text-sm font-medium">{m.name}</span>}
/>API reference
| Prop | Type | Default |
|---|---|---|
| items* Items in their current order (controlled). | T[] | — |
| onReorder* Called with the whole list in its new order after a drag step or keyboard move; store it back into `items`. | (items: T[]) => void | — |
| getKey* Stable unique key of an item. Also the default for `getLabel`. | (item: T) => string | — |
| renderItem* Content of a row. `state.index` is the current position, `state.dragging` is true while that row is held. | (item: T, state: ListReorderRenderState) => React.ReactNode | — |
| getLabel Plain-text name of an item, used for the handle label and screen reader announcements. | (item: T) => string | getKey |
| handle Where the grip sits. "none" makes the whole row draggable. | "start" | "end" | "none" | "start" |
| label Accessible name of the list. | string | "Reorderable list" |
| itemClassName Classes added to every row (<li>). | string | — |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<ListReorderLabels> | — |
Also accepts every <div> attribute.
Accessibility
| ArrowUp / ArrowDown | On a focused grip handle, moves that row up or down one position (stops at the ends). |
| ArrowUp / ArrowDown (handle="none") | On a focused row, moves that row one position. |
| Alt + ArrowUp / Alt + ArrowDown | With focus on anything inside a row, moves that row one position. |
| Tab | Moves between grip handles and the rows' own controls. |
- With handle="none" each row is focusable (tabIndex 0, visible focus ring) and described by the same text as the grip label (labels.handle).
- The grip is a real <button> whose aria-label names the item and its position ("Reorder Haircut, position 2 of 4. Use the arrow keys to move.").
- Keyboard moves and drops are announced through an assertive live region, using getLabel for the item name.
- The list is a <ul> named by the label prop; every announcement and the handle label can be translated through labels.
Motion
- Rows spring into their new place (stiffness 520, damping 42) as the dragged row passes them.
- The held row scales to 1.025 and gets a primary-tinted border and a large shadow (CSS, 200 ms).
- Reduced motion: no lift scale, and rows move to their new place instantly; dragging still works.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- The list is controlled: items plus onReorder are required, and onReorder must store the new array (setItems), or nothing moves.
- getKey must return a stable unique id, never the array index.
- Pass getLabel returning plain text; without it the key is read out to screen readers.
- With handle="none" the row itself takes focus and moves with plain arrows; keep renderItem free of extra buttons unless they are needed (Alt+Arrow still works from inside them).
- onReorder fires while dragging, not only on drop; debounce saving to the server.
FAQ
How do I save the new order?
onReorder gives you the full array in its new order. Store it in state and persist it, preferably debounced, because it fires on every step of a drag.
Does it work on touch screens?
Yes. The grip (or the whole row with handle="none") sets touch-action: none so a drag does not scroll the page.
Related
Data table where new rows grow in with a brief highlight, removed rows slide out and collapse, and re-sorting glides every row to its new position.
Notification stack where new items spring in from the top, older ones glide down, slightly dimmed in the depth variant, and items beyond the limit blur away.
Appointment rows that open inline into a raised card with notes and working actions, while neighbouring rows smoothly make room.