Lists

Reorderable

A list the user puts in order: recipe steps, a queue, favorites. Each row has a drag handle, a 44px target on a touch screen. Dragging it moves the row with the pointer, the other rows make room, and holding it near the top or bottom edge scrolls the list or the page. Let go and the row drops into the gap the others made for it, springing there from where it was, and it can be picked up again on the way. A touch that starts anywhere else on a row scrolls as usual.

  1. Sear the ribs
    Brown them hard on every side.
  2. Soften the onions
    Low heat, ten minutes.
  3. Add the wine
    Scrape up everything stuck to the pot.
  4. Braise
    Three hours at 150°C, covered.

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/components/recipe/StepList.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/reorderable#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/reorderable

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/reorderable#v1.2.13 ourostack/ouro-ui/reorderable-tests#v1.2.13
import { Reorderable } from "@/components/ouro/reorderable/reorderable";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Reorderable
  label="Steps"
  items={steps.map((step) => ({ id: step.id, label: step.title, content: <Item title={step.title} description={step.text} /> }))}
  onReorder={(ids) => setSteps(ids.map((id) => steps.find((step) => step.id === id)!))}
/>

Usage

Dragging is never the only way. A tap or click on a handle, Enter, or a screen reader's activation opens a menu with Move up, Move down, Move to top and Move to bottom; the ones that cannot apply are disabled. From the keyboard, Space on a handle also picks the row up: the up and down arrow keys move it, Space drops it and Escape cancels, which puts it back. Moving focus away drops it where it is. Every move is announced to screen readers with the row's position ("Sear the ribs moved. Position 3 of 7."), and each spoken string and menu item is a prop with an English default.

The list shows the new order at once and keeps it; onReorder reports it, once per drop. With reduced motion the other rows jump out of the way instead of sliding, and a dropped row is in its place at once.

Use instead when

  • Use ItemGroup instead for a list whose order the user does not change, and Resizable to change sizes, not order.

Props

Type: ReorderableProps.

canceledLabel
Type: string
Default: "{label} put back. Position {n} of {total}."
Announced when a move is cancelled and the row goes back.
className
Type: string
Layout only: margins, or a height with overflow-auto (max-h-80 overflow-auto) to scroll the list in place. Appearance classes warn in development.
closeLabel
Type: string
Default: "Close"
The item that closes a handle's menu on a phone, where it is a bottom sheet.
disabled
Type: boolean
Stops reordering, for example while a save of the last move is in flight: no drag, no menu of moves, no pick-up with Space. The handles stay in the tab order, marked aria-disabled, so focus stays where it is and a screen reader says they are unavailable. A row held from the keyboard or dragged when it turns on is put back, and an open menu closes.
droppedLabel
Type: string
Default: "{label} dropped. Position {n} of {total}."
Announced when a row is dropped.
handleLabel
Type: string
Default: "Reorder {label}"
Each handle's accessible name; {label} is the row's label.
heldLabel
Type: string
Default: "Picked up. Use the arrow keys to move, Space to drop, and Escape to cancel."
Read after a handle's name, in place of the instructions, while its row is picked up from the keyboard.
instructionsLabel
Type: string
Default: "Press Enter for a menu of moves. Press Space to pick up, then use the arrow keys to move, Space to drop, and Escape to cancel."
How to use a handle from the keyboard, read after its name.
items (required)
Type: readonly ReorderableItem[]
The rows, in order: { id, label, content }. Pass them in a new order to change the order from outside.
label (required)
Type: string
Names the list for screen readers, such as "Steps". It is always required.
movedLabel
Type: string
Default: "{label} moved. Position {n} of {total}."
Announced on each move, with the arrow keys or from a handle's menu.
moveDownLabel
Type: string
Default: "Move down"
The item of a handle's menu that moves the row down one place.
moveToBottomLabel
Type: string
Default: "Move to bottom"
The item of a handle's menu that moves the row to the end of the list.
moveToTopLabel
Type: string
Default: "Move to top"
The item of a handle's menu that moves the row to the start of the list.
moveUpLabel
Type: string
Default: "Move up"
The item of a handle's menu that moves the row up one place.
onReorder
Type: ((ids: string[], move: ReorderableMove) => void)
Called once when the user drops a row in a new place, or moves it from its handle's menu, with every id in the new order and the move. The list already shows the new order; save it, or keep it in state.
pickedUpLabel
Type: string
Default: "{label} picked up. Position {n} of {total}."
Announced when a row is picked up; {label}, {n} (its position) and {total} are filled in.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • axis: Not a prop. Reorderable is a vertical list.
  • children: Not a prop. Rows are data, with their content in content: items={[{ id: "1", label: "Sear", content: <Item title="Sear" /> }]}.
  • onChange: Not a prop. Ouro UI uses onReorder: onReorder={(ids) => save(ids)}.
  • onDragEnd: Not a prop. Ouro UI uses onReorder: onReorder={(ids) => save(ids)}.
  • renderItem: Not a prop. Each row's content is what it shows: items={[{ id: "1", label: "Sear", content: <Item title="Sear" /> }]}.
  • values: Not a prop. Ouro UI uses items: items={steps.map((step) => ({ id: step.id, label: step.title }))}.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

ReorderableItem

One row of a Reorderable.

interface ReorderableItem {
  /** Identifies the row; `onReorder` reports the new order as these ids. Unique in the list. */
  id: string;
  /** Names the row for its handle ("Reorder Sear the ribs") and in announcements. It is the row's content too, unless you pass `content`. */
  label: string;
  /** What the row shows, in place of the label: an `Item`, or any content. */
  content?: ReactNode;
}

ReorderableMove

The move onReorder reports: the row's id and its position before and after, counting from 0.

interface ReorderableMove {
  id: string;
  from: number;
  to: number;
}