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.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";4import {5 AnimatePresence,6 Reorder,7 motion,8 useDragControls,9 useMotionValue,10 useReducedMotion,11 useSpring,12 useTransform,13 useVelocity,14} from "motion/react";15import { cn } from "@/lib/utils";16 17export type ReorderListItem = {18 id: string;19 label: string;20 meta?: string;21};22 23export type ReorderListProps = {24 items: ReorderListItem[];25 /** Fires once per committed move: on drop, or when a keyboard move is confirmed. */26 onReorder?: (ids: string[]) => void;27 /** Drag from handle only (default) or entire row */28 dragFrom?: "handle" | "row";29 /** Show a rank number on each row. */30 numbered?: boolean;31 disabled?: boolean;32 className?: string;33};34 35/** Critically damped — graceful settle, no overshoot */36const LAYOUT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.32 };37const LIFT_SPRING = { type: "spring" as const, bounce: 0.15, duration: 0.3 };38const EASE_OUT = [0.23, 1, 0.32, 1] as const;39 40const sameOrder = (a: ReorderListItem[], b: ReorderListItem[]) =>41 a.length === b.length && a.every((it, i) => it.id === b[i].id);42 43/* ── Row ─────────────────────────────────────────────────── */44 45function Row({46 item,47 index,48 total,49 dragFrom,50 numbered,51 disabled,52 reduce,53 lifted,54 dimmed,55 settled,56 describedBy,57 constraints,58 onDragStart,59 onDragEnd,60 onHandleKeyDown,61 onHandleBlur,62}: {63 item: ReorderListItem;64 index: number;65 total: number;66 dragFrom: "handle" | "row";67 numbered: boolean;68 disabled?: boolean;69 reduce: boolean | null;70 lifted: boolean;71 dimmed: boolean;72 settled: boolean;73 describedBy: string;74 constraints: React.RefObject<HTMLDivElement | null>;75 onDragStart: () => void;76 onDragEnd: () => void;77 onHandleKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;78 onHandleBlur: () => void;79}) {80 const controls = useDragControls();81 82 // Reorder.Item adopts a y motion value from style, so the row can lean83 // into the direction it's being thrown.84 const y = useMotionValue(0);85 const vy = useVelocity(y);86 const lean = useSpring(useTransform(vy, [-1600, 0, 1600], [-2.2, 0, 2.2]), {87 stiffness: 400,88 damping: 30,89 });90 91 return (92 <Reorder.Item93 value={item}94 id={item.id}95 as="div"96 role="listitem"97 dragListener={dragFrom === "row" && !disabled}98 dragControls={controls}99 dragConstraints={constraints}100 dragElastic={0.08}101 onDragStart={onDragStart}102 onDragEnd={onDragEnd}103 layout={reduce ? "position" : true}104 transition={reduce ? { duration: 0 } : { layout: LAYOUT_SPRING }}105 style={{ y, rotate: reduce ? 0 : lean, position: "relative", zIndex: lifted ? 20 : 1 }}106 className={cn(107 "group/row select-none",108 dragFrom === "row" && !disabled && "cursor-grab touch-none active:cursor-grabbing",109 )}110 >111 <motion.div112 initial={false}113 animate={{114 scale: lifted && !reduce ? 1.025 : 1,115 opacity: dimmed ? 0.55 : 1,116 }}117 transition={LIFT_SPRING}118 className={cn(119 "relative flex items-center gap-1 overflow-hidden rounded-[14px] border bg-(--color-paper) py-1.5 pr-3 pl-1.5",120 "transition-[border-color,box-shadow] duration-200 ease-out",121 lifted122 ? "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)]"123 : "border-(--color-rule) shadow-[0_1px_2px_rgba(0,0,0,0.03)]",124 )}125 >126 {/* Landing flash: a quick wash when the row settles into its new slot */}127 <AnimatePresence>128 {settled && !reduce && (129 <motion.span130 aria-hidden131 initial={{ opacity: 0.9 }}132 animate={{ opacity: 0 }}133 exit={{ opacity: 0 }}134 transition={{ duration: 0.7, ease: "easeOut" }}135 className="pointer-events-none absolute inset-0 bg-(--color-ink)/[0.05]"136 />137 )}138 </AnimatePresence>139 140 <button141 type="button"142 aria-label={`${item.label}. Position ${index + 1} of ${total}.`}143 aria-describedby={describedBy}144 data-reorder-handle={item.id}145 aria-pressed={lifted}146 disabled={disabled}147 onKeyDown={onHandleKeyDown}148 onBlur={onHandleBlur}149 onPointerDown={(e) => {150 if (disabled || dragFrom !== "handle") return;151 e.preventDefault();152 controls.start(e);153 }}154 className={cn(155 "relative grid size-10 shrink-0 place-items-center rounded-[10px] text-(--color-neutral) outline-none",156 "transition-[background-color,color,transform] duration-150 ease-out",157 "hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.94]",158 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",159 "disabled:pointer-events-none disabled:opacity-40",160 lifted && "bg-(--color-paper-2) text-(--color-ink)",161 dragFrom === "handle" && !disabled && "cursor-grab touch-none active:cursor-grabbing",162 )}163 >164 <GripIcon />165 </button>166 167 {numbered ? (168 <span169 aria-hidden170 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"171 >172 <AnimatePresence initial={false} mode="popLayout">173 <motion.span174 key={index}175 initial={reduce ? false : { y: 10, opacity: 0, filter: "blur(2px)" }}176 animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}177 exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0, filter: "blur(2px)" }}178 transition={{ duration: 0.22, ease: EASE_OUT }}179 className="block"180 >181 {String(index + 1).padStart(2, "0")}182 </motion.span>183 </AnimatePresence>184 </span>185 ) : null}186 187 <div className="min-w-0 flex-1 py-0.5">188 <div className="truncate text-[14px] font-medium text-(--color-ink)">{item.label}</div>189 {item.meta ? (190 <div className="truncate text-[12px] text-(--color-neutral)">{item.meta}</div>191 ) : null}192 </div>193 </motion.div>194 </Reorder.Item>195 );196}197 198function GripIcon() {199 return (200 <svg width="10" height="16" viewBox="0 0 10 16" fill="currentColor" aria-hidden>201 {[2, 8, 14].map((cy) =>202 [2.5, 7.5].map((cx) => <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="1.4" />),203 )}204 </svg>205 );206}207 208/* ── List ────────────────────────────────────────────────── */209 210export function ReorderList({211 items: itemsProp,212 onReorder,213 dragFrom = "handle",214 numbered = true,215 disabled,216 className,217}: ReorderListProps) {218 const reduce = useReducedMotion();219 const instructionsId = `${useId()}-instructions`;220 const [items, setItems] = useState(itemsProp);221 const itemsRef = useRef(items);222 itemsRef.current = items;223 224 // Pointer drag and keyboard pick-up share one notion of "lifted".225 const [dragId, setDragId] = useState<string | null>(null);226 const [grabbedId, setGrabbedId] = useState<string | null>(null);227 const [settledId, setSettledId] = useState<string | null>(null);228 const [announce, setAnnounce] = useState("");229 const startOrder = useRef<ReorderListItem[]>(items);230 const groupRef = useRef<HTMLDivElement>(null);231 const settleTimer = useRef<number | null>(null);232 // Moving a node in the DOM can blur it; keep the held handle focused.233 const moving = useRef(false);234 235 useLayoutEffect(() => {236 if (!moving.current || !grabbedId) return;237 groupRef.current238 ?.querySelector<HTMLButtonElement>(`[data-reorder-handle="${grabbedId}"]`)239 ?.focus({ preventScroll: true });240 moving.current = false;241 }, [items, grabbedId]);242 243 useEffect(() => {244 if (!dragId && !grabbedId) setItems(itemsProp);245 // eslint-disable-next-line react-hooks/exhaustive-deps246 }, [itemsProp]);247 248 useEffect(() => () => {249 if (settleTimer.current) window.clearTimeout(settleTimer.current);250 }, []);251 252 const flashSettled = (id: string) => {253 setSettledId(id);254 if (settleTimer.current) window.clearTimeout(settleTimer.current);255 settleTimer.current = window.setTimeout(() => setSettledId(null), 750);256 };257 258 const commit = (id: string) => {259 const now = itemsRef.current;260 const pos = now.findIndex((it) => it.id === id) + 1;261 const label = now[pos - 1]?.label ?? "";262 if (!sameOrder(now, startOrder.current)) {263 onReorder?.(now.map((it) => it.id));264 setAnnounce(`${label} dropped at position ${pos} of ${now.length}.`);265 flashSettled(id);266 } else {267 setAnnounce(`${label} dropped. Position unchanged.`);268 }269 };270 271 const move = (id: string, to: number) => {272 const current = itemsRef.current;273 const from = current.findIndex((it) => it.id === id);274 const target = Math.max(0, Math.min(current.length - 1, to));275 if (from < 0 || from === target) return;276 const next = [...current];277 const [it] = next.splice(from, 1);278 next.splice(target, 0, it);279 itemsRef.current = next;280 moving.current = true;281 setItems(next);282 setAnnounce(`${it.label}, position ${target + 1} of ${next.length}.`);283 };284 285 const focusHandle = (index: number) => {286 const handles = groupRef.current?.querySelectorAll<HTMLButtonElement>("[data-reorder-handle]");287 handles?.[Math.max(0, Math.min((handles?.length ?? 1) - 1, index))]?.focus();288 };289 290 const onHandleKeyDown = (id: string, index: number) => (e: React.KeyboardEvent<HTMLButtonElement>) => {291 if (disabled) return;292 const grabbed = grabbedId === id;293 switch (e.key) {294 case " ":295 case "Enter": {296 e.preventDefault();297 if (grabbed) {298 setGrabbedId(null);299 commit(id);300 } else {301 startOrder.current = itemsRef.current;302 setGrabbedId(id);303 setAnnounce(304 `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.`,305 );306 }307 return;308 }309 case "Escape":310 if (!grabbed) return;311 e.preventDefault();312 setItems(startOrder.current);313 itemsRef.current = startOrder.current;314 setGrabbedId(null);315 setAnnounce("Move cancelled.");316 return;317 case "ArrowUp":318 case "ArrowDown":319 case "Home":320 case "End": {321 e.preventDefault();322 const to =323 e.key === "Home" ? 0 : e.key === "End" ? itemsRef.current.length - 1 : index + (e.key === "ArrowUp" ? -1 : 1);324 // Holding an item moves it; otherwise arrows just move focus.325 if (grabbed) move(id, to);326 else focusHandle(to);327 return;328 }329 }330 };331 332 const liftedId = dragId ?? grabbedId;333 334 return (335 <div className={cn("w-full", className)}>336 <span id={instructionsId} className="sr-only">337 Press Space to pick up. Use the arrow keys to move, Space to drop, Escape to cancel.338 </span>339 <Reorder.Group340 ref={groupRef}341 as="div"342 role="list"343 axis="y"344 values={items}345 onReorder={(next) => {346 if (disabled) return;347 itemsRef.current = next;348 setItems(next);349 }}350 className="relative flex flex-col gap-2"351 >352 {items.map((item, index) => (353 <Row354 key={item.id}355 item={item}356 index={index}357 total={items.length}358 dragFrom={dragFrom}359 numbered={numbered}360 disabled={disabled}361 reduce={reduce}362 lifted={liftedId === item.id}363 dimmed={!!liftedId && liftedId !== item.id}364 settled={settledId === item.id}365 describedBy={instructionsId}366 constraints={groupRef}367 onDragStart={() => {368 startOrder.current = itemsRef.current;369 setGrabbedId(null);370 setDragId(item.id);371 }}372 onDragEnd={() => {373 setDragId(null);374 commit(item.id);375 }}376 onHandleKeyDown={onHandleKeyDown(item.id, index)}377 onHandleBlur={() => {378 if (moving.current) return;379 if (grabbedId === item.id) {380 setGrabbedId(null);381 commit(item.id);382 }383 }}384 />385 ))}386 </Reorder.Group>387 <span className="sr-only" role="status" aria-live="assertive">388 {announce}389 </span>390 </div>391 );392}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { ReorderList } from "@/components/ui/reorder-list";2 3<ReorderList4 items={items}5 onReorder={(ids) => setOrder(ids)}6/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | ReorderListItem[] | Yes | — | Rows with id, label, and optional meta. |
| onReorder | (ids: string[]) => void | No | — | 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. |
| numbered | boolean | No | true | Show an animated rank number on each row. |
| disabled | boolean | No | — | Disables dragging. |
| className | string | No | — | Optional class names on the list. |
Best Practices
- Prefer handle drag when rows contain other interactive controls.
- Lift uses a small scale and a layered shadow; a velocity-driven lean makes the row feel thrown rather than slid.
- Sibling slots settle with critically damped springs (
bounce: 0); constrain dragging to the list. - Give keyboard users pick-up / move / drop / cancel with announcements — moving on plain arrow presses is surprising.
- Reduced motion keeps reordering; it drops lift scale, lean and layout springs.
- Persist
onReorderids to your store — local state is only for the gesture.