"use client";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { Reorder as MotionReorder, useDragControls } from "motion/react";
import { cn } from "@/lib/utils";
export interface ReorderItemData {
id: string;
label: string;
meta?: string;
}
export interface ReorderProps {
items?: ReorderItemData[];
onReorder?: (next: ReorderItemData[]) => void;
axis?: "y" | "x";
stiffness?: number;
damping?: number;
handle?: boolean;
lift?: number;
liftShadow?: number;
gap?: number;
radius?: number;
density?: "comfortable" | "compact";
showIndex?: boolean;
accentColor?: string;
reducedMotion?: boolean;
className?: string;
}
const move = <T,>(list: T[], from: number, to: number): T[] => {
const next = list.slice();
const [row] = next.splice(from, 1);
next.splice(to, 0, row);
return next;
};
export default function Reorder({
items = [],
onReorder,
axis = "y",
stiffness = 520,
damping = 38,
handle = true,
lift = 1.03,
liftShadow = 0.35,
gap = 8,
radius = 8,
density = "comfortable",
showIndex = true,
accentColor = "#a855f7",
reducedMotion = false,
className,
}: ReorderProps) {
const [grabbed, setGrabbed] = useState<string | null>(null);
const originRef = useRef<ReorderItemData[] | null>(null);
const [message, setMessage] = useState("");
const [roving, setRoving] = useState(0);
const focusWanted = useRef<string | null>(null);
const gripRefs = useRef(new Map<string, HTMLButtonElement>());
const index = (id: string) => items.findIndex((item) => item.id === id);
useEffect(() => {
const id = focusWanted.current;
if (!id) return;
focusWanted.current = null;
gripRefs.current.get(id)?.focus({ preventScroll: true });
}, [items]);
const commit = (next: ReorderItemData[], id: string) => {
focusWanted.current = id;
setRoving(next.findIndex((item) => item.id === id));
onReorder?.(next);
};
const step = (id: string, delta: number) => {
const from = index(id);
const to = Math.min(items.length - 1, Math.max(0, from + delta));
if (from < 0 || to === from) return;
commit(move(items, from, to), id);
setMessage(`Moved to position ${to + 1} of ${items.length}`);
};
const jump = (id: string, edge: "start" | "end") => {
const from = index(id);
const to = edge === "start" ? 0 : items.length - 1;
if (from < 0 || to === from) return;
commit(move(items, from, to), id);
setMessage(`Moved to position ${to + 1} of ${items.length}`);
};
const grab = (id: string) => {
originRef.current = items;
setGrabbed(id);
setMessage(`${label(items, id)}, grabbed. Position ${index(id) + 1} of ${items.length}.`);
};
const drop = (id: string) => {
originRef.current = null;
setGrabbed(null);
setMessage(`Dropped at position ${index(id) + 1} of ${items.length}`);
};
const cancel = (id: string) => {
const origin = originRef.current;
originRef.current = null;
setGrabbed(null);
if (origin) {
const back = origin.findIndex((item) => item.id === id);
commit(origin, id);
setMessage(`Cancelled, returned to position ${back + 1} of ${items.length}`);
return;
}
setMessage("Cancelled");
};
const [prevKey, nextKey] =
axis === "y" ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"];
const onKeyDown = (event: KeyboardEvent, id: string) => {
const held = grabbed === id;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (held) drop(id);
else grab(id);
return;
}
if (event.key === "Escape" && held) {
event.preventDefault();
cancel(id);
return;
}
if (event.key === "Tab" && held) {
event.preventDefault();
return;
}
if (event.key === prevKey || event.key === nextKey) {
event.preventDefault();
const delta = event.key === nextKey ? 1 : -1;
if (held) {
step(id, delta);
return;
}
const to = Math.min(items.length - 1, Math.max(0, index(id) + delta));
setRoving(to);
gripRefs.current.get(items[to]?.id ?? "")?.focus({ preventScroll: true });
return;
}
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const edge = event.key === "Home" ? "start" : "end";
if (held) {
jump(id, edge);
return;
}
const to = edge === "start" ? 0 : items.length - 1;
setRoving(to);
gripRefs.current.get(items[to]?.id ?? "")?.focus({ preventScroll: true });
}
};
return (
<div className={cn("w-full", className)}>
<MotionReorder.Group
axis={axis}
values={items}
onReorder={(next: ReorderItemData[]) => onReorder?.(next)}
className={cn("flex list-none", axis === "y" ? "flex-col" : "flex-row")}
style={{ gap }}
>
{items.map((item, i) => (
<Row
key={item.id}
item={item}
position={i}
total={items.length}
axis={axis}
stiffness={stiffness}
damping={damping}
handle={handle}
lift={lift}
liftShadow={liftShadow}
radius={radius}
density={density}
showIndex={showIndex}
accentColor={accentColor}
reducedMotion={reducedMotion}
grabbed={grabbed === item.id}
tabbable={roving === i || (roving >= items.length && i === 0)}
registerGrip={(node) => {
if (node) gripRefs.current.set(item.id, node);
else gripRefs.current.delete(item.id);
}}
onGripFocus={() => setRoving(i)}
onKeyDown={(event) => onKeyDown(event, item.id)}
/>
))}
</MotionReorder.Group>
<p aria-live="assertive" aria-atomic className="sr-only">
{message}
</p>
</div>
);
}
function label(items: ReorderItemData[], id: string): string {
return items.find((item) => item.id === id)?.label ?? "Row";
}
function Row({
item,
position,
total,
axis,
stiffness,
damping,
handle,
lift,
liftShadow,
radius,
density,
showIndex,
accentColor,
reducedMotion,
grabbed,
tabbable,
registerGrip,
onGripFocus,
onKeyDown,
}: {
item: ReorderItemData;
position: number;
total: number;
axis: "y" | "x";
stiffness: number;
damping: number;
handle: boolean;
lift: number;
liftShadow: number;
radius: number;
density: "comfortable" | "compact";
showIndex: boolean;
accentColor: string;
reducedMotion: boolean;
grabbed: boolean;
tabbable: boolean;
registerGrip: (node: HTMLButtonElement | null) => void;
onGripFocus: () => void;
onKeyDown: (event: KeyboardEvent) => void;
}) {
const controls = useDragControls();
return (
<MotionReorder.Item
value={item}
layout="position"
dragListener={!handle}
dragControls={controls}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness, damping }
}
whileDrag={
reducedMotion
? undefined
: {
scale: lift,
boxShadow: `0 ${Math.round(liftShadow * 26)}px ${Math.round(liftShadow * 44)}px rgb(0 0 0 / ${liftShadow})`,
}
}
className={cn(
"flex items-center gap-3 border border-hairline bg-panel select-none",
density === "compact" ? "px-3 py-2" : "px-4 py-3",
axis === "x" && "min-w-0 flex-1",
!handle && "cursor-grab active:cursor-grabbing",
)}
style={{
borderRadius: radius,
outline: grabbed ? `2px solid ${accentColor}` : undefined,
outlineOffset: 2,
}}
>
<button
ref={registerGrip}
type="button"
className="touch-none grid size-7 shrink-0 cursor-grab place-items-center rounded-md border border-hairline text-ink-mute transition-colors outline-none hover:bg-raised hover:text-ink focus-visible:ring-2 focus-visible:ring-accent active:cursor-grabbing"
style={grabbed ? { color: accentColor, borderColor: accentColor } : undefined}
aria-roledescription="sortable"
aria-pressed={grabbed}
aria-label={`Reorder ${item.label}. Position ${position + 1} of ${total}. Press space to pick up.`}
tabIndex={tabbable ? 0 : -1}
onFocus={onGripFocus}
onKeyDown={onKeyDown}
onPointerDown={(event) => {
if (handle) controls.start(event);
}}
>
<svg viewBox="0 0 24 24" className="size-3.5" aria-hidden>
<path
d="M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
</button>
{showIndex ? (
<span className="w-5 shrink-0 text-right font-display text-[11px] tabular-nums text-ink-mute">
{position + 1}
</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-display text-[12px] tracking-[0.12em] text-ink uppercase">
{item.label}
</span>
{item.meta ? (
<span className="mt-0.5 block truncate font-sans text-[12px] text-ink-mute">
{item.meta}
</span>
) : null}
</span>
</MotionReorder.Item>
);
}