Overview

A priority queue you can drag. The held row lifts with a layered shadow and leans a couple of degrees into the direction you throw it, the others dim and slide aside on critically damped springs, and rank numbers roll to their new values. When it lands, the row gets a soft wash so you can see where it went. Dragging stays inside the list. onReorder fires once per committed move, not on every swap.

Keyboard follows the drag-and-drop pattern screen-reader users expect: arrows move focus between grips; Space picks a row up, arrows (or Home/End) move it, Space drops and Escape cancels back to the original order. Every step is announced.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/reorder-list.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/reorder-list.tsx
tsx
"use client";
import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";import {  AnimatePresence,  Reorder,  motion,  useDragControls,  useMotionValue,  useReducedMotion,  useSpring,  useTransform,  useVelocity,} from "motion/react";import { cn } from "@/lib/utils";
export type ReorderListItem = {  id: string;  label: string;  meta?: string;};
export type ReorderListProps = {  items: ReorderListItem[];  /** Fires once per committed move: on drop, or when a keyboard move is confirmed. */  onReorder?: (ids: string[]) => void;  /** Drag from handle only (default) or entire row */  dragFrom?: "handle" | "row";  /** Show a rank number on each row. */  numbered?: boolean;  disabled?: boolean;  className?: string;};
/** Critically damped — graceful settle, no overshoot */const LAYOUT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };const LIFT_SPRING = { type: "spring" as const, bounce: 0.15, duration: 0.3 };const EASE_OUT = [0.23, 1, 0.32, 1] as const;
const sameOrder = (a: ReorderListItem[], b: ReorderListItem[]) =>  a.length === b.length && a.every((it, i) => it.id === b[i].id);
/* ── Row ─────────────────────────────────────────────────── */
function Row({  item,  index,  total,  dragFrom,  numbered,  disabled,  reduce,  lifted,  dimmed,  settled,  describedBy,  constraints,  onDragStart,  onDragEnd,  onHandleKeyDown,  onHandleBlur,}: {  item: ReorderListItem;  index: number;  total: number;  dragFrom: "handle" | "row";  numbered: boolean;  disabled?: boolean;  reduce: boolean | null;  lifted: boolean;  dimmed: boolean;  settled: boolean;  describedBy: string;  constraints: React.RefObject<HTMLDivElement | null>;  onDragStart: () => void;  onDragEnd: () => void;  onHandleKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;  onHandleBlur: () => void;}) {  const controls = useDragControls();
  // Reorder.Item adopts a y motion value from style, so the row can lean  // into the direction it's being thrown.  const y = useMotionValue(0);  const vy = useVelocity(y);  const lean = useSpring(useTransform(vy, [-1600, 0, 1600], [-2.2, 0, 2.2]), {    stiffness: 400,    damping: 30,  });
  return (    <Reorder.Item      value={item}      id={item.id}      as="div"      role="listitem"      dragListener={dragFrom === "row" && !disabled}      dragControls={controls}      dragConstraints={constraints}      dragElastic={0.08}      onDragStart={onDragStart}      onDragEnd={onDragEnd}      layout={reduce ? "position" : true}      transition={reduce ? { duration: 0 } : { layout: LAYOUT_SPRING }}      style={{ y, rotate: reduce ? 0 : lean, position: "relative", zIndex: lifted ? 20 : 1 }}      className={cn(        "group/row select-none",        dragFrom === "row" && !disabled && "cursor-grab touch-none active:cursor-grabbing",      )}    >      <motion.div        initial={false}        animate={{          scale: lifted && !reduce ? 1.025 : 1,          opacity: dimmed ? 0.55 : 1,        }}        transition={LIFT_SPRING}        className={cn(          "relative flex items-center gap-1 overflow-hidden rounded-[14px] border bg-(--color-paper) py-1.5 pr-3 pl-1.5",          "transition-[border-color,box-shadow] duration-200 ease-out",          lifted            ? "border-(--color-ink)/15 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_28px_rgba(0,0,0,0.12),0_24px_48px_rgba(0,0,0,0.08)] dark:shadow-[0_16px_40px_rgba(0,0,0,0.5)]"            : "border-(--color-rule) shadow-[0_1px_2px_rgba(0,0,0,0.03)]",        )}      >        {/* Landing flash: a quick wash when the row settles into its new slot */}        <AnimatePresence>          {settled && !reduce && (            <motion.span              aria-hidden              initial={{ opacity: 0.9 }}              animate={{ opacity: 0 }}              exit={{ opacity: 0 }}              transition={{ duration: 0.7, ease: "easeOut" }}              className="pointer-events-none absolute inset-0 bg-(--color-ink)/[0.05]"            />          )}        </AnimatePresence>
        <button          type="button"          aria-label={`${item.label}. Position ${index + 1} of ${total}.`}          aria-describedby={describedBy}          data-reorder-handle={item.id}          aria-pressed={lifted}          disabled={disabled}          onKeyDown={onHandleKeyDown}          onBlur={onHandleBlur}          onPointerDown={(e) => {            if (disabled || dragFrom !== "handle") return;            e.preventDefault();            controls.start(e);          }}          className={cn(            "relative grid size-10 shrink-0 place-items-center rounded-[10px] text-(--color-neutral) outline-none",            "transition-[background-color,color,transform] duration-150 ease-out",            "hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.94]",            "focus-visible:ring-2 focus-visible:ring-(--color-focus)",            "disabled:pointer-events-none disabled:opacity-40",            lifted && "bg-(--color-paper-2) text-(--color-ink)",            dragFrom === "handle" && !disabled && "cursor-grab touch-none active:cursor-grabbing",          )}        >          <GripIcon />        </button>
        {numbered ? (          <span            aria-hidden            className="relative mr-2 h-5 w-6 shrink-0 overflow-hidden text-center font-mono text-[12px] leading-5 text-(--color-neutral) tabular-nums"          >            <AnimatePresence initial={false} mode="popLayout">              <motion.span                key={index}                initial={reduce ? false : { y: 10, opacity: 0, filter: "blur(2px)" }}                animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}                exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0, filter: "blur(2px)" }}                transition={{ duration: 0.22, ease: EASE_OUT }}                className="block"              >                {String(index + 1).padStart(2, "0")}              </motion.span>            </AnimatePresence>          </span>        ) : null}
        <div className="min-w-0 flex-1 py-0.5">          <div className="truncate text-[14px] font-medium text-(--color-ink)">{item.label}</div>          {item.meta ? (            <div className="truncate text-[12px] text-(--color-neutral)">{item.meta}</div>          ) : null}        </div>      </motion.div>    </Reorder.Item>  );}
