# Cap the Total Stagger

**MUST** · **ID:** `animations-lottie-stagger-budget` · **Category:** animations
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-stagger-budget

> MUST: Treat a cascade as a TOTAL budget, not a per-item constant: keep total stagger under 500ms (micro 20–40ms/item under 200ms; standard 50–100ms under 400ms; dramatic 100–200ms under 600ms for theatre only). Take the per-item value from `animations-emil-stagger` (30–80ms), take the CEILING from here — and on long lists the ceiling wins, because `i * 60ms` on 30 rows delays the last row 1740ms and pops content in below the scroll position. Clamp, or stagger only the rows in view.

Clamp a cascade to a total budget under 500ms instead of multiplying a per-item delay by the list length

A stagger is a budget, not a per-item constant. The budgets: micro cascade 20–40ms per item, total under 200ms; standard 50–100ms, under 400ms; dramatic 100–200ms, under 600ms — and the hard ceiling is 500ms of total stagger for anything that is interface rather than theatre. Write animationDelay: i * 60ms on a 30-row list and the last row does not begin moving until 1740ms after the first: the user has already scrolled past it, so the animation is still introducing content they have finished reading, and rows visibly pop in beneath the scroll position. Fix it by clamping — delay = Math.min(i * step, MAX_TOTAL) — or by only staggering the rows currently in view. COLLISION: animations-emil-stagger prescribes 30–80ms per item and states no cap, which ACTIVELY CAUSES this bug the moment the list is long. At 60ms per item the rule is safe up to about 8 rows and broken by 30. Take the per-item value from Emil, take the ceiling from here, and let the ceiling win.

## Rule snippet

```tsx
const MAX_TOTAL = 400;
const delay = Math.min(i * 60, MAX_TOTAL);
```

## Bad — do not do this

`animations-lottie-stagger-budget-bad`

```tsx
import { useEffect, useState } from 'react';

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';
const DURATION_MS = 200;
const ROW_COUNT = 30;

/** A per-item delay with no cap. Reasonable on 5 rows; a disaster on 30. */
const STEP_MS = 60;
const delayFor = (i: number) => i * STEP_MS;

const LAST_DELAY_MS = delayFor(ROW_COUNT - 1);

/**
 * Bad: `animationDelay: i * 60ms` on a 30-row list. Row 30 does not begin moving
 * until 1740ms after row 1 — by which time the user has already scrolled past it,
 * and rows are visibly popping in underneath their own scroll position.
 */
export function LottieStaggerBudgetBad() {
  const [run, setRun] = useState(0);
  const [entered, setEntered] = useState(false);
  const [elapsed, setElapsed] = useState(0);

  useEffect(() => {
    setEntered(false);
    setElapsed(0);

    let inner = 0;
    let raf = 0;
    const outer = requestAnimationFrame(() => {
      inner = requestAnimationFrame(() => {
        setEntered(true);
        const start = performance.now();
        const tick = () => {
          const t = performance.now() - start;
          setElapsed(t);
          if (t < LAST_DELAY_MS + DURATION_MS) raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      });
    });

    return () => {
      cancelAnimationFrame(outer);
      cancelAnimationFrame(inner);
      cancelAnimationFrame(raf);
    };
  }, [run]);

  const settled = elapsed >= LAST_DELAY_MS + DURATION_MS;

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setRun((r) => r + 1)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Replay list
        </button>
        <span className="text-xs tabular-nums text-muted-foreground">
          {settled ? 'settled' : 'filling'} · {Math.round(Math.min(elapsed, LAST_DELAY_MS + DURATION_MS))}ms
        </span>
      </div>

      <ul className="h-56 space-y-1 overflow-y-auto rounded-lg border border-border bg-muted/40 p-2">
        {Array.from({ length: ROW_COUNT }, (_, i) => (
          <li
            key={i}
            className="rounded-md bg-card px-2 py-1.5 text-xs text-card-foreground"
            style={{
              transition: `transform ${DURATION_MS}ms ${EASE} ${delayFor(i)}ms, opacity ${DURATION_MS}ms ${EASE} ${delayFor(i)}ms`,
              transform: entered ? 'translateY(0)' : 'translateY(6px)',
              opacity: entered ? 1 : 0,
            }}
          >
            Row {i + 1} <span className="text-muted-foreground">· starts at {delayFor(i)}ms</span>
          </li>
        ))}
      </ul>

      <p className="text-xs text-destructive">
        Uncapped cascade: row 30 does not start until {LAST_DELAY_MS}ms. Scroll while it plays and you will watch rows
        pop in beneath you — the animation is still introducing content the user has already read.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-lottie-stagger-budget-good`

