## Reorder List — Gesture
The gap the siblings open is the drop target.
Docs: https://www.interior.dev/docs/reorder-list
Reference: https://www.interior.dev/reference/reorder-list
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/reorder-list.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { useState } from "react";
import { ReorderList } from "@/components/interior/reorder-list";
type Step = { id: string; name: string };
export function PipelineEditor({ initial }: { initial: Step[] }) {
const [steps, setSteps] = useState(initial);
return (
s.id}
getLabel={(s) => s.name}
onReorder={setSteps}
onCommit={(next) =>
void fetch("/api/pipeline", {
method: "POST",
body: JSON.stringify(next.map((s) => s.id)),
})
}
label="Pipeline steps"
>
{(s) => {s.name}}
);
}
```
### Props
- `items` (`readonly T[]`)
The list, in its current order. The component never owns the data.
- `getId` (`(item: T) => string`)
Stable identity for each row. Keys, focus and announcements all hang off it.
- `getLabel` (`(item: T) => string`)
What the screen reader calls the row while it moves.
- `onReorder` (`(next: T[]) => void`)
Fires live as the order changes, once per crossing, so the gap can follow the drag.
- `onCommit` (`(next: T[]) => void`)
Fires once when the drag drops or a keyboard move lands. The one to persist from.
- `children` (`(item: T) => ReactNode`)
The row's content. The grip, lift and focus are already handled around it.
- `label` (`string`)
Accessible name of the list.
- `disabled` (`boolean`) — default: `false`
Freezes the order and takes the rows out of the tab order.
- `className` (`string`) — default: `""`
Appended to the outer wrapper.
### Behavior notes
- There is no ghost. The thing you are dragging is the row itself, lifted off the surface, and the gap the siblings open is the drop target — a floating copy would be one statement too many.
- Siblings close and open the gap with a layout spring; the dragged row never animates its height and the list never reflows more than the two rows trading places.
- The keyboard is a second complete implementation: Space grabs, arrows carry the row a slot at a time, Space drops, Escape restores the order from before the grab — pointer drags get the same Escape.
- A grabbed row wears the accent, because the system is responding to you right now; a merely hovered row only lifts.
- One announcement per move — 'position 2 of 5' — never a stream, and blurring a grabbed row cancels instead of leaving it stranded.
- onReorder keeps the preview live while onCommit fires once at the drop, so persistence code runs when the person decides, not while they are deciding.
- Under prefers-reduced-motion the rows trade places instantly; the order still changes, only the travel is skipped.
### Source (`components/interior/reorder-list.tsx`)
```tsx
"use client";
import { useCallback, useId, useRef, useState } from "react";
import { Reorder, useReducedMotion } from "motion/react";
const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const INSTANT = { duration: 0 } as const;
const moveItem = (list: readonly T[], from: number, to: number): T[] => {
const next = [...list];
const [taken] = next.splice(from, 1);
next.splice(to, 0, taken);
return next;
};
export type UseReorderListOptions = {
items: readonly T[];
getId: (item: T) => string;
getLabel: (item: T) => string;
onReorder: (next: T[]) => void;
onCommit?: (next: T[]) => void;
disabled?: boolean;
};
export function useReorderList({
items,
getId,
getLabel,
onReorder,
onCommit,
disabled = false,
}: UseReorderListOptions) {
const [grabbed, setGrabbed] = useState(null);
const [dragging, setDragging] = useState(null);
const [spoken, setSpoken] = useState("");
const emit = useRef(onReorder);
emit.current = onReorder;
const settle = useRef(onCommit);
settle.current = onCommit;
const live = useRef(items);
live.current = items;
const snapshot = useRef(null);
const indexOf = useCallback(
(id: string) => live.current.findIndex((item) => getId(item) === id),
[getId],
);
const grab = useCallback(
(id: string) => {
snapshot.current = live.current;
setGrabbed(id);
const at = indexOf(id);
const item = live.current[at];
setSpoken(
`${getLabel(item)} grabbed, position ${at + 1} of ${live.current.length}.`,
);
},
[getLabel, indexOf],
);
const drop = useCallback(
(id: string) => {
snapshot.current = null;
setGrabbed(null);
const at = indexOf(id);
const item = live.current[at];
setSpoken(`${getLabel(item)} dropped at position ${at + 1}.`);
settle.current?.([...live.current]);
},
[getLabel, indexOf],
);
const cancel = useCallback(() => {
if (snapshot.current) emit.current([...snapshot.current]);
snapshot.current = null;
setGrabbed(null);
setSpoken("Reorder cancelled, original order restored.");
}, []);
const step = useCallback(
(id: string, delta: -1 | 1) => {
const from = indexOf(id);
const to = from + delta;
if (from < 0 || to < 0 || to >= live.current.length) return;
const next = moveItem(live.current, from, to);
emit.current(next);
const item = next[to];
setSpoken(
`${getLabel(item)}, position ${to + 1} of ${next.length}.`,
);
if (snapshot.current === null) settle.current?.(next);
},
[getLabel, indexOf],
);
const rowKeyDown = useCallback(
(id: string) => (event: React.KeyboardEvent) => {
if (disabled || event.target !== event.currentTarget) return;
const held = grabbed === id;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (held) drop(id);
else grab(id);
return;
}
if ((event.key === "ArrowUp" || event.key === "ArrowDown") && held) {
event.preventDefault();
step(id, event.key === "ArrowUp" ? -1 : 1);
return;
}
if (event.key === "Escape" && held) {
event.preventDefault();
cancel();
}
},
[disabled, grabbed, grab, drop, step, cancel],
);
const onDragStart = useCallback(
(id: string) => {
snapshot.current = live.current;
setDragging(id);
},
[],
);
const onDragEnd = useCallback(
(id: string) => {
snapshot.current = null;
setDragging(null);
const at = indexOf(id);
const item = live.current[at];
setSpoken(`${getLabel(item)} dropped at position ${at + 1}.`);
settle.current?.([...live.current]);
},
[getLabel, indexOf],
);
return {
grabbed,
dragging,
spoken,
grab,
drop,
cancel,
step,
rowKeyDown,
onDragStart,
onDragEnd,
};
}
export type ReorderListProps = UseReorderListOptions & {
children: (item: T) => React.ReactNode;
label: string;
className?: string;
};
const GRIP = (
);
export function ReorderList({
children,
label,
className = "",
...options
}: ReorderListProps) {
const { items, getId, getLabel, onReorder, disabled = false } = options;
const list = useReorderList(options);
const reduced = useReducedMotion() === true;
const hintId = useId();
return (