# Overlap a Text Swap; Never Hard-Cut the Slot

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

> MUST: Rotating/replaced text must overlap its exit and enter (100–300ms, or at minimum a 28–85ms micro-delay) so no frame shows an empty slot, and both layers must share one grid cell (grid-area: 1/1) sized to the longest string so surrounding content never reflows. A looping swap also needs a pause control and a reduced-motion path.

A rotating word must start entering before the old one has finished leaving, in a slot sized so the line around it never moves

The "Build ___ faster" rotating headline is usually a `setInterval` that swaps a string, and it fails twice on the same frame. First the cut: with no overlap there is a moment where the old word is gone and the new one has not arrived, and an empty slot reads as a bug, not a transition. The catalog overlaps 100–300ms on crossfade effects (soft-blur-in 300, mask-reveal-up 210, per-word-crossfade 170) and — this is the part that gets skipped — even the effects with `overlap_ms: 0` still insert a `micro_delay_ms` of 28–85ms rather than cutting at exactly t=0, because the beat is what makes a replacement read as choreography instead of a glitch. Second the reflow: mount both layers in normal flow during the overlap and the line doubles in height, then snaps back. Stack them in one grid cell instead — a `grid` container with both children on `grid-area: 1 / 1` — which also sizes the slot to the WIDEST string automatically, so the words on either side stop shuffling every four seconds. Reserving that width is the same argument as performance-no-image-cls, one text node down. And a swap that loops is looping text: content-moving-text-can-be-paused still applies, so it needs a reduced-motion path and a way to stop it.

## Rule snippet

```tsx
// bad — hard cut, slot resizes every tick
setInterval(() => setI(i => (i + 1) % words.length), 2000)
// good — stacked layers, enter starts before exit ends
<span style={{ display: "inline-grid" }}>
  {words.map((w, i) => (
    <span key={w} style={{ gridArea: "1 / 1" }} className={i === index ? "enter" : i === leaving ? "exit" : "invisible"}>{w}</span>
  ))}
</span>
```

## Bad — do not do this

`animations-text-swap-overlap-bad`

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

const WORDS = ['faster', 'smarter', 'collaboratively', 'together'];

export function TextSwapOverlapBad() {
  const [i, setI] = useState(0);

  // The reflex: an interval that replaces the string. There is no exit, no
  // overlap, and no reserved slot — `transition` cannot animate a text node
  // being swapped out from under it, so the "animation" never runs at all.
  useEffect(() => {
    const id = setInterval(() => setI((v) => (v + 1) % WORDS.length), 1600);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="space-y-4">
      <div className="min-h-[7rem] flex items-center rounded-lg bg-muted p-4">
        <h3 className="text-2xl font-medium">
          Ship{' '}
          <span
            className="text-primary"
            style={{ transition: 'all 0.3s ease-in-out' }}
          >
            {WORDS[i]}
          </span>{' '}
          every day
        </h3>
      </div>

      <p className="text-xs text-destructive">
        The word hard-cuts with no overlap, and because the slot is only as wide as the
        current string, &ldquo;every day&rdquo; jumps sideways on every tick — with no way to
        pause it
      </p>
    </div>
  );
}
```

## Good — do this

`animations-text-swap-overlap-good`

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

const WORDS = ['faster', 'smarter', 'collaboratively', 'together'];

const EXIT_MS = 400;
const ENTER_MS = 500;
const OVERLAP_MS = 220;
// The new word starts entering before the old one has finished leaving, so no
// frame ever shows an empty slot.
const ENTER_DELAY_MS = EXIT_MS - OVERLAP_MS;
const HOLD_MS = 1600;

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

  // Read the live index without re-creating the interval on every swap, so the
  // rotation keeps a steady beat. A state updater must stay pure — setting
  // `leaving` from inside one fires twice under StrictMode's double-invoke.
  const indexRef = useRef(index);
  indexRef.current = index;

  useEffect(() => {
    if (!playing) return;
    const id = setInterval(() => {
      setLeaving(indexRef.current);
      setIndex((current) => (current + 1) % WORDS.length);
    }, HOLD_MS + EXIT_MS);
    return () => clearInterval(id);
  }, [playing]);

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

  return (
    <div className="space-y-4">
      <div className="min-h-[7rem] flex items-center rounded-lg bg-muted p-4">
        <h3 className="text-2xl font-medium">
          Ship{' '}
          {/* Every word lives in the SAME grid cell, so the slot is always as wide as
              the longest string and the words around it never move. */}
          <span className="swap-slot text-primary">
            {WORDS.map((word, i) => (
              <span
                key={word}
                className={
                  i === index
                    ? 'swap-layer swap-enter'
                    : i === leaving
                      ? 'swap-layer swap-exit'
                      : 'swap-layer invisible'
                }
                aria-hidden={i === index ? undefined : 'true'}
              >
                {word}
              </span>
            ))}
          </span>{' '}
          every day
        </h3>
      </div>

      <button
        onClick={() => setPlaying((v) => !v)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        {playing ? 'Pause rotation' : 'Resume rotation'}
      </button>

      <style>{`
        .swap-slot {
          display: inline-grid;
          vertical-align: bottom;
        }
        .swap-layer {
          grid-area: 1 / 1;
          justify-self: center;
        }
        .swap-enter {
          animation: swapEnter ${ENTER_MS}ms cubic-bezier(0.22, 1, 0.36, 1) ${ENTER_DELAY_MS}ms both;
        }
        .swap-exit {
          animation: swapExit ${EXIT_MS}ms cubic-bezier(0.4, 0, 1, 1) both;
        }
        @keyframes swapEnter {
          from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @keyframes swapExit {
          from { opacity: 1; transform: translateY(0); filter: blur(0); }
          to { opacity: 0; transform: translateY(-10px); filter: blur(4px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .swap-enter, .swap-exit { animation: none; }
          .swap-exit { opacity: 0; }
        }
      `}</style>

      <p className="text-xs text-success">
        The outgoing word starts leaving at 0ms and the incoming one enters at{' '}
        {ENTER_DELAY_MS}ms, so the two overlap for {OVERLAP_MS}ms — and the grid-stacked slot
        holds the width of the longest word throughout
      </p>
    </div>
  );
}
```

## References

- [animate-text — soft-blur-in swap.scenario_spec](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/soft-blur-in.json)
- [animate-text — SKILL.md](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/SKILL.md)
- [MDN — grid-area](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/grid-area)
