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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/reorderableIts 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.13import { 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
ItemGroupinstead for a list whose order the user does not change, andResizableto 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 incontent:items={[{ id: "1", label: "Sear", content: <Item title="Sear" /> }]}.onChange: Not a prop. Ouro UI usesonReorder:onReorder={(ids) => save(ids)}.onDragEnd: Not a prop. Ouro UI usesonReorder:onReorder={(ids) => save(ids)}.renderItem: Not a prop. Each row'scontentis what it shows:items={[{ id: "1", label: "Sear", content: <Item title="Sear" /> }]}.values: Not a prop. Ouro UI usesitems: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;
}