Anatomy of Reorder List
How a data attribute (not whileDrag) drives the grab lift, and how one spring resolves the dragged item and every displaced neighbor together.
↓Scroll to step through it
Group, item, and a data-driven lift
Reorder List leans almost entirely on Motion's built-in drag-to-reorder
primitives — Reorder.Group and Reorder.Item already know how to swap
values and animate the rest of the list around a drag. The interesting
decisions here are in what this component deliberately does not let
Framer drive: the lift.
Each row is a Reorder.Item with a value inside one Reorder.Group
holding the ordered array and the onReorder callback. The grab lift —
scale-[1.03] and a shadow — isn't a Framer whileDrag animation. It's a
plain data-dragging attribute flipped by onDragStart/onDragEnd, read by
a Tailwind data-[dragging]:scale-[1.03] and a CSS transition.
whileDrag was the first approach, and it has a real failure mode: a
scale/boxShadow animation driven that way can get stranded as an inline
style if a drop lands mid-gesture, leaving the row visibly wider or shadowed
afterward. A CSS class keyed off a data attribute always resets cleanly —
when dragging flips back to false, the CSS transition just runs in
reverse.
The lift shadow lives on a sibling overlay whose opacity fades in,
rather than transitioning box-shadow on the row itself — box-shadow is a
main-thread paint property; opacity is compositor-only.
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-lg opacity-0 shadow-xl [transition:opacity_200ms_ease] group-data-[dragging]:opacity-100"
/>Neighbor flow
What Framer does own is the reordering itself. The moment a dragged
item's center crosses a neighbour's, Reorder.Group swaps them in the
values array and calls onReorder — every item, dragged or not, then
animates to its new slot under the same REORDER_SPRING.
<Reorder.Item value={value} transition={REORDER_SPRING}>Stiffness 520 with damping 32 is snappier than the 320/32 spring used elsewhere in the library — a list reorder needs to feel instantaneous, not smoothed, or the displaced rows read as sluggish while a hand is still holding the dragged one.
ReorderList never keeps its own copy of the order. values and
onReorder are both owned by the caller — the component is a rendering and
gesture layer over state you already control, so anything downstream of the
list (persistence, undo, syncing to a server) just reacts to the same
setState call the drag already triggers.
<ReorderList values={items} onReorder={setItems}>
{items.map((item) => (
<ReorderItem key={item.id} value={item}>{item.label}</ReorderItem>
))}
</ReorderList>The result
One built-in reorder primitive for the list, one intentionally CSS-driven lift so a stray drop can never strand an inline style — that's the whole mechanism.
- Group
- Reorder.Group, holds values + axis
- Item
- Reorder.Item, one value each
- Lift
- data-dragging → scale 1.03, CSS transition
- Shadow
- sibling overlay, opacity fade only
<Reorder.Itemvalue={value}data-dragging={dragging || undefined}transition={REORDER_SPRING}onDragStart={() => setDragging(true)}onDragEnd={() => setDragging(false)}className="... [transition:scale_150ms_ease] data-[dragging]:scale-[1.03]">Anatomy of Reorder List
How a data attribute (not whileDrag) drives the grab lift, and how one spring resolves the dragged item and every displaced neighbor together.
- Group
- Reorder.Group, holds values + axis
- Item
- Reorder.Item, one value each
- Lift
- data-dragging → scale 1.03, CSS transition
- Shadow
- sibling overlay, opacity fade only
<Reorder.Itemvalue={value}data-dragging={dragging || undefined}transition={REORDER_SPRING}onDragStart={() => setDragging(true)}onDragEnd={() => setDragging(false)}className="... [transition:scale_150ms_ease] data-[dragging]:scale-[1.03]">Group, item, and a data-driven lift
Reorder List leans almost entirely on Motion's built-in drag-to-reorder
primitives — Reorder.Group and Reorder.Item already know how to swap
values and animate the rest of the list around a drag. The interesting
decisions here are in what this component deliberately does not let
Framer drive: the lift.
Each row is a Reorder.Item with a value inside one Reorder.Group
holding the ordered array and the onReorder callback. The grab lift —
scale-[1.03] and a shadow — isn't a Framer whileDrag animation. It's a
plain data-dragging attribute flipped by onDragStart/onDragEnd, read by
a Tailwind data-[dragging]:scale-[1.03] and a CSS transition.
whileDrag was the first approach, and it has a real failure mode: a
scale/boxShadow animation driven that way can get stranded as an inline
style if a drop lands mid-gesture, leaving the row visibly wider or shadowed
afterward. A CSS class keyed off a data attribute always resets cleanly —
when dragging flips back to false, the CSS transition just runs in
reverse.
The lift shadow lives on a sibling overlay whose opacity fades in,
rather than transitioning box-shadow on the row itself — box-shadow is a
main-thread paint property; opacity is compositor-only.
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-lg opacity-0 shadow-xl [transition:opacity_200ms_ease] group-data-[dragging]:opacity-100"
/>- Dragged
- follows the pointer, lift via data-dragging
- Neighbor
- springs into the vacated slot — same REORDER_SPRING
// Snappy enough that neighbours flow out of the way instantly, but with a// little spring so items settle rather than snap.const REORDER_SPRING = { type: "spring" as const, stiffness: 520, damping: 32 };Neighbor flow
What Framer does own is the reordering itself. The moment a dragged
item's center crosses a neighbour's, Reorder.Group swaps them in the
values array and calls onReorder — every item, dragged or not, then
animates to its new slot under the same REORDER_SPRING.
<Reorder.Item value={value} transition={REORDER_SPRING}>Stiffness 520 with damping 32 is snappier than the 320/32 spring used elsewhere in the library — a list reorder needs to feel instantaneous, not smoothed, or the displaced rows read as sluggish while a hand is still holding the dragged one.
ReorderList never keeps its own copy of the order. values and
onReorder are both owned by the caller — the component is a rendering and
gesture layer over state you already control, so anything downstream of the
list (persistence, undo, syncing to a server) just reacts to the same
setState call the drag already triggers.
<ReorderList values={items} onReorder={setItems}>
{items.map((item) => (
<ReorderItem key={item.id} value={item}>{item.label}</ReorderItem>
))}
</ReorderList>- Draft proposal
- Review with design
- Ship to staging
- Announce
The result
One built-in reorder primitive for the list, one intentionally CSS-driven lift so a stray drop can never strand an inline style — that's the whole mechanism.
Accessibility and reduced motion
Rows are real <li> elements inside a <ul> (Reorder.Group's as="ul"
and Reorder.Item's as="li"), so list semantics survive for anyone using
a screen reader. axis can flip to "x" for a horizontal list without
changing anything else. Under prefers-reduced-motion, both the scale
transition and the shadow's opacity transition drop out
(motion-reduce:transition-none) — the lift is skipped, though the drag
gesture itself still works.
Motion Score
scaleScale spring / pressopacityFade / cross-fade