All patterns

Staggered List Entrance

Rows fade and lift into place about fifty milliseconds apart, top to bottom.

loadingfriendlyminimalautomatic · finite · starter · ~0.6s
Variant

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.

216 lines · react + motion only
import { Children } from "react";
import type { ReactNode } from "react";
import { motion, stagger, useReducedMotion } from "motion/react";
import type { Variants } from "motion/react";

/**
 * Vibary · Staggered List Entrance
 *
 * Rows fade and lift into place a fraction of a second apart, top to
 * bottom, so a freshly loaded list has somewhere for the eye to start.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; pass `children` to stagger your own rows.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type StaggeredListEntranceProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Your rows — each top-level child becomes one staggered row.
   *  Falls back to embedded sample rows. */
  children?: ReactNode;
  /** Gap between row starts in ms. Overrides the variant when set. */
  staggerMs?: number;
  /** List width — px number or any CSS length. */
  width?: number | string;
  /** Fires once the last row has settled. */
  onComplete?: () => void;
};

type VariantConfig = {
  lift: number;
  staggerMs: number;
  leadIn: number;
  fadeSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: rows carry text, so they travel on a spring at or above
// critical damping — one soft landing, never a rebound. Variants change
// how far a row travels and how long the wave takes to cross the list.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // 6px and 40ms: present for long lists where a bigger wave would turn
  // into a queue the user has to wait out.
  subtle: {
    lift: 6,
    staggerMs: 40,
    leadIn: 0.04,
    fadeSeconds: 0.2,
    spring: { type: "spring", stiffness: 440, damping: 42 },
  },
  // The all-purpose setting: 50ms apart is enough to read a direction of
  // travel and short enough that eight rows are settled inside a second.
  default: {
    lift: 12,
    staggerMs: 50,
    leadIn: 0.06,
    fadeSeconds: 0.24,
    spring: { type: "spring", stiffness: 380, damping: 34 },
  },
  // Longer travel and a wider gap — for a short list that is the whole
  // point of the screen.
  playful: {
    lift: 18,
    staggerMs: 65,
    leadIn: 0.08,
    fadeSeconds: 0.28,
    spring: { type: "spring", stiffness: 330, damping: 30 },
  },
};

const SAMPLE_ROWS = [
  { name: "Acme Studio", note: "Invoice paid", amount: "$1,240.00" },
  { name: "Northwind Supply", note: "Awaiting fulfilment", amount: "$318.75" },
  { name: "Bright Labs", note: "Subscription renewed", amount: "$49.00" },
  { name: "Harbor Freight Co", note: "Refund issued", amount: "-$86.20" },
];

export default function StaggeredListEntrance({
  variant = "default",
  children,
  staggerMs,
  width = 320,
  onComplete,
}: StaggeredListEntranceProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: every row still arrives, all at once and in place.
  // The sequence was pacing, not information.
  const lift = reduceMotion ? 0 : cfg.lift;
  const interval = reduceMotion ? 0 : (staggerMs ?? cfg.staggerMs) / 1000;
  const leadIn = reduceMotion ? 0 : cfg.leadIn;

  const list: Variants = {
    hidden: {},
    visible: {
      transition: { delayChildren: stagger(interval, { startDelay: leadIn }) },
    },
  };

  const row: Variants = {
    hidden: { opacity: 0, y: lift },
    visible: {
      opacity: 1,
      y: 0,
      transition: {
        y: cfg.spring,
        // Opacity runs on its own short curve; springing a fade only
        // makes the row look hesitant.
        opacity: {
          duration: reduceMotion ? 0.15 : cfg.fadeSeconds,
          ease: "easeOut",
        },
      },
    },
  };

  const rows: ReactNode[] =
    children != null
      ? Children.toArray(children)
      : SAMPLE_ROWS.map((sample) => (
          <SampleRow key={sample.name} {...sample} />
        ));

  return (
    <motion.ul
      // Safari drops list semantics from a list with no bullets, so the
      // role is restated rather than assumed.
      role="list"
      variants={list}
      initial="hidden"
      animate="visible"
      style={{
        width,
        listStyle: "none",
        margin: 0,
        padding: 0,
        display: "flex",
        flexDirection: "column",
        gap: 2,
      }}
    >
      {rows.map((content, index) => (
        <motion.li
          key={index}
          variants={row}
          onAnimationComplete={
            index === rows.length - 1 ? onComplete : undefined
          }
        >
          {content}
        </motion.li>
      ))}
    </motion.ul>
  );
}

/** Embedded sample so the component renders something real with zero
 *  props. Replace it by passing your own rows as `children`. */
function SampleRow({
  name,
  note,
  amount,
}: {
  name: string;
  note: string;
  amount: string;
}) {
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        gap: 12,
        padding: "9px 10px",
        borderRadius: 12,
      }}
    >
      <div
        style={{
          width: 30,
          height: 30,
          flexShrink: 0,
          borderRadius: 9,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          background: "rgba(124, 124, 240, 0.16)",
          color: "#7C7CF0",
          fontSize: 12.5,
          fontWeight: 700,
        }}
      >
        {name.slice(0, 1)}
      </div>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 13.5, fontWeight: 600, lineHeight: 1.3 }}>
          {name}
        </div>
        <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}>
          {note}
        </div>
      </div>
      <div
        style={{
          fontSize: 13,
          fontWeight: 600,
          fontVariantNumeric: "tabular-nums",
        }}
      >
        {amount}
      </div>
    </div>
  );
}

About this pattern

What a freshly fetched list should feel like. Each row fades in and lifts a few pixels, offset from the one above it by around fifty milliseconds — long enough for the eye to read a direction of travel, short enough that the whole group has settled well inside a second. The offset also gives the eye somewhere to land: it starts at the first row instead of taking in a wall of items that all appeared at once. Rows travel on a near-critically-damped spring, because anything springier turns text into jelly.

Search resultsInbox rowsActivity feedSettings groups

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    Restrained sequencing as rows settle after a page loads.

Related patterns