# Overlap Only When the Slot Holds Still

**MUST** · **ID:** `animations-text-swap-mode-matches-layout` · **Category:** animations
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-text-swap-mode-matches-layout

> MUST: Pick the swap mode from whether the slot holds still, not from habit. Opacity/blur replacement in a fixed slot: crossfade with 100–300ms overlap. Anything that travels, pushes, or restacks layout: exit fully, then a 70–220ms micro-delay before enter — overlapping two moving phrases sends them through the same space on opposite vectors and reads as a glitch.

Crossfade a swap that replaces text in a fixed slot; run exit fully before enter when the effect travels, or the two phrases cross in mid-air

This is the boundary of animations-text-swap-overlap, and without it that rule gets over-applied. Read which of the catalog's specs set `overlap_ms: 0` and the pattern is exact: every effect that MOVES layout runs sequential — the kinetic builds that push a line sideways, the letter staircases that travel 46px, the line-by-line slide that carries a whole line 48px across. Four separate specs give the same reason in the same words: to avoid content intersections. Overlap is safe when both phrases are pinned in one slot and the only things changing are opacity and blur, because the layers sit exactly on top of each other and read as a single dissolve. The moment either phrase is travelling, the overlap window sends two moving strings through the same space on different vectors, and the eye cannot assign a glyph to a word — it reads as a glitch, which is worse than the hard cut you were avoiding. So the test is not "is this a swap" but "does the slot hold still": stable slot means crossfade with 100–300ms of overlap; moving layout means exit fully, then spend the budget on a micro-delay instead (70–220ms across the build effects) so the replacement still reads as a beat rather than a cut. Under `prefers-reduced-motion` both collapse to the same instant replacement.

## Rule snippet

```tsx
// stable slot (opacity/blur only) — overlap
const enterDelay = exitMs - 220;
// travelling / pushing layout — exit, then a beat
const enterDelay = exitMs + 70;
```

## Bad — do not do this

`animations-text-swap-mode-matches-layout-bad`

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

const PHRASES = ['Built for teams', 'Made for speed'];

const EXIT_MS = 420;
const ENTER_MS = 520;
// "Always overlap a swap" — applied to an effect that TRAVELS 48px. The two
// phrases are now in the same space on opposite vectors.
const OVERLAP_MS = 240;
const ENTER_DELAY_MS = EXIT_MS - OVERLAP_MS;

export function TextSwapModeMatchesLayoutBad() {
  const [index, setIndex] = useState(0);
  const [leaving, setLeaving] = useState<number | null>(null);

  useEffect(() => {
    if (leaving === null) return;
    const id = setTimeout(() => setLeaving(null), EXIT_MS);
    return () => clearTimeout(id);
  }, [leaving]);

  return (
    <div className="space-y-4">
      <button
        onClick={() => {
          setLeaving(index);
          setIndex((v) => (v + 1) % PHRASES.length);
        }}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Swap phrase
      </button>

      <div className="min-h-[6rem] flex items-center overflow-hidden rounded-lg bg-muted p-4">
        <span className="mode-bad-slot text-2xl font-medium">
          {PHRASES.map((phrase, i) => (
            <span
              key={phrase}
              className={
                i === index
                  ? 'mode-bad-layer mode-bad-enter'
                  : i === leaving
                    ? 'mode-bad-layer mode-bad-exit'
                    : 'mode-bad-layer invisible'
              }
            >
              {phrase}
            </span>
          ))}
        </span>
      </div>

      <style>{`
        .mode-bad-slot { display: inline-grid; }
        .mode-bad-layer { grid-area: 1 / 1; justify-self: start; white-space: nowrap; }
        .mode-bad-enter {
          animation: modeBadEnter ${ENTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1) ${ENTER_DELAY_MS}ms both;
        }
        .mode-bad-exit {
          animation: modeBadExit ${EXIT_MS}ms cubic-bezier(0.4, 0, 1, 1) both;
        }
        @keyframes modeBadEnter {
          from { opacity: 0; transform: translateX(-48px); }
          to { opacity: 1; transform: translateX(0); }
        }
        @keyframes modeBadExit {
          from { opacity: 1; transform: translateX(0); }
          to { opacity: 0; transform: translateX(48px); }
        }
      `}</style>

      <p className="text-xs text-destructive">
        Both phrases travel, so the {OVERLAP_MS}ms overlap sends them through the same space on
        opposite vectors — the glyphs interleave and the swap reads as a glitch, worse than
        the hard cut it was meant to avoid
      </p>
    </div>
  );
}
```

## Good — do this

`animations-text-swap-mode-matches-layout-good`

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

const PHRASES = ['Built for teams', 'Made for speed'];

const EXIT_MS = 420;
const ENTER_MS = 520;
// The effect travels, so the slot does NOT hold still: exit fully, then spend
// the budget on a beat instead of an overlap.
const MICRO_DELAY_MS = 70;
const ENTER_DELAY_MS = EXIT_MS + MICRO_DELAY_MS;

export function TextSwapModeMatchesLayoutGood() {
  const [index, setIndex] = useState(0);
  const [leaving, setLeaving] = useState<number | null>(null);

  useEffect(() => {
    if (leaving === null) return;
    const id = setTimeout(() => setLeaving(null), EXIT_MS);
    return () => clearTimeout(id);
  }, [leaving]);

  return (
    <div className="space-y-4">
      <button
        onClick={() => {
          setLeaving(index);
          setIndex((v) => (v + 1) % PHRASES.length);
        }}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Swap phrase
      </button>

      <div className="min-h-[6rem] flex items-center overflow-hidden rounded-lg bg-muted p-4">
        <span className="mode-good-slot text-2xl font-medium">
          {PHRASES.map((phrase, i) => (
            <span
              key={phrase}
              aria-hidden={i === index ? undefined : 'true'}
              className={
                i === index
                  ? 'mode-good-layer mode-good-enter'
                  : i === leaving
                    ? 'mode-good-layer mode-good-exit'
                    : 'mode-good-layer invisible'
              }
            >
              {phrase}
            </span>
          ))}
        </span>
      </div>

      <style>{`
        .mode-good-slot { display: inline-grid; }
        .mode-good-layer { grid-area: 1 / 1; justify-self: start; white-space: nowrap; }
        .mode-good-enter {
          animation: modeGoodEnter ${ENTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1) ${ENTER_DELAY_MS}ms both;
        }
        .mode-good-exit {
          animation: modeGoodExit ${EXIT_MS}ms cubic-bezier(0.4, 0, 1, 1) both;
        }
        @keyframes modeGoodEnter {
          from { opacity: 0; transform: translateX(-48px); }
          to { opacity: 1; transform: translateX(0); }
        }
        @keyframes modeGoodExit {
          from { opacity: 1; transform: translateX(0); }
          to { opacity: 0; transform: translateX(48px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .mode-good-enter, .mode-good-exit { animation: none; }
          .mode-good-exit { opacity: 0; }
        }
      `}</style>

      <p className="text-xs text-success">
        Exit finishes at {EXIT_MS}ms and the new phrase starts at {ENTER_DELAY_MS}ms — the{' '}
        {MICRO_DELAY_MS}ms beat keeps it reading as a deliberate replacement, and only one
        phrase is ever moving
      </p>
    </div>
  );
}
```

## References

- [animate-text — micro-scale-fade spec (overlap_ms: 0)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/micro-scale-fade.json)
- [animate-text — line-by-line-slide spec (travelling, sequential)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/line-by-line-slide.json)
- [Material Design — Shared axis transitions](https://m2.material.io/design/motion/the-motion-system.html)
