Field Reorder Drag
A row lifts onto a shadow while the rows around it part to make room — by pointer, and by arrow key from a grabbed state.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import {
useId,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { Reorder, useDragControls, useReducedMotion } from "motion/react";
/**
* Vibary · Field Reorder Drag
*
* A row lifts out of the list on a shadow while the rows around it part
* to make room, and drops into the gap they left. The same motion is on
* the keyboard: grab a row with space, move it with the arrow keys.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are mixed
* from the inherited text color, so the list reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `fields`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ReorderField = {
id: string;
label: string;
kind: string;
required: boolean;
};
export type FieldReorderDragProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Rows to order. */
fields?: readonly ReorderField[];
/** Accent for the grabbed state and focus. */
accent?: string;
/** Overall width. */
width?: number | string;
/** Fires with the new order after every move. */
onOrderChange?: (fields: ReorderField[]) => void;
};
type VariantConfig = {
/** Spring the rows use to close and reopen around the moving one. */
settle: { type: "spring"; stiffness: number; damping: number };
/** How far the row can be pulled past the ends of the list. */
elastic: number;
/**
* Height the row reads as being off the surface, in px. The shadow is
* derived from it rather than written out three times: the offset,
* the blur and the darkness are one quantity seen three ways, and
* choosing them independently is how a lift stops looking like a
* lift.
*/
lift: number;
};
/** Shadow for a row held `lift` px above the list. */
const shadowFor = (lift: number) =>
`0 ${lift}px ${Math.round(lift * 2.2 + 8)}px rgba(0, 0, 0, ${(0.14 + lift * 0.007).toFixed(2)})`;
// Quality rule: the row lifts on shadow and surface, never on scale — the
// row is mostly text, and text that grows under a finger reads as a bug
// rather than as depth. Every spring sits at or above a 0.8 damping
// ratio, so the rows part and close once instead of shuffling to a stop.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely off the surface, and the gap closes almost as fast as the
// row leaves it. For a long list of rows being sorted quickly.
subtle: {
settle: { type: "spring", stiffness: 760, damping: 54 },
elastic: 0.03,
lift: 4,
},
// The gap opens visibly ahead of the row. The all-purpose setting.
default: {
settle: { type: "spring", stiffness: 520, damping: 42 },
elastic: 0.08,
lift: 10,
},
// Well clear of the list, with the rows taking their time to part and
// close behind it, and more give at the ends.
playful: {
settle: { type: "spring", stiffness: 300, damping: 31 },
elastic: 0.16,
lift: 18,
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_FIELDS: readonly ReorderField[] = [
{ id: "name", label: "Full name", kind: "Short text", required: true },
{ id: "email", label: "Work email", kind: "Email", required: true },
{ id: "company", label: "Company", kind: "Short text", required: false },
{ id: "budget", label: "Budget range", kind: "Select", required: false },
];
function GripGlyph() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden>
<circle cx="4" cy="2.5" r="1.1" />
<circle cx="8" cy="2.5" r="1.1" />
<circle cx="4" cy="6" r="1.1" />
<circle cx="8" cy="6" r="1.1" />
<circle cx="4" cy="9.5" r="1.1" />
<circle cx="8" cy="9.5" r="1.1" />
</svg>
);
}
type RowProps = {
field: ReorderField;
position: number;
total: number;
grabbed: boolean;
dragging: boolean;
accent: string;
cfg: VariantConfig;
reduceMotion: boolean;
hintId: string;
onGrabToggle: () => void;
onRelease: () => void;
onMove: (delta: number) => void;
onDragState: (active: boolean) => void;
onRequiredToggle: () => void;
};
function FieldRow({
field,
position,
total,
grabbed,
dragging,
accent,
cfg,
reduceMotion,
hintId,
onGrabToggle,
onRelease,
onMove,
onDragState,
onRequiredToggle,
}: RowProps) {
const controls = useDragControls();
const [focused, setFocused] = useState(false);
const lifted = grabbed || dragging;
const onHandleKey = (event: ReactKeyboardEvent) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
onGrabToggle();
return;
}
if (event.key === "Escape" && grabbed) {
event.preventDefault();
onRelease();
return;
}
if (!grabbed) return;
if (event.key === "ArrowUp") {
event.preventDefault();
onMove(-1);
} else if (event.key === "ArrowDown") {
event.preventDefault();
onMove(1);
}
};
return (
<Reorder.Item
value={field}
as="li"
// Only the handle starts a drag, so the controls inside the row stay
// clickable and a stray swipe over the list does nothing.
dragListener={false}
dragControls={controls}
dragElastic={cfg.elastic}
onDragStart={() => onDragState(true)}
onDragEnd={() => onDragState(false)}
transition={reduceMotion ? { duration: 0 } : cfg.settle}
style={{
position: "relative",
listStyle: "none",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 11px",
marginBottom: 6,
borderRadius: 12,
border: `1px solid ${lifted ? accent : tone(12)}`,
background: lifted ? tone(11) : tone(5),
// The lift is shadow and surface only. Depth without a size change
// keeps every glyph in the row at exactly the size it started.
boxShadow: lifted ? shadowFor(cfg.lift) : "0 0 0 0 transparent",
zIndex: lifted ? 2 : 1,
transition:
"box-shadow 170ms ease-out, background-color 170ms ease-out, border-color 170ms ease-out",
}}
>
<button
type="button"
aria-label={`Reorder ${field.label}`}
aria-pressed={grabbed}
aria-describedby={hintId}
onPointerDown={(event: ReactPointerEvent) => {
if (!grabbed) controls.start(event);
}}
onKeyDown={onHandleKey}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
display: "grid",
placeItems: "center",
width: 24,
height: 26,
flex: "0 0 auto",
padding: 0,
borderRadius: 7,
border: 0,
background: grabbed ? tone(16) : "transparent",
color: "inherit",
opacity: grabbed ? 1 : 0.45,
cursor: grabbed ? "grabbing" : "grab",
touchAction: "none",
outline: "none",
boxShadow: focused
? `0 0 0 2px color-mix(in srgb, ${accent} 55%, transparent)`
: "0 0 0 0 transparent",
transition: "box-shadow 140ms ease-out, opacity 140ms ease-out",
}}
>
<GripGlyph />
</button>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}>
{field.label}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.45 }}>
{field.kind} · position {position} of {total}
</span>
</span>
{/* A real switch, not a decoration: space and enter toggle it, and
its state is exposed rather than implied by the tint. */}
<RequiredSwitch
checked={field.required}
label={field.label}
accent={accent}
onToggle={onRequiredToggle}
/>
</Reorder.Item>
);
}
function RequiredSwitch({
checked,
label,
accent,
onToggle,
}: {
checked: boolean;
label: string;
accent: string;
onToggle: () => void;
}) {
const [focused, setFocused] = useState(false);
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={`${label} required`}
onClick={onToggle}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
flex: "0 0 auto",
padding: "4px 9px",
borderRadius: 999,
border: `1px solid ${
checked ? `color-mix(in srgb, ${accent} 45%, transparent)` : tone(13)
}`,
background: checked
? `color-mix(in srgb, ${accent} 15%, transparent)`
: tone(5),
color: "inherit",
fontFamily: "inherit",
fontSize: 10.5,
fontWeight: 600,
letterSpacing: 0.2,
opacity: checked ? 0.95 : 0.55,
cursor: "pointer",
outline: "none",
boxShadow: focused
? `0 0 0 2px color-mix(in srgb, ${accent} 50%, transparent)`
: "0 0 0 0 transparent",
transition:
"background-color 150ms ease-out, border-color 150ms ease-out, box-shadow 140ms ease-out, opacity 150ms ease-out",
}}
>
{checked ? "Required" : "Optional"}
</button>
);
}
export default function FieldReorderDrag({
variant = "default",
fields = DEFAULT_FIELDS,
accent = "#5B5BD6",
width = 330,
onOrderChange,
}: FieldReorderDragProps) {
const [order, setOrder] = useState<ReorderField[]>([...fields]);
const [grabbedId, setGrabbedId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const hintId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const commit = (next: ReorderField[]) => {
setOrder(next);
onOrderChange?.(next);
};
const move = (id: string, delta: number) => {
const from = order.findIndex((entry) => entry.id === id);
const to = from + delta;
if (from < 0 || to < 0 || to >= order.length) return;
const next = [...order];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
commit(next);
setMessage(`${moved.label} moved to position ${to + 1} of ${next.length}`);
};
return (
<div style={{ width, color: "inherit" }}>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
marginBottom: 9,
}}
>
<span style={{ fontSize: 11.5, fontWeight: 650, letterSpacing: 0.3, opacity: 0.55 }}>
FIELD ORDER
</span>
<span id={hintId} style={{ fontSize: 10.5, opacity: 0.42 }}>
Drag the handle, or press space then use the arrow keys
</span>
</div>
<Reorder.Group
axis="y"
as="ul"
values={order}
onReorder={(next: ReorderField[]) => {
commit(next);
setMessage("Order updated");
}}
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{order.map((field, index) => (
<FieldRow
key={field.id}
field={field}
position={index + 1}
total={order.length}
grabbed={grabbedId === field.id}
dragging={draggingId === field.id}
accent={accent}
cfg={cfg}
reduceMotion={Boolean(reduceMotion)}
hintId={hintId}
onGrabToggle={() => {
const next = grabbedId === field.id ? null : field.id;
setGrabbedId(next);
setMessage(
next
? `${field.label} grabbed. Use the arrow keys to move it.`
: `${field.label} dropped at position ${index + 1} of ${order.length}`
);
}}
onRelease={() => {
setGrabbedId(null);
setMessage(
`${field.label} dropped at position ${index + 1} of ${order.length}`
);
}}
onMove={(delta) => move(field.id, delta)}
onDragState={(active) => setDraggingId(active ? field.id : null)}
onRequiredToggle={() =>
commit(
order.map((entry) =>
entry.id === field.id
? { ...entry, required: !entry.required }
: entry
)
)
}
/>
))}
</Reorder.Group>
{/* The move is spoken as well as animated, so the keyboard path is
not a silent one. */}
<div
role="status"
aria-live="polite"
style={{ marginTop: 4, minHeight: 15, fontSize: 11, opacity: 0.5 }}
>
{message}
</div>
</div>
);
}About this pattern
Reordering is the interaction people are most likely to get stuck in, so the row being moved has to look picked up and the gap has to open before the drop, not after it. The lift is shadow and surface only — never scale, because the row is mostly text and text that grows under a finger reads as a bug rather than as depth — and the rows on either side spring apart and close once, without shuffling to a stop. The keyboard gets the identical motion rather than a lesser version of it: the handle is a real button that grabs on space, moves on the arrow keys, drops on escape, and every move is spoken through a live region so the reorder is not a silent one.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Form
A row raised on a shadow while its neighbours slide to fill the space.
Related patterns
- Range Double HandleTwo handles bound a range and the fill between them tracks both, one-to-one under the pointer and settling on a spring from the keyboard.
- Slider Drag ValueThe handle stays under the finger while held, with a value bubble that rises on grab.
- Toggle Group SelectOne highlight slides between grouped buttons and resizes to each label.