"use client";
import * as React from "react";
import { Reorder, useDragControls } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface SortableListItem {
/** Stable, unique key. */
id: string;
/** Visible label; also used in screen reader announcements. */
label?: string;
/** Optional secondary line for the default renderer. */
description?: React.ReactNode;
}
export interface SortableItemState {
/** Zero-based position in the list. */
index: number;
/** Number of items in the list. */
total: number;
/** Being dragged with a pointer. */
dragging: boolean;
/** Picked up with the keyboard. */
grabbed: boolean;
}
export interface SortableListProps<T extends SortableListItem>
extends Omit<React.HTMLAttributes<HTMLUListElement>, "children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"> {
/** Items in their current order (controlled). */
items: T[];
/** Called with the new order after every move (pointer or keyboard). */
onReorder: (items: T[]) => void;
/** Renders the body of a row. The handle and shell are provided. */
renderItem?: (item: T, state: SortableItemState) => React.ReactNode;
/** Text used for an item in announcements. Defaults to `item.label ?? item.id`. */
getItemLabel?: (item: T) => string;
/** Only the grip handle starts a pointer drag (recommended on touch). */
handleOnly?: boolean;
/** Show the position number on each row. */
showIndex?: boolean;
/** Row density. */
density?: "comfortable" | "compact";
/** Scale of a lifted row. */
lift?: number;
/** Scroll the nearest scrollable ancestor while dragging near its edges. */
autoScroll?: boolean;
/** Spring for rows moving into place. */
spring?: Spring;
/** Accent for the lifted outline and focus rings. */
accent?: string;
/** Accessible name of the list. */
label?: string;
}
const EDGE = 56; // px from a scroll edge where auto-scroll starts
const MAX_SPEED = 14; // px per frame
function scrollParent(node: HTMLElement | null): HTMLElement {
let el = node?.parentElement ?? null;
while (el) {
const { overflowY } = getComputedStyle(el);
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) return el;
el = el.parentElement;
}
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
}
function move<T>(list: T[], from: number, to: number) {
const next = list.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* A drag-to-reorder list built on Motion's Reorder (layout animations). Rows are dragged by a
* grip handle, or picked up from the keyboard: Space / Enter lifts, arrows move, Space / Enter
* drops and Escape cancels. Every move is announced through a polite live region.
*/
export function SortableList<T extends SortableListItem>({
items,
onReorder,
renderItem,
getItemLabel = (item) => item.label ?? item.id,
handleOnly = true,
showIndex = true,
density = "comfortable",
lift = 1.03,
autoScroll = true,
spring = { stiffness: 520, damping: 40, mass: 1 },
accent = "#FF7448",
label = "Sortable list",
className = "",
style,
...props
}: SortableListProps<T>) {
const uid = React.useId();
const instructionsId = `${uid}-instructions`;
const reduceMotion = usePrefersReducedMotion();
const [grabbedId, setGrabbedId] = React.useState<string | null>(null);
const [draggingId, setDraggingId] = React.useState<string | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const handleRefs = React.useRef(new Map<string, HTMLButtonElement>());
const snapshotRef = React.useRef<T[] | null>(null);
const dragStartIndexRef = React.useRef(-1);
const refocusRef = React.useRef<string | null>(null);
const autoScrollRef = React.useRef<{ frame: number; y: number; el: HTMLElement | null }>({ frame: 0, y: -1, el: null });
const total = items.length;
const position = (index: number) => `position ${index + 1} of ${total}`;
const indexOf = (id: string) => items.findIndex((item) => item.id === id);
// Moving a focused row in the DOM can drop focus; restore it to the handle after each commit.
React.useEffect(() => {
const id = refocusRef.current;
if (!id) return;
refocusRef.current = null;
const handle = handleRefs.current.get(id);
if (handle && document.activeElement !== handle) handle.focus({ preventScroll: true });
}, [items]);
// Stop any auto-scroll loop on unmount.
React.useEffect(() => {
const state = autoScrollRef.current;
return () => cancelAnimationFrame(state.frame);
}, []);
/* -------------------------------- keyboard -------------------------------- */
const pickUp = (item: T) => {
snapshotRef.current = items;
setGrabbedId(item.id);
setAnnouncement(
`Picked up ${getItemLabel(item)}, ${position(indexOf(item.id))}. Use the arrow keys to move, Space to drop, Escape to cancel.`,
);
};
const drop = (item: T) => {
snapshotRef.current = null;
setGrabbedId(null);
setAnnouncement(`Dropped ${getItemLabel(item)} at ${position(indexOf(item.id))}.`);
};
const cancel = (item: T) => {
const snapshot = snapshotRef.current;
snapshotRef.current = null;
setGrabbedId(null);
if (snapshot) {
refocusRef.current = item.id;
onReorder(snapshot);
const original = snapshot.findIndex((entry) => entry.id === item.id);
setAnnouncement(`Reorder cancelled. ${getItemLabel(item)} returned to position ${original + 1} of ${total}.`);
}
};
const moveTo = (item: T, to: number) => {
const from = indexOf(item.id);
const target = Math.max(0, Math.min(total - 1, to));
if (from < 0 || target === from) return;
refocusRef.current = item.id;
onReorder(move(items, from, target));
setAnnouncement(`Moved ${getItemLabel(item)} to ${position(target)}.`);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, item: T) => {
const grabbed = grabbedId === item.id;
const index = indexOf(item.id);
if (!grabbed) {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
pickUp(item);
}
return;
}
switch (event.key) {
case "ArrowUp":
case "ArrowLeft":
event.preventDefault();
moveTo(item, index - 1);
break;
case "ArrowDown":
case "ArrowRight":
event.preventDefault();
moveTo(item, index + 1);
break;
case "Home":
event.preventDefault();
moveTo(item, 0);
break;
case "End":
event.preventDefault();
moveTo(item, total - 1);
break;
case " ":
case "Enter":
event.preventDefault();
drop(item);
break;
case "Escape":
event.preventDefault();
// Keep Escape from also closing a surrounding dialog.
event.stopPropagation();
cancel(item);
break;
case "Tab":
drop(item);
break;
}
};
/* ---------------------------------- pointer ---------------------------------- */
const stopAutoScroll = () => {
const state = autoScrollRef.current;
cancelAnimationFrame(state.frame);
state.frame = 0;
state.y = -1;
state.el = null;
};
const startAutoScroll = (node: HTMLElement | null) => {
if (!autoScroll) return;
const state = autoScrollRef.current;
state.el = scrollParent(node);
const tick = () => {
const el = state.el;
if (el && state.y >= 0) {
const isRoot = el === document.scrollingElement || el === document.documentElement;
const top = isRoot ? 0 : el.getBoundingClientRect().top;
const bottom = isRoot ? window.innerHeight : el.getBoundingClientRect().bottom;
let speed = 0;
if (state.y < top + EDGE) speed = -MAX_SPEED * Math.min(1, (top + EDGE - state.y) / EDGE);
else if (state.y > bottom - EDGE) speed = MAX_SPEED * Math.min(1, (state.y - (bottom - EDGE)) / EDGE);
if (speed) el.scrollTop += speed;
}
state.frame = requestAnimationFrame(tick);
};
state.frame = requestAnimationFrame(tick);
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
return (
<div className={`w-full ${className}`} style={{ ["--sl-accent" as string]: accent, ...style }}>
<p id={instructionsId} className="sr-only">
Press Space or Enter to pick up an item. While it is picked up, use the arrow keys to move it, Space or Enter to
drop it, and Escape to cancel.
</p>
<Reorder.Group
as="ul"
axis="y"
values={items}
onReorder={onReorder}
aria-label={label}
className={`flex flex-col ${density === "compact" ? "gap-1.5" : "gap-2"}`}
{...props}
>
{items.map((item, index) => (
<SortableRow
key={item.id}
item={item}
state={{ index, total, dragging: draggingId === item.id, grabbed: grabbedId === item.id }}
label={getItemLabel(item)}
instructionsId={instructionsId}
handleOnly={handleOnly}
showIndex={showIndex}
density={density}
lift={lift}
transition={transition}
renderItem={renderItem}
registerHandle={(node) => {
if (node) handleRefs.current.set(item.id, node);
else handleRefs.current.delete(item.id);
}}
onHandleKeyDown={(event) => handleKeyDown(event, item)}
onHandleBlur={() => {
// Focus leaving a lifted row (other than our own refocus after a move) drops it in place.
if (grabbedId === item.id && refocusRef.current !== item.id) drop(item);
}}
onDragStart={(node) => {
if (grabbedId) setGrabbedId(null);
snapshotRef.current = null;
dragStartIndexRef.current = index;
setDraggingId(item.id);
startAutoScroll(node);
}}
onDragMove={(clientY) => {
autoScrollRef.current.y = clientY;
}}
onDragEnd={() => {
stopAutoScroll();
setDraggingId(null);
const finalIndex = indexOf(item.id);
if (finalIndex !== dragStartIndexRef.current) {
setAnnouncement(`Moved ${getItemLabel(item)} to ${position(finalIndex)}.`);
}
dragStartIndexRef.current = -1;
}}
/>
))}
</Reorder.Group>
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
</div>
);
}
interface SortableRowProps<T extends SortableListItem> {
item: T;
state: SortableItemState;
label: string;
instructionsId: string;
handleOnly: boolean;
showIndex: boolean;
density: "comfortable" | "compact";
lift: number;
transition: { duration: number } | ({ type: "spring" } & Spring);
renderItem?: (item: T, state: SortableItemState) => React.ReactNode;
registerHandle: (node: HTMLButtonElement | null) => void;
onHandleKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
onHandleBlur: () => void;
onDragStart: (node: HTMLElement | null) => void;
onDragMove: (clientY: number) => void;
onDragEnd: () => void;
}
function SortableRow<T extends SortableListItem>({
item,
state,
label,
instructionsId,
handleOnly,
showIndex,
density,
lift,
transition,
renderItem,
registerHandle,
onHandleKeyDown,
onHandleBlur,
onDragStart,
onDragMove,
onDragEnd,
}: SortableRowProps<T>) {
const controls = useDragControls();
const rowRef = React.useRef<HTMLLIElement>(null);
const lifted = state.dragging || state.grabbed;
const compact = density === "compact";
return (
<Reorder.Item
ref={rowRef}
value={item}
dragListener={!handleOnly}
dragControls={controls}
onDragStart={() => onDragStart(rowRef.current)}
onDrag={(event) => {
if ("clientY" in event) onDragMove(event.clientY);
}}
onDragEnd={onDragEnd}
animate={{ scale: lifted ? lift : 1 }}
transition={transition}
data-lifted={lifted || undefined}
className={`relative flex items-center rounded-xl border bg-white text-zinc-900 transition-[box-shadow,border-color] duration-200 dark:bg-zinc-900 dark:text-zinc-100 ${
compact ? "gap-2 px-2 py-1.5" : "gap-3 px-2.5 py-2.5"
} ${
lifted
? "border-[var(--sl-accent)] shadow-[0_18px_40px_-16px_rgba(0,0,0,0.35),0_2px_6px_rgba(0,0,0,0.08)] dark:shadow-[0_18px_40px_-12px_rgba(0,0,0,0.9)]"
: "border-zinc-900/10 shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/10"
} ${handleOnly ? "" : "cursor-grab touch-none select-none active:cursor-grabbing"}`}
>
<button
ref={registerHandle}
type="button"
aria-label={`Reorder ${label}`}
aria-roledescription="sortable"
aria-describedby={instructionsId}
aria-pressed={state.grabbed}
onPointerDown={(event) => {
if (event.button !== 0) return;
controls.start(event);
}}
onKeyDown={onHandleKeyDown}
onBlur={onHandleBlur}
className={`flex shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--sl-accent)] active:cursor-grabbing aria-pressed:bg-[var(--sl-accent)] aria-pressed:text-zinc-950 dark:text-zinc-500 dark:hover:bg-white/[0.06] dark:hover:text-zinc-200 dark:aria-pressed:text-zinc-950 ${
compact ? "h-7 w-6" : "h-9 w-7"
}`}
>
<svg viewBox="0 0 10 16" className="h-4 w-2.5" aria-hidden="true">
{[3, 8, 13].map((y) => (
<React.Fragment key={y}>
<circle cx="2.5" cy={y} r="1.25" fill="currentColor" />
<circle cx="7.5" cy={y} r="1.25" fill="currentColor" />
</React.Fragment>
))}
</svg>
</button>
{showIndex && (
<span aria-hidden="true" className="w-5 shrink-0 font-mono text-[10px] tabular-nums tracking-[0.08em] text-zinc-400 dark:text-zinc-500">
{String(state.index + 1).padStart(2, "0")}
</span>
)}
<div className="min-w-0 flex-1">
{renderItem ? (
renderItem(item, state)
) : (
<>
<span className={`block truncate font-medium tracking-tight ${compact ? "text-[13px]" : "text-sm"}`}>{label}</span>
{item.description && !compact && (
<span className="block truncate text-[13px] text-zinc-500 dark:text-zinc-400">{item.description}</span>
)}
</>
)}
</div>
</Reorder.Item>
);
}