# Cap Simultaneous Motion

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

> SHOULD: With 3+ elements, keep at most ~1/3 in active motion at any instant so the eye keeps an anchor: land a hero element first, then bring the rest in waves. This is NOT stagger — stagger sets when items START, this caps how many are MOVING. A 30ms stagger over 9 cards with a 220ms duration still has all 9 in flight; you need both rules.

Keep no more than a third of a group in active motion at any instant so the eye keeps an anchor

Nine cards that all scale, fade and slide on mount give the eye nothing to hold on to: the grid is perceived as a burst of noise that resolves, not as a reveal you can follow. Land a hero element first so there is an anchor, then bring the rest in waves — with a 220ms per-card duration and waves at 0/200/340/480ms, at most 3 of 9 are ever in flight and the cascade still finishes inside the 500ms stagger budget. DISTINCT FROM animations-emil-stagger, which prescribes a per-item DELAY (30–80ms) and says nothing about how many items overlap: a 30ms stagger across 9 cards with a 220ms duration still has all 9 moving at the same time, because item 9 starts at 240ms while item 1 is still animating. Stagger controls when things start; the 1/3 rule controls how many are moving at once. You need both, and satisfying one does not satisfy the other.

## Bad — do not do this

`animations-lottie-concurrency-cap-bad`

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

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';
const DURATION_MS = 220;

/** Every card starts at the same instant. Nine things move; nothing is the anchor. */
const DELAYS_MS = [0, 0, 0, 0, 0, 0, 0, 0, 0];
const TOTAL_MS = Math.max(...DELAYS_MS) + DURATION_MS + 100;

const CARDS = ['Revenue', 'Signups', 'Churn', 'Latency', 'Errors', 'Uptime', 'Sessions', 'Retention', 'NPS'];

/**
 * The cascade lives in its own component so a changing `key` remounts it — that is
 * what makes Replay start the enter transition over from scratch instead of trying
 * (and failing) to reset an already-entered grid in place.
 */
function Reveal() {
  const [entered, setEntered] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const peakRef = useRef(0);
  const [peak, setPeak] = useState(0);

  useEffect(() => {
    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);
          const flying = DELAYS_MS.filter((d) => t >= d && t < d + DURATION_MS).length;
          if (flying > peakRef.current) {
            peakRef.current = flying;
            setPeak(flying);
          }
          if (t < TOTAL_MS) raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      });
    });

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

  const inFlight = entered ? DELAYS_MS.filter((d) => elapsed >= d && elapsed < d + DURATION_MS).length : 0;

  return (
    <>
      <p className="text-xs tabular-nums text-muted-foreground">
        In motion now: <strong className="text-foreground">{inFlight}</strong>/9 · peak{' '}
        <strong className="text-destructive">{peak}</strong>/9
      </p>

      <div className="grid grid-cols-3 gap-2 rounded-lg border border-border bg-muted/40 p-3">
        {CARDS.map((label, i) => (
          <div
            key={label}
            className="rounded-md border border-border bg-card p-3 text-xs text-card-foreground"
            style={{
              transition: `transform ${DURATION_MS}ms ${EASE} ${DELAYS_MS[i]}ms, opacity ${DURATION_MS}ms ${EASE} ${DELAYS_MS[i]}ms`,
              transform: entered ? 'translateY(0) scale(1)' : 'translateY(10px) scale(0.94)',
              opacity: entered ? 1 : 0,
            }}
          >
            {label}
          </div>
        ))}
      </div>
    </>
  );
}

/**
 * Bad: all nine cards scale, fade and slide on mount. At the peak, 9 of 9 elements
 * are in active motion — the eye has nothing to hold on to, so the grid reads as a
 * burst of noise that then resolves, rather than as a reveal you can follow.
 */
export function LottieConcurrencyCapBad() {
  const [run, setRun] = useState(0);

  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 reveal
        </button>
      </div>

      <Reveal key={run} />

      <p className="text-xs text-destructive">
        Peak concurrency is 9 of 9. With three or more elements, keeping more than a third of them in flight at once
        gives the eye no anchor — the reveal is perceived as noise settling, not as an entrance.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-lottie-concurrency-cap-good`

```tsx
import { useEffect, useRef, 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 = 220;

/**
 * The hero lands alone, then the rest arrive in waves of three. Because a card is
 * only in flight for 220ms, at most 3 of 9 (one third) are ever moving at once,
 * and the whole cascade still finishes inside the 500ms stagger budget.
 */
const DELAYS_MS = [0, 200, 200, 200, 340, 340, 340, 480, 480];
const TOTAL_MS = Math.max(...DELAYS_MS) + DURATION_MS + 100;

const CARDS = ['Revenue', 'Signups', 'Churn', 'Latency', 'Errors', 'Uptime', 'Sessions', 'Retention', 'NPS'];

/**
 * The cascade lives in its own component so a changing `key` remounts it — that is
 * what makes Replay start the enter transition over from scratch instead of trying
 * (and failing) to reset an already-entered grid in place.
 */
function Reveal({ reduced }: { reduced: boolean }) {
  const [entered, setEntered] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const peakRef = useRef(0);
  const [peak, setPeak] = useState(0);

  useEffect(() => {
    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);
          const flying = DELAYS_MS.filter((d) => t >= d && t < d + DURATION_MS).length;
          if (flying > peakRef.current) {
            peakRef.current = flying;
            setPeak(flying);
          }
          if (t < TOTAL_MS) raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      });
    });

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

  const inFlight = entered ? DELAYS_MS.filter((d) => elapsed >= d && elapsed < d + DURATION_MS).length : 0;

  return (
    <>
      {reduced ? (
        <p className="text-xs text-muted-foreground">
          Reduced motion: every card appears at once with no travel. Concurrency is a motion problem, so with the
          motion removed there is nothing left to cap.
        </p>
      ) : (
        <p className="text-xs tabular-nums text-muted-foreground">
          In motion now: <strong className="text-foreground">{inFlight}</strong>/9 · peak{' '}
          <strong className="text-success">{peak}</strong>/9
        </p>
      )}

      <div className="grid grid-cols-3 gap-2 rounded-lg border border-border bg-muted/40 p-3">
        {CARDS.map((label, i) => (
          <div
            key={label}
            className="rounded-md border border-border bg-card p-3 text-xs text-card-foreground"
            style={
              reduced
                ? { transition: 'opacity 150ms linear', transform: 'none', opacity: entered ? 1 : 0 }
                : {
                    transition: `transform ${DURATION_MS}ms ${EASE} ${DELAYS_MS[i]}ms, opacity ${DURATION_MS}ms ${EASE} ${DELAYS_MS[i]}ms`,
                    transform: entered ? 'translateY(0) scale(1)' : 'translateY(10px) scale(0.94)',
                    opacity: entered ? 1 : 0,
                  }
            }
          >
            {label}
          </div>
        ))}
      </div>
    </>
  );
}

/**
 * Good: identical grid, identical per-card motion — only the concurrency changes.
 * The hero card lands first and becomes the anchor; the remaining cards enter in
 * waves so no more than a third of the grid is in active motion at any instant.
 */
export function LottieConcurrencyCapGood() {
  const [run, setRun] = useState(0);
  const reduced = useSimulatedReducedMotion();

  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 reveal
        </button>
        <ReducedMotionSwitch />
      </div>

      <Reveal key={run} reduced={reduced} />

      <p className="text-xs text-success">
        Peak concurrency is 3 of 9. The hero lands alone and anchors the eye, then waves of three follow — and the
        last card still starts at 480ms, inside the 500ms stagger budget.
      </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 — transition-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/transition-delay)
