Back to Code Bytes
3 min read
Text States Swap

Description

Text States Swap replaces a status label in place using a three-phase sequence: the old text exits upward with blur and fade, the new text is placed below the baseline with no transition, then the transition is released and the new text animates back to rest. Because the element stays in the DOM throughout, the surrounding layout never shifts. This makes it a good fit for button labels (“Save” to “Saved”), step indicators, or any brief copy that changes without a full component swap.

Example

Save

CSS

:root {
  --text-swap-dur: 150ms;
  --text-swap-translate-y: 4px;
  --text-swap-blur: 2px;
  --text-swap-ease: ease-in-out;
}

.t-text-swap {
  display: inline-block;
  transform: translateY(0);
  filter: blur(0);
  opacity: 1;
  transition:
    transform var(--text-swap-dur) var(--text-swap-ease),
    filter var(--text-swap-dur) var(--text-swap-ease),
    opacity var(--text-swap-dur) var(--text-swap-ease);
  will-change: transform, filter, opacity;
}
.t-text-swap.is-exit {
  transform: translateY(calc(var(--text-swap-translate-y) * -1));
  filter: blur(var(--text-swap-blur));
  opacity: 0;
}
.t-text-swap.is-enter-start {
  transform: translateY(var(--text-swap-translate-y));
  filter: blur(var(--text-swap-blur));
  opacity: 0;
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .t-text-swap {
    transition: none !important;
  }
}

React

import { useEffect, useRef } from "react";
import "./text-states-swap.css"; // paste the CSS above

const LABELS = ["Save", "Saving...", "Saved"];

export function TextStatesSwap() {
  const ref = useRef<HTMLSpanElement>(null);
  const idx = useRef(0);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  const swap = () => {
    window.clearTimeout(timer.current);
    const el = ref.current;
    if (!el) return;
    idx.current = (idx.current + 1) % LABELS.length;
    const next = LABELS[idx.current];
    const dur =
      parseFloat(
        getComputedStyle(document.documentElement).getPropertyValue(
          "--text-swap-dur",
        ),
      ) || 150;
    el.classList.add("is-exit");
    timer.current = window.setTimeout(() => {
      el.textContent = next;
      el.classList.remove("is-exit");
      el.classList.add("is-enter-start");
      void el.offsetHeight;
      el.classList.remove("is-enter-start");
    }, dur);
  };
  return (
    <div>
      <span ref={ref} className="t-text-swap">
        Save
      </span>
      <button onClick={swap}>Swap text</button>
    </div>
  );
}

Variables

VariableDefaultNotes
--text-swap-dur150mssourced from --p6-dur
--text-swap-translate-y4pxsourced from --p6-translate-y
--text-swap-blur2pxsourced from --p6-blur
--text-swap-easeease-in-outsourced from --p6-ease

Credit

Adapted from Text States Swap on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.