Overview

A priority queue you can drag. Items lift slightly (scale 1.02) while dragging and settle with a critically damped spring. Default interaction is handle-only (40×40 hit target); set dragFrom="row" for whole-row grabs.


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, useState } from "react";import {  Reorder,  useDragControls,  useReducedMotion,  motion,} from "motion/react";import { GripVertical } from "lucide-react";import { cn } from "@/lib/utils";
export type ReorderListItem = {  id: string;  label: string;  meta?: string;};
export type ReorderListProps = {  items: ReorderListItem[];  onReorder?: (ids: string[]) => void;  /** Drag from handle only (default) or entire row */  dragFrom?: "handle" | "row";  disabled?: boolean;  className?: string;};
/** Apple critically damped — graceful settle, no overshoot */const LAYOUT_SPRING = {  type: "spring" as const,  bounce: 0,  duration: 0.3,};
const DRAG_SPRING = {  type: "spring" as const,  bounce: 0,  duration: 0.32,};
function Row({  item,  dragFrom,  disabled,  reduce,}: {  item: ReorderListItem;  dragFrom: "handle" | "row";  disabled?: boolean;  reduce: boolean | null;}) {  const controls = useDragControls();  const [dragging, setDragging] = useState(false);  const [pressed, setPressed] = useState(false);
  return (    <Reorder.Item      value={item}      id={item.id}      as="li"      layout={reduce ? undefined : true}      dragListener={dragFrom === "row" && !disabled}      dragControls={controls}      dragElastic={0.12}      dragMomentum={false}      onDragStart={() => {        setDragging(true);        setPressed(false);      }}      onDragEnd={() => setDragging(false)}      transition={        reduce          ? { duration: 0.12 }          : {              layout: LAYOUT_SPRING,              scale: DRAG_SPRING,              boxShadow: { duration: 0.2 },            }      }      initial={false}      animate={        reduce          ? { scale: 1 }          : {              scale: dragging ? 1.03 : pressed ? 0.96 : 1,              zIndex: dragging ? 20 : 1,            }      }      style={{        position: "relative",        touchAction: dragFrom === "row" ? "none" : undefined,      }}      className={cn(        "list-none will-change-transform",        "flex items-center gap-2 rounded-md",        "border border-(--color-rule) bg-(--color-paper) px-2 py-2",        "outline outline-1 outline-transparent",        dragging &&          !reduce &&          [            "z-20 outline-black/6",            "shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.12),0_16px_40px_rgba(0,0,0,0.08)]",            "dark:outline-white/10",            "dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_10px_28px_rgba(0,0,0,0.45),0_20px_48px_rgba(0,0,0,0.3)]",          ].join(" "),        !dragging &&          "shadow-none transition-shadow duration-160 ease-[cubic-bezier(0.23,1,0.32,1)]",        dragFrom === "row" && !disabled && "cursor-grab active:cursor-grabbing",      )}      whileDrag={        reduce          ? undefined          : {              scale: 1.03,              cursor: "grabbing",            }      }    >      <motion.button        type="button"        aria-label={`Reorder ${item.label}`}        disabled={disabled}        onPointerDown={(e) => {          if (disabled) return;          // Instant press feedback (Apple §1 — respond on pointer-down)          setPressed(true);          if (dragFrom === "handle") {            controls.start(e);          }        }}        onPointerUp={() => setPressed(false)}        onPointerCancel={() => setPressed(false)}        whileTap={disabled || reduce ? undefined : { scale: 0.96 }}        className={cn(          "flex size-10 shrink-0 items-center justify-center",          // Concentric: track radius-md (8) − padding ≈ 4px inset          "rounded-[calc(var(--radius-md)-4px)]",          "text-(--color-ink-muted)",          "outline-none select-none",          "transition-colors duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",          "hover:bg-(--color-paper-2) hover:text-(--color-ink)",          "focus-visible:ring-2 focus-visible:ring-(--color-focus)",          "disabled:pointer-events-none disabled:opacity-40",          dragFrom === "handle" &&            !disabled &&            "cursor-grab touch-none active:cursor-grabbing",        )}      >        <GripVertical className="size-4 stroke-[1.5]" aria-hidden />      </motion.button>
      <div className="min-w-0 flex-1 pr-2">        <p className="truncate text-sm font-medium text-(--color-ink)">          {item.label}        </p>        {item.meta ? (          <p className="truncate text-xs text-(--color-ink-muted)">            {item.meta}          </p>        ) : null}      </div>    </Reorder.Item>  );}
export function ReorderList({  items: itemsProp,  onReorder,  dragFrom = "handle",  disabled,  className,}: ReorderListProps) {  const reduce = useReducedMotion();  const [items, setItems] = useState(itemsProp);
  useEffect(() => {    setItems(itemsProp);  }, [itemsProp]);
  return (    <Reorder.Group      axis="y"      values={items}      onReorder={(next) => {        if (disabled) return;        setItems(next);        onReorder?.(next.map((i) => i.id));      }}      className={cn("m-0 flex list-none flex-col gap-2 p-0", className)}      as="ul"    >      {items.map((item) => (        <Row          key={item.id}          item={item}          dragFrom={dragFrom}          disabled={disabled}          reduce={reduce}        />      ))}    </Reorder.Group>  );}

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[]YesRows with id, label, and optional meta.
onReorder(ids: string[]) => voidNoFires with the new id order after a drag settle.
dragFrom"handle" | "row"No"handle"Drag affordance — grip only or entire row.
disabledbooleanNoDisables dragging.
classNamestringNoOptional class names on the list.

Best Practices

  1. Prefer handle drag when rows contain other interactive controls.
  2. Lift uses scale(1.03) + layered shadow — press responds on pointer-down at 0.98.
  3. Sibling slots settle with critically damped springs (bounce: 0, duration: 0.4); dragElastic: 0.12 softens edges.
  4. Reduced motion keeps reordering; it drops lift scale and layout springs.
  5. Persist onReorder ids to your store — local state is only for the gesture.