Studio KitDeveloper-Interface Animationsmotion/react

ReorderList

A drag-to-reorder list built on motion's Reorder primitives, with springy layout reflow, a lifted drag state, grip handles and full keyboard reordering via Alt+Arrow keys.

Preview

Drag to reorder, or focus an item and press Alt plus Arrow Up or Arrow Down to move it.

  • Design tokens
  • Motion primitives
  • Component library
  • Docs site
  • Release v2.1

Customize

3 props
1.04

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add reorder-list

Writes components/ui/reorder-list.tsx and components/styles/reorder-list.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { ReorderList } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ReorderList />6  );7}

Props

PropTypeDefault
items

Unique string items to reorder; changing the content resets the internal order.

string[]['Design tokens', 'Motion primitives', 'Component library', 'Docs site', 'Release v2.1']
onReorder

Called with the new order after a drag or keyboard move.

(items: string[]) => voidundefined
showHandles

Show the grip handle icon on each item.

booleantrue
dragScale

Scale applied to the item while dragging (ignored under reduced motion).

number1.04
className

Additional class merged onto the root element.

string''

Accessibility

Renders a semantic <ul>/<li>; each item is focusable with an aria-label including its position. Arrow Up/Down moves focus, Alt+Arrow Up/Down moves the item (focus follows it) and an aria-live region announces the new position; usage instructions are linked via aria-describedby. MotionConfig reducedMotion="user" plus a disabled drag scale respect prefers-reduced-motion.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/reorder-list.tsx
1"use client";2 3import * as React from "react";4import { MotionConfig, Reorder, useReducedMotion } from "motion/react";5import styles from "../styles/reorder-list.module.css";6 7export interface ReorderListProps {8  /** Unique string items to reorder */9  items?: string[];10  /** Called with the new order after a drag or keyboard move */11  onReorder?: (items: string[]) => void;12  /** Show the grip handle icon on each item */13  showHandles?: boolean;14  /** Scale applied to the item while it is being dragged */15  dragScale?: number;16  /** Optional className merged onto the root */17  className?: string;18}19 20const DEFAULT_ITEMS = ["Design tokens", "Motion primitives", "Component library", "Docs site", "Release v2.1"];21 22function GripIcon() {23  return (24    <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">25      <circle cx="9" cy="6" r="1.5" />26      <circle cx="15" cy="6" r="1.5" />27      <circle cx="9" cy="12" r="1.5" />28      <circle cx="15" cy="12" r="1.5" />29      <circle cx="9" cy="18" r="1.5" />30      <circle cx="15" cy="18" r="1.5" />31    </svg>32  );33}34 35export function ReorderList({ items = DEFAULT_ITEMS, onReorder, showHandles = true, dragScale = 1.04, className = "" }: ReorderListProps) {36  const prefersReducedMotion = useReducedMotion();37  const instructionsId = React.useId();38  const [list, setList] = React.useState<string[]>(items);39  const [announcement, setAnnouncement] = React.useState("");40  const refs = React.useRef(new Map<string, HTMLLIElement>());41  const pendingFocus = React.useRef<string | null>(null);42  const itemsKey = items.join("\u0000");43  const lastKey = React.useRef(itemsKey);44 45  if (lastKey.current !== itemsKey) {46    lastKey.current = itemsKey;47    setList(items);48  }49 50  React.useEffect(() => {51    if (pendingFocus.current === null) return;52    refs.current.get(pendingFocus.current)?.focus();53    pendingFocus.current = null;54  }, [list]);55 56  const commit = (next: string[]) => {57    setList(next);58    onReorder?.(next);59  };60 61  const handleKeyDown = (e: React.KeyboardEvent<HTMLLIElement>, index: number) => {62    if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;63    e.preventDefault();64    const target = index + (e.key === "ArrowUp" ? -1 : 1);65    if (target < 0 || target >= list.length) return;66    if (!e.altKey) {67      refs.current.get(list[target])?.focus();68      return;69    }70    const value = list[index];71    const next = [...list];72    next.splice(index, 1);73    next.splice(target, 0, value);74    pendingFocus.current = value;75    setAnnouncement(`Moved ${value} to position ${target + 1} of ${next.length}`);76    commit(next);77  };78 79  return (80    <MotionConfig reducedMotion="user">81      <div className={`${styles.root} ${className}`.trim()}>82        <p id={instructionsId} className={styles.srOnly}>83          Drag to reorder, or focus an item and press Alt plus Arrow Up or Arrow Down to move it.84        </p>85        <Reorder.Group axis="y" values={list} onReorder={commit} className={styles.list} aria-describedby={instructionsId}>86          {list.map((it, i) => (87            <Reorder.Item88              key={it}89              value={it}90              ref={(node: HTMLLIElement | null) => {91                if (node) refs.current.set(it, node);92                else refs.current.delete(it);93              }}94              tabIndex={0}95              aria-label={`${it}, position ${i + 1} of ${list.length}`}96              onKeyDown={(e: React.KeyboardEvent<HTMLLIElement>) => handleKeyDown(e, i)}97              whileDrag={prefersReducedMotion ? undefined : { scale: dragScale }}98              className={styles.item}99            >100              {showHandles && (101                <span aria-hidden="true" className={styles.handle}>102                  <GripIcon />103                </span>104              )}105              <span className={styles.label}>{it}</span>106              <span aria-hidden="true" className={styles.index}>107                {String(i + 1).padStart(2, "0")}108              </span>109            </Reorder.Item>110          ))}111        </Reorder.Group>112        <p aria-live="polite" className={styles.srOnly}>113          {announcement}114        </p>115      </div>116    </MotionConfig>117  );118}