# A Stagger Below One Frame Is Not a Stagger

**MUST** · **ID:** `animations-stagger-floor-one-frame` · **Category:** animations
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-stagger-floor-one-frame

> MUST: Never set a per-unit stagger below ~16ms. One frame at 60Hz is 16.7ms, so a smaller delay quantizes multiple units onto the same paint and the cascade renders as a flash — you ship N elements and N animations for nothing. Hold the 16ms floor even though a 120Hz panel could resolve less, or the effect differs between displays. Floor 16ms, per-unit 22–95ms by unit type, total under 500ms.

Per-unit delays under ~16ms quantize onto the same frame — the cascade flattens into a flash no matter what number you typed

The catalog's floor is not taste, it is the refresh rate. At 60Hz a frame is 16.7ms, so an 8ms stagger means the first two units begin on the same frame and the browser physically cannot render the difference you asked for. The cascade does not get subtler, it disappears — you pay the full cost of splitting the string, shipping N elements and N animations, and get a flash. That is why the catalog's tightest per-character stagger is 22ms and its stated floor is 16ms, one frame. The tempting objection is that a 120Hz panel halves the frame to 8ms and would render it: that makes the situation worse, not better, because the effect then differs between the reviewer's laptop and the user's phone. Hold 16ms regardless of the display. The other end of the window is animations-lottie-stagger-budget's 500ms total ceiling, and the pair defines the whole usable range: N units must fit between one frame each and half a second overall. When N is large enough that those two constraints cross, the answer is a coarser unit (animations-text-split-granularity), never a smaller delay. Distinct from animations-frame-budget, which is about how much WORK fits inside a frame — this is about the smallest DELAY a frame can express.

## Rule snippet

```tsx
// bad — 8ms is half a frame; letters 1-2 start together
const STAGGER_MS = 8;
// good — clears the frame floor, total stays under the 500ms ceiling
const STAGGER_MS = 24; // >= 16.7ms (one frame at 60Hz)
// if units * STAGGER_MS > 500, coarsen the unit — do not shrink the delay
```

## Bad — do not do this

`animations-stagger-floor-one-frame-bad`

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

const WORD = 'Cascading';
// "Subtle" was the intent. 8ms is half a frame at 60Hz, so the browser starts
// letters 1 and 2 on the same paint and the staircase never exists.
const STAGGER_MS = 8;
const DURATION_MS = 400;
const FRAME_MS = 16.7;

export function StaggerFloorOneFrameBad() {
  const [run, setRun] = useState(0);

  return (
    <div className="space-y-4">
      <button
        onClick={() => setRun((v) => v + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Replay cascade
      </button>

      <div className="min-h-[6rem] flex items-center rounded-lg bg-muted p-4">
        <p key={run} className="text-3xl font-semibold">
          {WORD.split('').map((char, i) => (
            <span
              key={i}
              className="floor-bad-char inline-block"
              style={{ animationDelay: `${i * STAGGER_MS}ms` }}
            >
              {char}
            </span>
          ))}
        </p>
      </div>

      <style>{`
        .floor-bad-char {
          animation: floorRise ${DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
        }
        @keyframes floorRise {
          from { opacity: 0; transform: translateY(20px); }
          to { opacity: 1; transform: translateY(0); }
        }
      `}</style>

      <p className="text-xs text-destructive">
        {STAGGER_MS}ms is under half a frame ({FRAME_MS}ms at 60Hz), so{' '}
        {Math.ceil(FRAME_MS / STAGGER_MS)} letters start on every paint — all{' '}
        {WORD.length} shards and {WORD.length} animations are shipped to render a flash
      </p>
    </div>
  );
}
```

## Good — do this

`animations-stagger-floor-one-frame-good`

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

const WORD = 'Cascading';
// 24ms clears the 16ms one-frame floor, and the resulting total stagger is well
// inside the 500ms ceiling. Both ends of the window are satisfied.
const STAGGER_MS = 24;
const DURATION_MS = 400;
const TOTAL_STAGGER_MS = (WORD.length - 1) * STAGGER_MS;

export function StaggerFloorOneFrameGood() {
  const [run, setRun] = useState(0);

  return (
    <div className="space-y-4">
      <button
        onClick={() => setRun((v) => v + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Replay cascade
      </button>

      <div className="min-h-[6rem] flex items-center rounded-lg bg-muted p-4">
        <p key={run} className="text-3xl font-semibold">
          <span className="sr-only">{WORD}</span>
          {WORD.split('').map((char, i) => (
            <span
              key={i}
              aria-hidden="true"
              className="floor-good-char inline-block"
              style={{ animationDelay: `${i * STAGGER_MS}ms` }}
            >
              {char}
            </span>
          ))}
        </p>
      </div>

      <style>{`
        .floor-good-char {
          animation: floorRiseGood ${DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
        }
        @keyframes floorRiseGood {
          from { opacity: 0; transform: translateY(20px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .floor-good-char { animation: none; }
        }
      `}</style>

      <p className="text-xs text-success">
        {STAGGER_MS}ms is above the one-frame floor so every letter gets its own paint, and{' '}
        {TOTAL_STAGGER_MS}ms of total stagger stays inside the 500ms ceiling — a real
        staircase, not a flash
      </p>
    </div>
  );
}
```

## References

- [animate-text — per-character-rise spec (16ms floor)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/per-character-rise.json)
- [animate-text — stagger-from-center spec (22ms, tightest)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/stagger-from-center.json)
- [MDN — requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)