```tsx
import { useEffect, useState } from 'react';
import { ReducedMotionSwitch } from '@/components/demo-kit/ReducedMotionSwitch';
import { useSimulatedReducedMotion } from '@/hooks/useSimulatedReducedMotion';

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';
const DURATION_MS = 200;
const ROW_COUNT = 30;

/**
 * The cascade is a budget, not a per-item constant. Pick the step you want, then
 * clamp the total: nothing may start later than MAX_TOTAL_MS after the first row.
 * A "micro cascade" over a long list means a small step and a hard ceiling.
 */
const STEP_MS = 24;
const MAX_TOTAL_MS = 400;
const delayFor = (i: number) => Math.min(i * STEP_MS, MAX_TOTAL_MS);

const LAST_DELAY_MS = delayFor(ROW_COUNT - 1);

/**
 * Good: identical rows, identical 200ms motion — the only change is that the
 * delay is clamped. Rows 1–17 cascade at 24ms apart, everything past that shares
 * the 400ms ceiling, so the list is fully settled well inside the 500ms budget.
 */
export function LottieStaggerBudgetGood() {
  const [run, setRun] = useState(0);
  const [entered, setEntered] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    setEntered(false);
    setElapsed(0);

    let inner = 0;
    let raf = 0;
    const outer = requestAnimationFrame(() => {
      inner = requestAnimationFrame(() => {
        setEntered(true);
        const start = performance.now();
        const tick = () => {
          const t = performance.now() - start;
          setElapsed(t);
          if (t < LAST_DELAY_MS + DURATION_MS) raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      });
    });

    return () => {
      cancelAnimationFrame(outer);
      cancelAnimationFrame(inner);
      cancelAnimationFrame(raf);
    };
  }, [run]);

  const settled = elapsed >= LAST_DELAY_MS + DURATION_MS;

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setRun((r) => r + 1)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Replay list
        </button>
        <ReducedMotionSwitch />
        {!reduced && (
          <span className="text-xs tabular-nums text-muted-foreground">
            {settled ? 'settled' : 'filling'} · {Math.round(Math.min(elapsed, LAST_DELAY_MS + DURATION_MS))}ms
          </span>
        )}
      </div>

      <ul className="h-56 space-y-1 overflow-y-auto rounded-lg border border-border bg-muted/40 p-2">
        {Array.from({ length: ROW_COUNT }, (_, i) => (
          <li
            key={i}
            className="rounded-md bg-card px-2 py-1.5 text-xs text-card-foreground"
            style={
              reduced
                ? { transition: 'opacity 150ms linear', transform: 'none', opacity: entered ? 1 : 0 }
                : {
                    transition: `transform ${DURATION_MS}ms ${EASE} ${delayFor(i)}ms, opacity ${DURATION_MS}ms ${EASE} ${delayFor(i)}ms`,
                    transform: entered ? 'translateY(0)' : 'translateY(6px)',
                    opacity: entered ? 1 : 0,
                  }
            }
          >
            Row {i + 1} <span className="text-muted-foreground">· starts at {delayFor(i)}ms</span>
          </li>
        ))}
      </ul>

      <p className="text-xs text-success">
        Clamped cascade: <code>Math.min(i * 24, 400)</code>. Row 30 starts at {LAST_DELAY_MS}ms instead of 1740ms, so
        the whole list settles inside the 500ms budget and no row arrives after the user has read it.
      </p>
    </div>
  );
}
```

## References

- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [LottieFiles — quality checklist](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/reference/quality-checklist.md)
- [MDN — animation-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-delay)
