# A Hard Cut Is steps(), Not a Fast Fade

**MUST** · **ID:** `animations-hard-cut-uses-steps` · **Category:** animations
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-hard-cut-uses-steps

> MUST: Any effect whose identity is discreteness — typewriter, per-word hard cut, blinking caret — must use steps(1, end), not an eased opacity ramp. A fade puts every unit in a half-present state for its whole duration, so at a normal stagger several are ghosting at once and the line smears instead of typing. The rhythm belongs to the stagger; the duration is only a gate.

A typewriter or word-by-word cut needs a stepped easing — a 240ms opacity ramp per character is not typing, it is a smear resolving

Two of the catalog's 24 effects reach for `steps(1, end)`, and both are effects whose entire identity is discreteness. A typewriter is a machine setting one glyph at a time: the character is struck or it is not, and there is no state in between. Build it from a 240ms opacity ramp at a 46ms stagger and roughly five characters are half-present at any moment, so the line reads as a grey smear resolving rather than as typing — the effect you asked for is the one thing you cannot see. `steps(1, end)` collapses the whole duration into a single transition at its end, which is also why a stepped effect survives being retimed: the RHYTHM lives entirely in the stagger and the duration is just a gate. The same argument covers `shared-axis-y`, a staircase of per-word hard swaps — ease it and a deliberate edit becomes a cross-dissolve. This is the one place in this corpus where an instant transition is the craft rather than the shortcut, and it is not in tension with animations-emil-no-ease-in: that rule chooses among CONTINUOUS curves and has nothing to say about a transition with no in-between. A stepped reveal is still motion, so `prefers-reduced-motion` should still land the whole string at once.

## Rule snippet

```tsx
// bad — 240ms ramp, ~5 glyphs half-present at a 46ms stagger
animation: fade 240ms ease-out both;
// good — struck or absent, never in between
animation: type 240ms steps(1, end) both;
animationDelay: `${i * 46}ms`;
```

## Bad — do not do this

`animations-hard-cut-uses-steps-bad`

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

const LINE = 'Deploying to production…';
const STAGGER_MS = 46;
const DURATION_MS = 240;
// 240ms of fade at a 46ms stagger means ~5 characters are half-present at once.
const GHOSTS = Math.round(DURATION_MS / STAGGER_MS);

export function HardCutUsesStepsBad() {
  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 typing
      </button>

      <div className="min-h-[5rem] flex items-center rounded-lg bg-muted p-4">
        <p key={run} className="font-mono text-lg whitespace-pre-wrap">
          {LINE.split('').map((char, i) => (
            <span
              key={i}
              className="type-bad-char inline-block"
              style={{ animationDelay: `${i * STAGGER_MS}ms` }}
            >
              {char}
            </span>
          ))}
        </p>
      </div>

      <style>{`
        .type-bad-char {
          /* An opacity ramp: every glyph spends 240ms as a ghost. */
          animation: typeBadFade ${DURATION_MS}ms ease-out both;
        }
        @keyframes typeBadFade {
          from { opacity: 0; }
          to { opacity: 1; }
        }
      `}</style>

      <p className="text-xs text-destructive">
        Each character fades over {DURATION_MS}ms, so about {GHOSTS} of them are half-present
        at any moment — the line reads as a grey smear resolving, not as typing
      </p>
    </div>
  );
}
```

## Good — do this

`animations-hard-cut-uses-steps-good`

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

const LINE = 'Deploying to production…';
const STAGGER_MS = 46;
const DURATION_MS = 240;

export function HardCutUsesStepsGood() {
  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 typing
      </button>

      <div className="min-h-[5rem] flex items-center rounded-lg bg-muted p-4">
        {/* The glyph shards are decoration; the line itself stays one string. */}
        <p key={run} className="font-mono text-lg whitespace-pre-wrap">
          <span className="sr-only">{LINE}</span>
          {LINE.split('').map((char, i) => (
            <span
              key={i}
              aria-hidden="true"
              className="type-good-char inline-block"
              style={{ animationDelay: `${i * STAGGER_MS}ms` }}
            >
              {char}
            </span>
          ))}
        </p>
      </div>

      <style>{`
        .type-good-char {
          /* steps(1, end) collapses the duration into one transition at its end:
             the glyph is struck, never half-present. */
          animation: typeGoodStep ${DURATION_MS}ms steps(1, end) both;
        }
        @keyframes typeGoodStep {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        @media (prefers-reduced-motion: reduce) {
          .type-good-char { animation: none; }
        }
      `}</style>

      <p className="text-xs text-success">
        Every glyph is either struck or absent — the rhythm lives entirely in the {STAGGER_MS}ms
        stagger, which is why the effect still reads as typing at any speed
      </p>
    </div>
  );
}
```

## References

- [animate-text — typewriter spec (steps(1, end))](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/typewriter.json)
- [animate-text — shared-axis-y spec (per-word hard cut)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/shared-axis-y.json)
- [MDN — steps() easing function](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/easing-function/steps)
