## 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 (
{items.map((item) => { const id = getId(item); const held = list.grabbed === id; const lifted = held || list.dragging === id; return ( list.onDragStart(id)} onDragEnd={() => list.onDragEnd(id)} onBlur={() => held && list.cancel()} transition={reduced ? INSTANT : CELL} whileDrag={reduced ? undefined : { scale: 1.02 }} style={{ touchAction: "pan-x" }} className={`relative flex items-center gap-2.5 rounded-[9px] border bg-white px-3 py-2.5 outline-none transition-[border-color,box-shadow,background-color] duration-150 focus-visible:outline-none dark:bg-[#1D1D1A] ${ lifted ? "z-10 cursor-grabbing border-stone-200 shadow-[0_1px_2px_rgba(28,25,23,0.08),0_14px_28px_-16px_rgba(28,25,23,0.5)] dark:border-white/[0.16] dark:shadow-[0_2px_14px_rgba(0,0,0,0.55)]" : "cursor-grab border-stone-200 shadow-[0_1px_2px_rgba(28,25,23,0.06)] dark:border-white/[0.16] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)]" } ${ held ? "border-[#4568FF] bg-[#4568FF]/[0.04] dark:border-[#93B0FF] dark:bg-[#93B0FF]/[0.08]" : "focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]" }`} > {GRIP} {getLabel(item)}
{children(item)}
); })}
Drag to reorder. With the keyboard, Space grabs the row, the arrow keys move it, Space drops it, and Escape puts everything back. {list.spoken}
); } ```