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;
}
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;
}
/**
* 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),
* and the same handle reorders with Arrow keys for keyboard users, with
* position changes announced to a polite live region.
*/
export function ListReorder<T>({
values,
onReorder,
className,
children,
...props
}: ListReorderProps<T>) {
const [liveMessage, setLiveMessage] = React.useState("");
const context = React.useMemo<ListReorderContextValue>(
() => ({
values,
onReorder: onReorder as (values: unknown[]) => void,
announce: setLiveMessage,
}),
[values, onReorder],
);
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 } = useListReorder("ListReorderItem");
const controls = useDragControls();
const reduced = useReducedMotion();
const [dragging, setDragging] = React.useState(false);
const move = (direction: -1 | 1) => {
const from = values.indexOf(value);
const to = from + direction;
if (from < 0 || to < 0 || to >= values.length) return;
const next = [...values];
next.splice(from, 1);
next.splice(to, 0, value);
onReorder(next);
announce(`Moved to position ${to + 1} of ${values.length}`);
};
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)}
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);
}
if (event.key === "ArrowDown") {
event.preventDefault();
move(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>
);
}