Rows glide to their new place on sort and filter; leavers step out first.
Motion primitivesmotion
01Preview
Checkout · Sprint 24
- Urgent priorityCHK-215Coupon field accepts expired codesAssigned to Priya NairDue Sep 24
- Urgent priorityCHK-212Apple Pay button misaligned on Safari 17Assigned to Maya ChenDue Sep 30
- High priorityCHK-199Show the VAT line on EU invoicesAssigned to Maya ChenDue Sep 26
- High priorityCHK-208Retry failed card charges once before emailingAssigned to Tomás RuizDue Oct 6
- Medium priorityCHK-203Currency switcher forgets the choice on refreshAssigned to Tomás RuizDue Sep 25
- Medium priorityCHK-187Keep the cart across devices for signed-in customersAssigned to Maya ChenDue Oct 2
- Low priorityCHK-176Copy review for the order confirmation emailAssigned to Sam OkaforDue Sep 29
7 open
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { FlipItem, FlipList } from "@/components/ui/flip-list";
<FlipList aria-label="Open issues">
{issues.filter(visible).sort(bySort).map((issue) => (
<FlipItem key={issue.id}>{issue.title}</FlipItem>
))}
</FlipList>04Source
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, useContext } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
const ReduceCtx = createContext(false);
type MotionConflicts = "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onAnimationIteration";
export type FlipListProps = Omit<React.ComponentProps<"ul">, MotionConflicts> & {
/** The list element. Children must be FlipItems with stable keys. */
as?: "ul" | "ol" | "div";
/** Animate the items that are there on first mount too. Off by default: a list that's already there shouldn't perform. */
initial?: boolean;
/** Called once every removed item has finished leaving. */
onExitComplete?: () => void;
};
/**
* When the children change order, arrive or leave, every item glides from where
* it was to where it now is. Leaving items are lifted out of the flow at once,
* so the rest close the gap while they fade rather than after.
*/
export function FlipList({ as: Tag = "ul", initial = false, onExitComplete, className, children, ...rest }: FlipListProps) {
const reduce = !!useReducedMotion();
const Comp = Tag as "ul";
return (
<ReduceCtx.Provider value={reduce}>
{/* Popped (leaving) items are positioned against the list, so it has to be a containing block. */}
<Comp className={cn("relative", className)} {...rest}>
{/* Without movement, a leaving item holds its place while it fades so nothing overlaps; then the rest snap up. */}
<AnimatePresence initial={initial} mode={reduce ? "sync" : "popLayout"} onExitComplete={onExitComplete}>
{children}
</AnimatePresence>
</Comp>
</ReduceCtx.Provider>
);
}
export type FlipItemProps = Omit<React.ComponentProps<"li">, MotionConflicts> & {
/** Use "div" inside a `FlipList as="div"`. */
as?: "li" | "div";
};
const move = spring.soft;
/** One item. Its `key` is its identity: keep it stable across sorts and filters. */
export function FlipItem({ as = "li", className, ref, ...rest }: FlipItemProps) {
const reduce = useContext(ReduceCtx);
const props = {
// "position" moves the item without scaling it, so text and borders never stretch mid-flight.
layout: reduce ? false : ("position" as const),
className,
initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97 },
animate: { opacity: 1, scale: 1 },
exit: reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { opacity: 0, scale: 0.97, transition: { duration: 0.16, ease: ease.out } },
transition: { layout: move, default: { duration: reduce ? 0.15 : 0.24, ease: ease.out } },
...(rest as React.ComponentProps<typeof motion.li>),
};
return as === "div" ? (
<motion.div ref={ref as unknown as React.Ref<HTMLDivElement>} {...(props as React.ComponentProps<typeof motion.div>)} />
) : (
<motion.li ref={ref} {...props} />
);
}05Props
FlipList
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "ul" | "ol" | "div" | "ul" | The list element. Its direct children must be FlipItems with stable keys. |
| initial | boolean | false | Animate the items present on first mount too. Pair with Stagger In instead if you want an entrance. |
| onExitComplete | () => void | — | Called once every removed item has finished leaving. |
FlipItem
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "li" | "div" | "li" | Use "div" inside FlipList as="div". |
| key* | string | — | The item's identity. Keep it stable across sorts and filters, or the item is treated as leaving and arriving instead of moving. |
06Notes
Behavior
- Leaving items are lifted out of the flow the moment they're removed, so the rows below start closing the gap while it fades instead of waiting for it.
- Items move by position only, never scaled, so text, borders and radii stay crisp mid-flight even when rows are different heights.
- A sort clicked again mid-flight retargets from wherever each row is; nothing snaps back to finish the first move.
- The list is a containing block for the leaving rows. If it sits inside a scroll container, give that container Motion's layoutScroll so moves are measured against the scroll.
- Removing the focused row is the app's call; the demo hands focus to the next row, or to Undo, and back to the restored row after Undo.
Motion
- Moves ride the soft spring (260 stiffness, 28 damping), settling in about 350ms: continuity for a change the user asked for, with no overshoot.
- Arrivals fade and grow from 0.97 over 240ms on the expo ease-out; exits fade and shrink back in 160ms, faster than they came.
- Reduced motion turns off the moves entirely: leaving rows fade out in 120ms in place, then the list closes up at once, and new rows fade in over 150ms.
Accessibility
- Renders a real list (ul/ol with li) by default, so screen readers announce the count and position; pass aria-label to name it.
- Only transforms and opacity animate. DOM order always matches the new order, so Tab and the reading order are correct from the first frame.
- Announce the result of a change yourself (the demo uses a polite status for Marked done and Undo); the motion carries no information on its own.