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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add reorder-listWrites 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.
1import { ReorderList } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ReorderList />6 );7}Props
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
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}