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
| Variable | Default | Notes |
|---|---|---|
--text-swap-dur | 150ms | sourced from --p6-dur |
--text-swap-translate-y | 4px | sourced from --p6-translate-y |
--text-swap-blur | 2px | sourced from --p6-blur |
--text-swap-ease | ease-in-out | sourced from --p6-ease |
Credit
Adapted from Text States Swap on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.