A drag-to-reorder list where the lifted item floats on a shadow-overlay surface, siblings part via layout animation, and the grip handle also reorders with arrow keys.
npx shadcn@latest add @paragon/list-reorder"use client";
import * as React from "react";
import { Reorder, useDragControls, useReducedMotion } from "motion/react";
import { GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
interface ListReorderContextValue {
values: unknown[];
onReorder: (values: unknown[]) => void;
announce: (message: string) => void;
isStatic: boolean;
}
const ListReorderContext = React.createContext<ListReorderContextValue | null>(
null,
);
function useListReorder(consumer: string) {
const context = React.useContext(ListReorderContext);
if (!context) {
throw new Error(`<${consumer}> must be used within <ListReorder>`);
}
return context;
}
export interface ListReorderProps<T>
extends Omit<
React.ComponentProps<"ul">,
// Motion owns the drag/animation callbacks on Reorder.Group.
"onChange" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
> {
values: T[];
onReorder: (values: T[]) => void;
/** Disables the lift/settle motion; reordering still works. */
static?: boolean;
}
/**
* A drag-to-reorder list on motion's Reorder engine. The lifted item
* scales to 1.02 on a shadow-overlay surface while siblings part around
* it via layout animation; release settles on a zero-bounce spring.
* Dragging starts from the grip handle (pointer-capture, touch-safe;
* text selection is suspended document-wide for the duration), and the
* same handle reorders with Arrow keys — Home/End jump to the ends —
* with every position change announced to a polite live region.
*/
export function ListReorder<T>({
values,
onReorder,
static: isStatic = false,
className,
children,
...props
}: ListReorderProps<T>) {
const [liveMessage, setLiveMessage] = React.useState("");
const context = React.useMemo<ListReorderContextValue>(
() => ({
values,
onReorder: onReorder as (values: unknown[]) => void,
announce: setLiveMessage,
isStatic,
}),
[values, onReorder, isStatic],
);
return (
<ListReorderContext.Provider value={context}>
<Reorder.Group
axis="y"
values={values}
onReorder={onReorder}
data-slot="list-reorder"
className={cn("flex w-full flex-col gap-2", className)}
{...props}
>
{children}
<li aria-live="polite" role="status" className="sr-only">
{liveMessage}
</li>
</Reorder.Group>
</ListReorderContext.Provider>
);
}
export interface ListReorderItemProps<T> {
value: T;
/** Accessible name for the drag handle, e.g. the row's title. */
label?: string;
className?: string;
children?: React.ReactNode;
}
export function ListReorderItem<T>({
value,
label,
className,
children,
}: ListReorderItemProps<T>) {
const { values, onReorder, announce, isStatic } =
useListReorder("ListReorderItem");
const controls = useDragControls();
const reduced = useReducedMotion();
const instant = isStatic || !!reduced;
const [dragging, setDragging] = React.useState(false);
// Text selection is suspended document-wide while a row is held.
React.useEffect(() => {
if (!dragging) return;
const previous = document.body.style.userSelect;
document.body.style.userSelect = "none";
return () => {
document.body.style.userSelect = previous;
};
}, [dragging]);
const moveTo = (to: number) => {
const from = values.indexOf(value);
if (from < 0 || to < 0 || to >= values.length || to === from) return;
const next = [...values];
next.splice(from, 1);
next.splice(to, 0, value);
onReorder(next);
announce(
`${label ?? "Item"} moved to position ${to + 1} of ${values.length}`,
);
};
const move = (direction: -1 | 1) => {
moveTo(values.indexOf(value) + direction);
};
return (
<Reorder.Item
value={value}
dragListener={false}
dragControls={controls}
transition={
reduced
? { duration: 0 }
: { type: "spring", duration: 0.35, bounce: 0 }
}
whileDrag={reduced ? undefined : { scale: 1.02 }}
onDragStart={() => setDragging(true)}
onDragEnd={() => {
setDragging(false);
const at = values.indexOf(value);
if (at >= 0) {
announce(
`${label ?? "Item"} dropped at position ${at + 1} of ${values.length}`,
);
}
}}
data-slot="list-reorder-item"
className={cn(
"relative flex items-center gap-2 rounded-lg bg-card py-2.5 pr-3 pl-1.5 transition-[box-shadow] duration-(--duration-quick) ease-(--ease-out)",
dragging ? "z-10 shadow-overlay" : "shadow-border",
className,
)}
>
<button
type="button"
aria-label={label ? `Reorder ${label}` : "Reorder item"}
onPointerDown={(event) => {
event.preventDefault();
controls.start(event);
}}
onKeyDown={(event) => {
if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "Home") {
event.preventDefault();
moveTo(0);
} else if (event.key === "End") {
event.preventDefault();
moveTo(values.length - 1);
}
}}
className={cn(
"relative flex size-6 shrink-0 touch-none items-center justify-center rounded text-muted-foreground/60 transition-colors duration-(--duration-fast) hover:text-foreground",
dragging ? "cursor-grabbing" : "cursor-grab",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
)}
>
<GripVertical className="size-4" />
</button>
<div className="min-w-0 flex-1">{children}</div>
</Reorder.Item>
);
}