function GripIcon() {  return (    <svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor" aria-hidden>      {[2, 8, 14].map((cy) =>        [2.5, 7.5].map((cx) => <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="1.4" />),      )}    </svg>  );}
/* ── List ────────────────────────────────────────────────── */
export function ReorderList({  items: itemsProp,  onReorder,  dragFrom = "handle",  numbered = true,  disabled,  className,}: ReorderListProps) {  const reduce = useReducedMotion();  const instructionsId = `${useId()}-instructions`;  const [items, setItems] = useState(itemsProp);  const itemsRef = useRef(items);  itemsRef.current = items;
  // Pointer drag and keyboard pick-up share one notion of "lifted".  const [dragId, setDragId] = useState<string | null>(null);  const [grabbedId, setGrabbedId] = useState<string | null>(null);  const [settledId, setSettledId] = useState<string | null>(null);  const [announce, setAnnounce] = useState("");  const startOrder = useRef<ReorderListItem[]>(items);  const groupRef = useRef<HTMLDivElement>(null);  const settleTimer = useRef<number | null>(null);  // Moving a node in the DOM can blur it; keep the held handle focused.  const moving = useRef(false);
  useLayoutEffect(() => {    if (!moving.current || !grabbedId) return;    groupRef.current      ?.querySelector<HTMLButtonElement>(`[data-reorder-handle="${grabbedId}"]`)      ?.focus({ preventScroll: true });    moving.current = false;  }, [items, grabbedId]);
  useEffect(() => {    if (!dragId && !grabbedId) setItems(itemsProp);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [itemsProp]);
  useEffect(() => () => {    if (settleTimer.current) window.clearTimeout(settleTimer.current);  }, []);
  const flashSettled = (id: string) => {    setSettledId(id);    if (settleTimer.current) window.clearTimeout(settleTimer.current);    settleTimer.current = window.setTimeout(() => setSettledId(null), 750);  };
  const commit = (id: string) => {    const now = itemsRef.current;    const pos = now.findIndex((it) => it.id === id) + 1;    const label = now[pos - 1]?.label ?? "";    if (!sameOrder(now, startOrder.current)) {      onReorder?.(now.map((it) => it.id));      setAnnounce(`${label} dropped at position ${pos} of ${now.length}.`);      flashSettled(id);    } else {      setAnnounce(`${label} dropped. Position unchanged.`);    }  };
  const move = (id: string, to: number) => {    const current = itemsRef.current;    const from = current.findIndex((it) => it.id === id);    const target = Math.max(0, Math.min(current.length - 1, to));    if (from < 0 || from === target) return;    const next = [...current];    const [it] = next.splice(from, 1);    next.splice(target, 0, it);    itemsRef.current = next;    moving.current = true;    setItems(next);    setAnnounce(`${it.label}, position ${target + 1} of ${next.length}.`);  };
  const focusHandle = (index: number) => {    const handles = groupRef.current?.querySelectorAll<HTMLButtonElement>("[data-reorder-handle]");    handles?.[Math.max(0, Math.min((handles?.length ?? 1) - 1, index))]?.focus();  };
  const onHandleKeyDown = (id: string, index: number) => (e: React.KeyboardEvent<HTMLButtonElement>) => {    if (disabled) return;    const grabbed = grabbedId === id;    switch (e.key) {      case " ":      case "Enter": {        e.preventDefault();        if (grabbed) {          setGrabbedId(null);          commit(id);        } else {          startOrder.current = itemsRef.current;          setGrabbedId(id);          setAnnounce(            `Picked up ${itemsRef.current[index].label}. Position ${index + 1} of ${itemsRef.current.length}. Use the arrow keys to move, Space to drop, Escape to cancel.`,          );        }        return;      }      case "Escape":        if (!grabbed) return;        e.preventDefault();        setItems(startOrder.current);        itemsRef.current = startOrder.current;        setGrabbedId(null);        setAnnounce("Move cancelled.");        return;      case "ArrowUp":      case "ArrowDown":      case "Home":      case "End": {        e.preventDefault();        const to =          e.key === "Home" ? 0 : e.key === "End" ? itemsRef.current.length - 1 : index + (e.key === "ArrowUp" ? -1 : 1);        // Holding an item moves it; otherwise arrows just move focus.        if (grabbed) move(id, to);        else focusHandle(to);        return;      }    }  };
  const liftedId = dragId ?? grabbedId;
  return (    <div className={cn("w-full", className)}>      <span id={instructionsId} className="sr-only">        Press Space to pick up. Use the arrow keys to move, Space to drop, Escape to cancel.      </span>      <Reorder.Group        ref={groupRef}        as="div"        role="list"        axis="y"        values={items}        onReorder={(next) => {          if (disabled) return;          itemsRef.current = next;          setItems(next);        }}        className="relative flex flex-col gap-2"      >        {items.map((item, index) => (          <Row            key={item.id}            item={item}            index={index}            total={items.length}            dragFrom={dragFrom}            numbered={numbered}            disabled={disabled}            reduce={reduce}            lifted={liftedId === item.id}            dimmed={!!liftedId && liftedId !== item.id}            settled={settledId === item.id}            describedBy={instructionsId}            constraints={groupRef}            onDragStart={() => {              startOrder.current = itemsRef.current;              setGrabbedId(null);              setDragId(item.id);            }}            onDragEnd={() => {              setDragId(null);              commit(item.id);            }}            onHandleKeyDown={onHandleKeyDown(item.id, index)}            onHandleBlur={() => {              if (moving.current) return;              if (grabbedId === item.id) {                setGrabbedId(null);                commit(item.id);              }            }}          />        ))}      </Reorder.Group>      <span className="sr-only" role="status" aria-live="assertive">        {announce}      </span>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ReorderList } from "@/components/ui/reorder-list";
<ReorderList  items={items}  onReorder={(ids) => setOrder(ids)}/>

Props

PropTypeRequiredDefaultDescription
itemsReorderListItem[]Yes—Rows with id, label, and optional meta.
onReorder(ids: string[]) => voidNo—Fires once with the new id order when a drag drops or a keyboard move is confirmed.
dragFrom"handle" | "row"No"handle"Drag affordance — grip only or entire row.
numberedbooleanNotrueShow an animated rank number on each row.
disabledbooleanNo—Disables dragging.
classNamestringNo—Optional class names on the list.

Best Practices

  1. Prefer handle drag when rows contain other interactive controls.
  2. Lift uses a small scale and a layered shadow; a velocity-driven lean makes the row feel thrown rather than slid.
  3. Sibling slots settle with critically damped springs (bounce: 0); constrain dragging to the list.
  4. Give keyboard users pick-up / move / drop / cancel with announcements — moving on plain arrow presses is surprising.
  5. Reduced motion keeps reordering; it drops lift scale, lean and layout springs.
  6. Persist onReorder ids to your store — local state is only for the gesture.