Sortable List
Data DisplayDrag-to-reorder, hand-written, with a keyboard mode that shares the same code path. The target index is a pure function of geometry frozen at lift and the pointer delta — monotonic in the delta by construction, so there is no feedback loop to oscillate in at any drag speed. Pointer capture, edge auto-scroll, a drop animation into the final slot, and `aria-live` announcements throughout.
Drag, or focus a row and press space
- Pointer capturedrag survives leaving the row
- Cached geometrysnapshotted once at lift — the whole reason this doesn't oscillate
- Auto-scrollnear the container edges
- Keyboard modespace, arrows, space
- Live announcementsaria-live on every move
- Drop animationglides into the final slot
- Ragged heightslayout is exact, not a uniform pitch
- Zero re-renderstransforms written straight to the DOM
Try to make it oscillate: park the cursor exactly on a boundary and jiggle. The target index is a pure function of the frozen snapshot and the pointer delta, so there is no feedback loop to buzz in.
Tab to a row, then Space to lift, ↑↓ to move, Space to drop, Esc to cancel — the entire flow without a mouse.
a → b → c → d → e → f → g → h
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* SortableList — drag-to-reorder, hand-written, with a real keyboard mode.
*
* <SortableList items={items} getId={(i) => i.id} onReorder={setItems}>
* {(item, { dragging }) => <>…row…</>}
* </SortableList>
*
* ## The whole difficulty is one bug
*
* As you drag, the list reorders beneath you. Compute the target index from
* **live** rects and the element you just displaced slides back under the
* cursor, flips the index back, and you oscillate at every boundary — at some
* drag speeds it buzzes between two positions indefinitely.
*
* The fix is that the target index must be a **pure function of frozen
* geometry and the pointer delta**, with no path back from the rendered result
* to the input:
*
* `targetIndex(slots, from, dy)`
*
* `slots` is snapshotted once at lift, and nothing inside reads the DOM. It
* resolves to whichever *original* slot centre is nearest, and because those
* centres are sorted and fixed for the whole gesture, the result is
* **monotonic in `dy`** by construction. Drag down and the index can only
* increase. There is no feedback loop to oscillate in, at any speed.
*
* Comparing against the list laid out *without* the dragged row is the more
* obvious reading and is equally free of feedback — but it closes the hole the
* row came from, which lands the decision boundary exactly on `dy = 0`, so a
* one-pixel twitch reorders the list. Keeping the hole open puts the swap at
* half a row's pitch, where it belongs. (That was a real bug here, caught by
* the geometry tests rather than by the type checker.)
*
* The visual displacement is computed from the same frozen cache, by laying the
* proposed order out from scratch (`layoutTops`) and diffing against the
* snapshot. That is exact for variable row heights, not an approximation of a
* uniform pitch.
*
* ## Cached geometry vs. auto-scroll
*
* Auto-scrolling while dragging normally means patching the cache by the scroll
* distance every frame — one more thing to get wrong. Instead the cache is
* stored in **content** coordinates (`offsetTop`), which scrolling does not
* change at all. Only the pointer delta needs the correction:
*
* `dy = (clientY − startClientY) + (scrollTop − startScrollTop)`
*
* One term, in one place, instead of rewriting every cached slot per frame.
*
* ## Keyboard mode is not a footnote
*
* Space lifts, arrows move, Space drops, Escape cancels, and every step is
* announced through an `aria-live` region. It shares the exact code path as the
* pointer drag — same cache, same `layoutTops`, same transforms — so the two
* cannot drift apart, and the keyboard move is animated for free.
*
* ## Zero re-renders while dragging
*
* Transforms are written straight to the DOM inside the rAF loop. React
* re-renders on lift and on drop, and not once in between.
*/
// ---------------------------------------------------------------------------
// Pure geometry — no DOM, no React. This is the part that must be right.
// ---------------------------------------------------------------------------
export interface SortableSlot {
/** Offset from the scroll container's content top. Scroll-independent. */
top: number;
height: number;
}
/** Move `from` to `to` in a copy. `to` is an index in the resulting array. */
export function moveItem<T>(list: T[], from: number, to: number): T[] {
const next = list.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/** The order array that results from moving `from` to `to`. */
export function orderAfterMove(count: number, from: number, to: number) {
const rest: number[] = [];
for (let i = 0; i < count; i++) if (i !== from) rest.push(i);
return [...rest.slice(0, to), from, ...rest.slice(to)];
}
/**
* Lay `order` out sequentially and return each item's new top, keyed by its
* *original* index. Uniform `gap`, but arbitrary per-item heights.
*/
export function layoutTops(
slots: SortableSlot[],
gap: number,
firstTop: number,
order: number[],
): number[] {
const tops = new Array<number>(slots.length);
let y = firstTop;
for (const idx of order) {
tops[idx] = y;
y += slots[idx].height + gap;
}
return tops;
}
/**
* Where the dragged item should land, given only the frozen snapshot and the
* pointer delta.
*
* The rule is "whichever original slot's centre is nearest". Two properties
* fall out of that, and both matter:
*
* - **Monotonic in `dy`.** The centres are sorted, so the nearest-centre index
* can only increase as the drag moves down. There is no path from the
* rendered result back into this calculation, so there is nothing to
* oscillate — at any drag speed.
* - **`dy = 0` is a no-op.** Comparing against the layout of the list with the
* dragged item *removed* seems more natural, but that closes the hole the
* item came from, which puts a threshold exactly at `dy = 0`: pressing down
* and twitching one pixel would swap rows. Comparing against the original
* centres keeps the hole open, so the swap happens at half a row's pitch,
* where it belongs.
*/
export function targetIndex(
slots: SortableSlot[],
from: number,
dy: number,
): number {
const center = slots[from].top + dy + slots[from].height / 2;
let best = 0;
let bestDistance = Infinity;
for (let i = 0; i < slots.length; i++) {
const distance = Math.abs(center - (slots[i].top + slots[i].height / 2));
// Strict `<` resolves a dead-centre tie to the earlier slot, so a drag
// parked exactly on a boundary settles instead of flickering.
if (distance < bestDistance) {
bestDistance = distance;
best = i;
}
}
return best;
}
/** Uniform gap inferred from the snapshot. Zero for a single-item list. */
export function inferGap(slots: SortableSlot[]): number {
if (slots.length < 2) return 0;
return Math.max(0, slots[1].top - (slots[0].top + slots[0].height));
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const EDGE = 52;
const MAX_SCROLL_SPEED = 900;
const DROP_MS = 220;
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
interface DragState {
from: number;
mode: "pointer" | "keyboard";
to: number;
}
export interface SortableItemState {
/** True for the row being dragged or lifted. */
dragging: boolean;
index: number;
}
export interface SortableListProps<T> {
items: T[];
getId: (item: T) => string | number;
/** Called once, on drop, with the reordered array. */
onReorder: (next: T[]) => void;
/** Used for the `aria-live` announcements. Defaults to the id. */
getLabel?: (item: T) => string;
children: (item: T, state: SortableItemState) => React.ReactNode;
/**
* Only start a pointer drag from an element marked
* `data-sortable-handle` inside the row. Keyboard is unaffected — the row
* itself is always focusable and operable.
*/
handle?: boolean;
/** Auto-scroll the nearest scrollable ancestor near its edges. */
autoScroll?: boolean;
className?: string;
itemClassName?: string;
}
export function SortableList<T>({
items,
getId,
onReorder,
getLabel,
children,
handle = false,
autoScroll = true,
className,
itemClassName,
}: SortableListProps<T>) {
const listRef = React.useRef<HTMLUListElement>(null);
const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const [drag, setDrag] = React.useState<DragState | null>(null);
const [announcement, setAnnouncement] = React.useState("");
// Frozen at lift. Nothing in the gesture ever re-reads the DOM for geometry.
const cache = React.useRef<{
slots: SortableSlot[];
gap: number;
firstTop: number;
scroller: HTMLElement | null;
startClientY: number;
startScrollTop: number;
pointerId: number;
clientY: number;
to: number;
} | null>(null);
const rafRef = React.useRef(0);
const dropTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const label = React.useCallback(
(item: T) => (getLabel ? getLabel(item) : String(getId(item))),
[getLabel, getId],
);
const reduce =
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches;
// Clear stray inline transforms whenever no gesture is running, before paint
// — so the frame where `items` arrives reordered is already clean.
React.useLayoutEffect(() => {
if (drag) return;
for (const el of itemRefs.current) {
if (el) {
el.style.transform = "";
el.style.transition = "";
}
}
});
React.useEffect(
() => () => {
cancelAnimationFrame(rafRef.current);
clearTimeout(dropTimer.current);
},
[],
);
/** Snapshot every row's content-space geometry. Called once, at lift. */
const snapshot = (from: number) => {
const els = itemRefs.current;
const slots: SortableSlot[] = [];
for (let i = 0; i < items.length; i++) {
const el = els[i];
slots.push({
top: el ? el.offsetTop : 0,
height: el ? el.offsetHeight : 0,
});
}
const gap = inferGap(slots);
return {
slots,
gap,
firstTop: slots[0]?.top ?? 0,
scroller: findScroller(els[from] ?? listRef.current),
};
};
/** Write every row's transform for a proposed target index. */
const paint = (from: number, to: number, dragOffset: number | null) => {
const c = cache.current;
if (!c) return;
const tops = layoutTops(
c.slots,
c.gap,
c.firstTop,
orderAfterMove(c.slots.length, from, to),
);
for (let i = 0; i < c.slots.length; i++) {
const el = itemRefs.current[i];
if (!el) continue;
const dy =
i === from && dragOffset !== null
? dragOffset
: tops[i] - c.slots[i].top;
el.style.transform = dy === 0 ? "" : `translate3d(0, ${dy}px, 0)`;
}
};
// --- pointer ------------------------------------------------------------
const frame = () => {
const c = cache.current;
const d = drag;
if (!c || !d) return;
if (autoScroll && c.scroller) {
const rect =
c.scroller === document.documentElement
? { top: 0, bottom: window.innerHeight }
: c.scroller.getBoundingClientRect();
let v = 0;
if (c.clientY < rect.top + EDGE) {
v = -(1 - (c.clientY - rect.top) / EDGE);
} else if (c.clientY > rect.bottom - EDGE) {
v = 1 - (rect.bottom - c.clientY) / EDGE;
}
if (v !== 0) {
c.scroller.scrollTop +=
Math.max(-1, Math.min(1, v)) * MAX_SCROLL_SPEED * (1 / 60);
}
}
// The cache is in content coordinates, so scrolling only corrects the
// pointer delta — the slots themselves never go stale.
const scrolled = c.scroller ? c.scroller.scrollTop - c.startScrollTop : 0;
const dy = c.clientY - c.startClientY + scrolled;
const to = targetIndex(c.slots, d.from, dy);
c.to = to;
paint(d.from, to, dy);
rafRef.current = requestAnimationFrame(frame);
};
const onPointerDown =
(index: number) => (e: React.PointerEvent<HTMLLIElement>) => {
if (drag || e.button !== 0) return;
if (
handle &&
!(e.target as HTMLElement).closest("[data-sortable-handle]")
) {
return;
}
const el = itemRefs.current[index];
if (!el) return;
const snap = snapshot(index);
cache.current = {
...snap,
startClientY: e.clientY,
startScrollTop: snap.scroller?.scrollTop ?? 0,
pointerId: e.pointerId,
clientY: e.clientY,
to: index,
};
// Survives the pointer leaving the row — without this the drag dies the
// moment the cursor outruns the element.
el.setPointerCapture(e.pointerId);
setDrag({ from: index, mode: "pointer", to: index });
setAnnouncement(
`Picked up ${label(items[index])}. Position ${index + 1} of ${items.length}.`,
);
rafRef.current = requestAnimationFrame(frame);
};
const onPointerMove = (e: React.PointerEvent<HTMLLIElement>) => {
const c = cache.current;
if (!c || !drag || drag.mode !== "pointer") return;
c.clientY = e.clientY;
};
const endPointer = (commit: boolean) => {
const c = cache.current;
const d = drag;
cancelAnimationFrame(rafRef.current);
if (!c || !d) return;
const to = commit ? c.to : d.from;
finish(d.from, to);
};
// --- shared finish (pointer + keyboard) ---------------------------------
const finish = (from: number, to: number) => {
const c = cache.current;
if (!c) {
setDrag(null);
return;
}
const settle = () => {
cache.current = null;
setDrag(null);
if (to !== from) onReorder(moveItem(items, from, to));
setAnnouncement(
to === from
? "Cancelled."
: `Dropped ${label(items[from])} at position ${to + 1} of ${items.length}.`,
);
};
if (reduce) {
settle();
return;
}
// Glide the lifted row into the slot it resolved to, then commit. The
// layout effect clears the transforms in the same frame the reordered
// items arrive, so the swap is invisible.
const tops = layoutTops(
c.slots,
c.gap,
c.firstTop,
orderAfterMove(c.slots.length, from, to),
);
const el = itemRefs.current[from];
if (el) {
el.style.transition = `transform ${DROP_MS}ms ${EASE}`;
el.style.transform = `translate3d(0, ${tops[from] - c.slots[from].top}px, 0)`;
}
dropTimer.current = setTimeout(settle, DROP_MS);
};
// --- keyboard -----------------------------------------------------------
const onKeyDown =
(index: number) => (e: React.KeyboardEvent<HTMLLIElement>) => {
const d = drag;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (!d) {
const snap = snapshot(index);
cache.current = {
...snap,
startClientY: 0,
startScrollTop: snap.scroller?.scrollTop ?? 0,
pointerId: -1,
clientY: 0,
to: index,
};
setDrag({ from: index, mode: "keyboard", to: index });
setAnnouncement(
`Picked up ${label(items[index])}. Position ${index + 1} of ${items.length}. Use arrow keys to move, space to drop, escape to cancel.`,
);
} else if (d.mode === "keyboard") {
finish(d.from, d.to);
}
return;
}
if (!d || d.mode !== "keyboard") return;
if (e.key === "Escape") {
e.preventDefault();
paint(d.from, d.from, null);
finish(d.from, d.from);
return;
}
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const next = Math.max(
0,
Math.min(items.length - 1, d.to + (e.key === "ArrowDown" ? 1 : -1)),
);
if (next === d.to) return;
const el = itemRefs.current[d.from];
if (el && !reduce)
el.style.transition = `transform ${DROP_MS}ms ${EASE}`;
setDrag({ ...d, to: next });
paint(d.from, next, null);
setAnnouncement(
`${label(items[d.from])} moved to position ${next + 1} of ${items.length}.`,
);
}
};
// --- render -------------------------------------------------------------
return (
<>
<ul
ref={listRef}
data-slot="sortable-list"
className={cn("flex list-none flex-col gap-2 p-0", className)}
>
{items.map((item, index) => {
const isDragging = drag?.from === index;
return (
<li
key={getId(item)}
ref={(el) => {
itemRefs.current[index] = el;
}}
data-slot="sortable-item"
data-dragging={isDragging || undefined}
tabIndex={0}
aria-roledescription="Sortable item"
aria-label={`${label(item)}, position ${index + 1} of ${items.length}`}
onPointerDown={onPointerDown(index)}
onPointerMove={onPointerMove}
onPointerUp={() => endPointer(true)}
onPointerCancel={() => endPointer(false)}
onKeyDown={onKeyDown(index)}
style={{
// Only the rows getting out of the way animate; the dragged one
// must track the pointer with no lag at all.
transition:
drag && !isDragging && !reduce
? `transform ${DROP_MS}ms ${EASE}`
: undefined,
touchAction: "none",
zIndex: isDragging ? 2 : undefined,
position: isDragging ? "relative" : undefined,
}}
className={cn(
"rounded-xl border border-zinc-200 bg-white select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-zinc-800 dark:bg-zinc-900 dark:focus-visible:outline-zinc-50",
isDragging && "shadow-lg",
itemClassName,
)}
>
{children(item, { dragging: isDragging, index })}
</li>
);
})}
</ul>
<div aria-live="assertive" aria-atomic className="sr-only">
{announcement}
</div>
</>
);
}
/** Nearest ancestor that actually scrolls vertically, else the document. */
function findScroller(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" ||
overflow === "scroll" ||
overflow === "overlay") &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return document.documentElement;
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/sortable-list.json1. Install dependencies
npm install clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* SortableList — drag-to-reorder, hand-written, with a real keyboard mode.
*
* <SortableList items={items} getId={(i) => i.id} onReorder={setItems}>
* {(item, { dragging }) => <>…row…</>}
* </SortableList>
*
* ## The whole difficulty is one bug
*
* As you drag, the list reorders beneath you. Compute the target index from
* **live** rects and the element you just displaced slides back under the
* cursor, flips the index back, and you oscillate at every boundary — at some
* drag speeds it buzzes between two positions indefinitely.
*
* The fix is that the target index must be a **pure function of frozen
* geometry and the pointer delta**, with no path back from the rendered result
* to the input:
*
* `targetIndex(slots, from, dy)`
*
* `slots` is snapshotted once at lift, and nothing inside reads the DOM. It
* resolves to whichever *original* slot centre is nearest, and because those
* centres are sorted and fixed for the whole gesture, the result is
* **monotonic in `dy`** by construction. Drag down and the index can only
* increase. There is no feedback loop to oscillate in, at any speed.
*
* Comparing against the list laid out *without* the dragged row is the more
* obvious reading and is equally free of feedback — but it closes the hole the
* row came from, which lands the decision boundary exactly on `dy = 0`, so a
* one-pixel twitch reorders the list. Keeping the hole open puts the swap at
* half a row's pitch, where it belongs. (That was a real bug here, caught by
* the geometry tests rather than by the type checker.)
*
* The visual displacement is computed from the same frozen cache, by laying the
* proposed order out from scratch (`layoutTops`) and diffing against the
* snapshot. That is exact for variable row heights, not an approximation of a
* uniform pitch.
*
* ## Cached geometry vs. auto-scroll
*
* Auto-scrolling while dragging normally means patching the cache by the scroll
* distance every frame — one more thing to get wrong. Instead the cache is
* stored in **content** coordinates (`offsetTop`), which scrolling does not
* change at all. Only the pointer delta needs the correction:
*
* `dy = (clientY − startClientY) + (scrollTop − startScrollTop)`
*
* One term, in one place, instead of rewriting every cached slot per frame.
*
* ## Keyboard mode is not a footnote
*
* Space lifts, arrows move, Space drops, Escape cancels, and every step is
* announced through an `aria-live` region. It shares the exact code path as the
* pointer drag — same cache, same `layoutTops`, same transforms — so the two
* cannot drift apart, and the keyboard move is animated for free.
*
* ## Zero re-renders while dragging
*
* Transforms are written straight to the DOM inside the rAF loop. React
* re-renders on lift and on drop, and not once in between.
*/
// ---------------------------------------------------------------------------
// Pure geometry — no DOM, no React. This is the part that must be right.
// ---------------------------------------------------------------------------
export interface SortableSlot {
/** Offset from the scroll container's content top. Scroll-independent. */
top: number;
height: number;
}
/** Move `from` to `to` in a copy. `to` is an index in the resulting array. */
export function moveItem<T>(list: T[], from: number, to: number): T[] {
const next = list.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/** The order array that results from moving `from` to `to`. */
export function orderAfterMove(count: number, from: number, to: number) {
const rest: number[] = [];
for (let i = 0; i < count; i++) if (i !== from) rest.push(i);
return [...rest.slice(0, to), from, ...rest.slice(to)];
}
/**
* Lay `order` out sequentially and return each item's new top, keyed by its
* *original* index. Uniform `gap`, but arbitrary per-item heights.
*/
export function layoutTops(
slots: SortableSlot[],
gap: number,
firstTop: number,
order: number[],
): number[] {
const tops = new Array<number>(slots.length);
let y = firstTop;
for (const idx of order) {
tops[idx] = y;
y += slots[idx].height + gap;
}
return tops;
}
/**
* Where the dragged item should land, given only the frozen snapshot and the
* pointer delta.
*
* The rule is "whichever original slot's centre is nearest". Two properties
* fall out of that, and both matter:
*
* - **Monotonic in `dy`.** The centres are sorted, so the nearest-centre index
* can only increase as the drag moves down. There is no path from the
* rendered result back into this calculation, so there is nothing to
* oscillate — at any drag speed.
* - **`dy = 0` is a no-op.** Comparing against the layout of the list with the
* dragged item *removed* seems more natural, but that closes the hole the
* item came from, which puts a threshold exactly at `dy = 0`: pressing down
* and twitching one pixel would swap rows. Comparing against the original
* centres keeps the hole open, so the swap happens at half a row's pitch,
* where it belongs.
*/
export function targetIndex(
slots: SortableSlot[],
from: number,
dy: number,
): number {
const center = slots[from].top + dy + slots[from].height / 2;
let best = 0;
let bestDistance = Infinity;
for (let i = 0; i < slots.length; i++) {
const distance = Math.abs(center - (slots[i].top + slots[i].height / 2));
// Strict `<` resolves a dead-centre tie to the earlier slot, so a drag
// parked exactly on a boundary settles instead of flickering.
if (distance < bestDistance) {
bestDistance = distance;
best = i;
}
}
return best;
}
/** Uniform gap inferred from the snapshot. Zero for a single-item list. */
export function inferGap(slots: SortableSlot[]): number {
if (slots.length < 2) return 0;
return Math.max(0, slots[1].top - (slots[0].top + slots[0].height));
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const EDGE = 52;
const MAX_SCROLL_SPEED = 900;
const DROP_MS = 220;
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
interface DragState {
from: number;
mode: "pointer" | "keyboard";
to: number;
}
export interface SortableItemState {
/** True for the row being dragged or lifted. */
dragging: boolean;
index: number;
}
export interface SortableListProps<T> {
items: T[];
getId: (item: T) => string | number;
/** Called once, on drop, with the reordered array. */
onReorder: (next: T[]) => void;
/** Used for the `aria-live` announcements. Defaults to the id. */
getLabel?: (item: T) => string;
children: (item: T, state: SortableItemState) => React.ReactNode;
/**
* Only start a pointer drag from an element marked
* `data-sortable-handle` inside the row. Keyboard is unaffected — the row
* itself is always focusable and operable.
*/
handle?: boolean;
/** Auto-scroll the nearest scrollable ancestor near its edges. */
autoScroll?: boolean;
className?: string;
itemClassName?: string;
}
export function SortableList<T>({
items,
getId,
onReorder,
getLabel,
children,
handle = false,
autoScroll = true,
className,
itemClassName,
}: SortableListProps<T>) {
const listRef = React.useRef<HTMLUListElement>(null);
const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const [drag, setDrag] = React.useState<DragState | null>(null);
const [announcement, setAnnouncement] = React.useState("");
// Frozen at lift. Nothing in the gesture ever re-reads the DOM for geometry.
const cache = React.useRef<{
slots: SortableSlot[];
gap: number;
firstTop: number;
scroller: HTMLElement | null;
startClientY: number;
startScrollTop: number;
pointerId: number;
clientY: number;
to: number;
} | null>(null);
const rafRef = React.useRef(0);
const dropTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const label = React.useCallback(
(item: T) => (getLabel ? getLabel(item) : String(getId(item))),
[getLabel, getId],
);
const reduce =
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches;
// Clear stray inline transforms whenever no gesture is running, before paint
// — so the frame where `items` arrives reordered is already clean.
React.useLayoutEffect(() => {
if (drag) return;
for (const el of itemRefs.current) {
if (el) {
el.style.transform = "";
el.style.transition = "";
}
}
});
React.useEffect(
() => () => {
cancelAnimationFrame(rafRef.current);
clearTimeout(dropTimer.current);
},
[],
);
/** Snapshot every row's content-space geometry. Called once, at lift. */
const snapshot = (from: number) => {
const els = itemRefs.current;
const slots: SortableSlot[] = [];
for (let i = 0; i < items.length; i++) {
const el = els[i];
slots.push({
top: el ? el.offsetTop : 0,
height: el ? el.offsetHeight : 0,
});
}
const gap = inferGap(slots);
return {
slots,
gap,
firstTop: slots[0]?.top ?? 0,
scroller: findScroller(els[from] ?? listRef.current),
};
};
/** Write every row's transform for a proposed target index. */
const paint = (from: number, to: number, dragOffset: number | null) => {
const c = cache.current;
if (!c) return;
const tops = layoutTops(
c.slots,
c.gap,
c.firstTop,
orderAfterMove(c.slots.length, from, to),
);
for (let i = 0; i < c.slots.length; i++) {
const el = itemRefs.current[i];
if (!el) continue;
const dy =
i === from && dragOffset !== null
? dragOffset
: tops[i] - c.slots[i].top;
el.style.transform = dy === 0 ? "" : `translate3d(0, ${dy}px, 0)`;
}
};
// --- pointer ------------------------------------------------------------
const frame = () => {
const c = cache.current;
const d = drag;
if (!c || !d) return;
if (autoScroll && c.scroller) {
const rect =
c.scroller === document.documentElement
? { top: 0, bottom: window.innerHeight }
: c.scroller.getBoundingClientRect();
let v = 0;
if (c.clientY < rect.top + EDGE) {
v = -(1 - (c.clientY - rect.top) / EDGE);
} else if (c.clientY > rect.bottom - EDGE) {
v = 1 - (rect.bottom - c.clientY) / EDGE;
}
if (v !== 0) {
c.scroller.scrollTop +=
Math.max(-1, Math.min(1, v)) * MAX_SCROLL_SPEED * (1 / 60);
}
}
// The cache is in content coordinates, so scrolling only corrects the
// pointer delta — the slots themselves never go stale.
const scrolled = c.scroller ? c.scroller.scrollTop - c.startScrollTop : 0;
const dy = c.clientY - c.startClientY + scrolled;
const to = targetIndex(c.slots, d.from, dy);
c.to = to;
paint(d.from, to, dy);
rafRef.current = requestAnimationFrame(frame);
};
const onPointerDown =
(index: number) => (e: React.PointerEvent<HTMLLIElement>) => {
if (drag || e.button !== 0) return;
if (
handle &&
!(e.target as HTMLElement).closest("[data-sortable-handle]")
) {
return;
}
const el = itemRefs.current[index];
if (!el) return;
const snap = snapshot(index);
cache.current = {
...snap,
startClientY: e.clientY,
startScrollTop: snap.scroller?.scrollTop ?? 0,
pointerId: e.pointerId,
clientY: e.clientY,
to: index,
};
// Survives the pointer leaving the row — without this the drag dies the
// moment the cursor outruns the element.
el.setPointerCapture(e.pointerId);
setDrag({ from: index, mode: "pointer", to: index });
setAnnouncement(
`Picked up ${label(items[index])}. Position ${index + 1} of ${items.length}.`,
);
rafRef.current = requestAnimationFrame(frame);
};
const onPointerMove = (e: React.PointerEvent<HTMLLIElement>) => {
const c = cache.current;
if (!c || !drag || drag.mode !== "pointer") return;
c.clientY = e.clientY;
};
const endPointer = (commit: boolean) => {
const c = cache.current;
const d = drag;
cancelAnimationFrame(rafRef.current);
if (!c || !d) return;
const to = commit ? c.to : d.from;
finish(d.from, to);
};
// --- shared finish (pointer + keyboard) ---------------------------------
const finish = (from: number, to: number) => {
const c = cache.current;
if (!c) {
setDrag(null);
return;
}
const settle = () => {
cache.current = null;
setDrag(null);
if (to !== from) onReorder(moveItem(items, from, to));
setAnnouncement(
to === from
? "Cancelled."
: `Dropped ${label(items[from])} at position ${to + 1} of ${items.length}.`,
);
};
if (reduce) {
settle();
return;
}
// Glide the lifted row into the slot it resolved to, then commit. The
// layout effect clears the transforms in the same frame the reordered
// items arrive, so the swap is invisible.
const tops = layoutTops(
c.slots,
c.gap,
c.firstTop,
orderAfterMove(c.slots.length, from, to),
);
const el = itemRefs.current[from];
if (el) {
el.style.transition = `transform ${DROP_MS}ms ${EASE}`;
el.style.transform = `translate3d(0, ${tops[from] - c.slots[from].top}px, 0)`;
}
dropTimer.current = setTimeout(settle, DROP_MS);
};
// --- keyboard -----------------------------------------------------------
const onKeyDown =
(index: number) => (e: React.KeyboardEvent<HTMLLIElement>) => {
const d = drag;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (!d) {
const snap = snapshot(index);
cache.current = {
...snap,
startClientY: 0,
startScrollTop: snap.scroller?.scrollTop ?? 0,
pointerId: -1,
clientY: 0,
to: index,
};
setDrag({ from: index, mode: "keyboard", to: index });
setAnnouncement(
`Picked up ${label(items[index])}. Position ${index + 1} of ${items.length}. Use arrow keys to move, space to drop, escape to cancel.`,
);
} else if (d.mode === "keyboard") {
finish(d.from, d.to);
}
return;
}
if (!d || d.mode !== "keyboard") return;
if (e.key === "Escape") {
e.preventDefault();
paint(d.from, d.from, null);
finish(d.from, d.from);
return;
}
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const next = Math.max(
0,
Math.min(items.length - 1, d.to + (e.key === "ArrowDown" ? 1 : -1)),
);
if (next === d.to) return;
const el = itemRefs.current[d.from];
if (el && !reduce)
el.style.transition = `transform ${DROP_MS}ms ${EASE}`;
setDrag({ ...d, to: next });
paint(d.from, next, null);
setAnnouncement(
`${label(items[d.from])} moved to position ${next + 1} of ${items.length}.`,
);
}
};
// --- render -------------------------------------------------------------
return (
<>
<ul
ref={listRef}
data-slot="sortable-list"
className={cn("flex list-none flex-col gap-2 p-0", className)}
>
{items.map((item, index) => {
const isDragging = drag?.from === index;
return (
<li
key={getId(item)}
ref={(el) => {
itemRefs.current[index] = el;
}}
data-slot="sortable-item"
data-dragging={isDragging || undefined}
tabIndex={0}
aria-roledescription="Sortable item"
aria-label={`${label(item)}, position ${index + 1} of ${items.length}`}
onPointerDown={onPointerDown(index)}
onPointerMove={onPointerMove}
onPointerUp={() => endPointer(true)}
onPointerCancel={() => endPointer(false)}
onKeyDown={onKeyDown(index)}
style={{
// Only the rows getting out of the way animate; the dragged one
// must track the pointer with no lag at all.
transition:
drag && !isDragging && !reduce
? `transform ${DROP_MS}ms ${EASE}`
: undefined,
touchAction: "none",
zIndex: isDragging ? 2 : undefined,
position: isDragging ? "relative" : undefined,
}}
className={cn(
"rounded-xl border border-zinc-200 bg-white select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-zinc-800 dark:bg-zinc-900 dark:focus-visible:outline-zinc-50",
isDragging && "shadow-lg",
itemClassName,
)}
>
{children(item, { dragging: isDragging, index })}
</li>
);
})}
</ul>
<div aria-live="assertive" aria-atomic className="sr-only">
{announcement}
</div>
</>
);
}
/** Nearest ancestor that actually scrolls vertically, else the document. */
function findScroller(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" ||
overflow === "scroll" ||
overflow === "overlay") &&
node.scrollHeight > node.clientHeight
) {
return node;
}
node = node.parentElement;
}
return document.documentElement;
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | The list, fully controlled. Rows may have different heights — the displacement layout is computed exactly, not from an assumed uniform pitch. |
getId | (item: T) => string | number | — | Stable React key, and the fallback announcement label. |
onReorder | (next: T[]) => void | — | Called once, on drop, with the reordered array — never mid-drag. |
getLabel | (item: T) => string | — | Human name used in the `aria-live` announcements. Defaults to the id. |
children | (item: T, state: { dragging, index }) => React.ReactNode | — | Row content. The component owns the `<li>`, its focus handling, and its transforms. |
handle | boolean | false | Only start a pointer drag from an element marked `data-sortable-handle` inside the row. Keyboard is unaffected — the row itself is always focusable and operable, so a handle can never lock keyboard users out. |
autoScroll | boolean | true | Scroll the nearest scrollable ancestor when dragging near its edges. The geometry cache is in content coordinates, so scrolling corrects one pointer-delta term rather than invalidating every cached row. |