List reorder

Free · MIT

Drag-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.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/list-reorder.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Rows with their own buttons or inputshandle="start" (default) or handle="end", so only the grip starts a drag
Simple rows where the whole row should be grabbablehandle="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

PropTypeDefault
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) => stringgetKey
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 / ArrowDownOn 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 + ArrowDownWith focus on anything inside a row, moves that row one position.
TabMoves 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