# Reveal Blur Is Priced by Type Size

**SHOULD** · **ID:** `animations-text-reveal-scales-with-type` · **Category:** animations
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-text-reveal-scales-with-type

> SHOULD: Reprice a text reveal for the type size it lands on. Hero (48px+): blur up to 12px, 25ms stagger, per-character. Body (<24px): blur 6px, 15ms stagger, per-word. Never copy a hero preset onto body copy — an absolute blur radius that softens a 5px stem erases a 1.5px one.

A 12px reveal blur that reads as premium on a 48px hero erases 16px body text — halve the blur and the stagger with the type

Blur radius is in absolute pixels; the stroke width of a glyph is not. On a 48px headline the stems are roughly 5px wide, so a 12px blur displaces each edge by about two stem widths — the letter is soft but still a letter, which is exactly the Apple-keynote effect the spec is after. Drop the same 12px onto 16px body text whose stems are nearer 1.5px and the glyph is gone: not blurred, erased, for most of the 900ms it takes to resolve. The catalog therefore halves both knobs below 24px, blur to 6 and stagger to 15, and that pairing is deliberate — smaller type means more units per line, so the cascade has to tighten as well. IMPORTANT — 12px is over the ≤8px ceiling in performance-ibelick-no-blur-animation, and it is licensed rather than exempt: that rule prices radius, AREA and duration together, and a hero title is one short strip of text animating once, small in two of the three variables. Body text is where you lose both arguments at the same time — you are over the performance threshold and under the legibility one. The 300ms ceiling in animations-emil-duration-budget does not bind a 900ms hero reveal either, because that rule exempts marketing and explanatory motion as content; it binds again the moment the effect is applied to interface text like a label, a table cell, or a toast.

## Rule snippet

```tsx
const HERO = { blurPx: 12, staggerMs: 25, unit: "char" };
const BODY = { blurPx: 6,  staggerMs: 15, unit: "word" };
```

## Bad — do not do this

`animations-text-reveal-scales-with-type-bad`

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

const HERO = 'Ship faster';
const BODY = 'Every component ships with tokens, tests, and documentation.';

// The reflex: one preset, copy-pasted. The hero numbers (12px blur, 25ms
// stagger, per-character) get applied to body copy without repricing anything.
const BLUR_PX = 12;
const STAGGER_MS = 25;
const DURATION_MS = 900;

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

  const shards = (text: string) =>
    text.split('').map((char, i) => (
      <span
        key={i}
        className="reveal-bad-char inline-block"
        style={{ animationDelay: `${i * STAGGER_MS}ms` }}
      >
        {char}
      </span>
    ));

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

      <div key={run} className="space-y-3 rounded-lg bg-muted p-4 min-h-[9rem]">
        <p className="text-4xl font-semibold leading-tight whitespace-pre-wrap">{shards(HERO)}</p>
        <p className="text-base leading-relaxed text-muted-foreground whitespace-pre-wrap">{shards(BODY)}</p>
      </div>

      <style>{`
        .reveal-bad-char {
          animation: revealBad ${DURATION_MS}ms cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        @keyframes revealBad {
          from { opacity: 0; transform: translateY(16px); filter: blur(${BLUR_PX}px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
      `}</style>

      <p className="text-xs text-destructive">
        {BLUR_PX}px of blur is about two stem widths on the heading but several times the
        stem width of 16px body text — the paragraph is not softened, it is erased for most
        of the {(DURATION_MS / 1000).toFixed(1)}s it takes each letter to resolve
      </p>
    </div>
  );
}
```

## Good — do this

`animations-text-reveal-scales-with-type-good`

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

const HERO = 'Ship faster';
const BODY = 'Every component ships with tokens, tests, and documentation.';

// Two presets, because the blur radius is absolute pixels and the stem width of
// the glyph is not. Below 24px the blur halves and the stagger tightens — and
// the unit coarsens, because smaller type means more units per line.
const HERO_PRESET = { blurPx: 12, staggerMs: 25, durationMs: 900 };
const BODY_PRESET = { blurPx: 6, staggerMs: 15, durationMs: 600 };

export function TextRevealScalesWithTypeGood() {
  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 reveal
      </button>

      <div key={run} className="space-y-3 rounded-lg bg-muted p-4 min-h-[9rem]">
        {/* pre-wrap keeps the space characters that per-character splitting turns
            into their own collapsible inline-blocks. */}
        <p className="text-4xl font-semibold leading-tight whitespace-pre-wrap">
          <span className="sr-only">{HERO}</span>
          {HERO.split('').map((char, i) => (
            <span
              key={i}
              aria-hidden="true"
              className="reveal-hero inline-block"
              style={{ animationDelay: `${i * HERO_PRESET.staggerMs}ms` }}
            >
              {char}
            </span>
          ))}
        </p>

        <p className="text-base leading-relaxed text-muted-foreground">
          <span className="sr-only">{BODY}</span>
          {BODY.split(' ').map((word, i, all) => (
            // Word gaps live between the tokens so the paragraph can still wrap.
            <Fragment key={i}>
              <span
                aria-hidden="true"
                className="reveal-body inline-block"
                style={{ animationDelay: `${i * BODY_PRESET.staggerMs}ms` }}
              >
                {word}
              </span>
              {i < all.length - 1 ? ' ' : null}
            </Fragment>
          ))}
        </p>
      </div>

      <style>{`
        .reveal-hero {
          animation: revealHero ${HERO_PRESET.durationMs}ms cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        .reveal-body {
          animation: revealBody ${BODY_PRESET.durationMs}ms cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        @keyframes revealHero {
          from { opacity: 0; transform: translateY(16px); filter: blur(${HERO_PRESET.blurPx}px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @keyframes revealBody {
          from { opacity: 0; transform: translateY(8px); filter: blur(${BODY_PRESET.blurPx}px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .reveal-hero, .reveal-body { animation: none; }
        }
      `}</style>

      <p className="text-xs text-success">
        {HERO_PRESET.blurPx}px / {HERO_PRESET.staggerMs}ms per character on the heading,{' '}
        {BODY_PRESET.blurPx}px / {BODY_PRESET.staggerMs}ms per word on the body — both stay
        legible the whole way through, and the paragraph resolves in a third of the time
      </p>
    </div>
  );
}
```

## References

- [animate-text — soft-blur-in spec (usage_notes)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/soft-blur-in.json)
- [animate-text — focus-blur-resolve spec](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/focus-blur-resolve.json)
- [MDN — filter: blur()](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Functions/blur)
