Anatomy of Reorder List
How a data attribute (instead of 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 main decision
here is 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) comes from a plain data-dragging attribute
flipped by onDragStart/onDragEnd, read by a Tailwind
data-[dragging]:scale-[1.03] and a CSS transition. Framer's whileDrag
doesn't drive it.
whileDrag was the first approach, and it has a 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 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, 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) 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
The list uses one built-in reorder primitive, and the lift is CSS-driven on purpose so a stray drop can never strand an inline style.
- 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 (instead of 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 main decision
here is 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) comes from a plain data-dragging attribute
flipped by onDragStart/onDragEnd, read by a Tailwind
data-[dragging]:scale-[1.03] and a CSS transition. Framer's whileDrag
doesn't drive it.
whileDrag was the first approach, and it has a 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 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 on the 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, 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) 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
The list uses one built-in reorder primitive, and the lift is CSS-driven on purpose so a stray drop can never strand an inline style.
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