Layout

List Reorder

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.

Install

npx shadcn@latest add @paragon/list-reorder

list-reorder.tsx

"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>
  );
}