Swap a character instead
Before a transition, try swapping a glyph or riding a path.
Emil Kowalski@emilkowalskiAn animaton doesn't always have to involve some CSS transition or keyframes, it can also be as simple as swapping a character like we did for Linear's /next page:
Shown in Demo 1: an ASCII progress bar that fills by swapping characters, next to a smooth CSS bar. Demo 3 swaps the characters of text you type.
Emil Kowalski@emilkowalskiThe way it works is that since each wire in the frame is a path, we can reuse that same path as a track and make the numbered chips move along it with SVG's <animateMotion> element. It also uses some ease-in-out easing.
Shown in Demo 2: numbered chips riding each wire with <animateMotion> and ease-in-out, next to chips translated in a straight line.
From emil-design-eng and Linear’s /next page by @pdotcv.
An ASCII progress bar
Press Run, then try another glyph set.
CSS transitionGeneric
transition: transform 1440ms linearCharacter swapGood
bar.textContent = "█".repeat(n) + "░".repeat(16 - n)const SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
let step = 0;
function tick() {
step = Math.min(16, step + 1);
spin.textContent = step === 16 ? "✓" : SPIN[step % SPIN.length];
bar.textContent = "█".repeat(step) + "░".repeat(16 - step);
pct.textContent = `${Math.round((step / 16) * 100)}%`.padStart(4);
if (step < 16) setTimeout(tick, 90);
}
Chips that ride the wires
Press Send a few times, fast.
translate(start → end)Bad
el.animate([{ transform: from }, { transform: to }])<animateMotion>Good
<mpath href="#wire"/>keySplines="0.77 0 0.175 1"<path id="wire-a" d="M76 90 H150 Q160 90 160 80 V40 Q160 30 170 30 H244"/>
<g class="chip">
<rect x="-11" y="-8" width="22" height="16" rx="8"/>
<text>7</text>
<!-- the wire is the track -->
<animateMotion dur="1100ms" fill="freeze" begin="indefinite"
calcMode="spline" keyTimes="0;1" keyPoints="0;1"
keySplines="0.77 0 0.175 1">
<mpath href="#wire-a"/>
</animateMotion>
</g>
// JS: append the chip, then
chip.querySelector("animateMotion").beginElement();
Text that changes
Type in the box, or press Next.
Crossfade + slideGeneric
opacity + translateY, 240msCharacter swapGood
30 swaps/sconst GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/";
function setText(next) {
let k = 0;
for (let i = 0; i < Math.max(next.length, shown.length); i++) {
const t = next[i] ?? "";
if (t !== target[i]) left[i] = FRAMES + k++; // stagger changed chars only
target[i] = t;
}
requestAnimationFrame(frame);
}
function frame() { // throttled to ~30 swaps per second
shown = shown.map((c, i) => {
if (!left[i]) return c;
left[i]--;
return left[i] ? GLYPHS[(Math.random() * GLYPHS.length) | 0] : target[i];
});
el.textContent = shown.join("");
}
Why it matters, pros and cons
Why it matters
A fade looks the same anywhere. A swapped glyph uses the drawing’s own material.
Pros
- Nothing to interpolate, no layout work.
- A monospace grid stays on the grid.
- The chip can never leave its wire.
Cons
- Stepped, not smooth: panels and toasts still need transforms.
- Glyphs need one width; watch font fallback.
- SMIL ignores reduced motion; handle it yourself.