Skip to content

Rows glide to their new place on sort and filter; leavers step out first.

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 motion

03Usage

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

PropTypeDefaultDescription
as"ul" | "ol" | "div""ul"The list element. Its direct children must be FlipItems with stable keys.
initialbooleanfalseAnimate the items present on first mount too. Pair with Stagger In instead if you want an entrance.
onExitComplete() => voidCalled once every removed item has finished leaving.

FlipItem

PropTypeDefaultDescription
as"li" | "div""li"Use "div" inside FlipList as="div".
key*stringThe 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.