Swap a character instead

Before a transition, try swapping a glyph or riding a path.

Emil Kowalski@emilkowalski
An 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.

Jun 5, 2026View on X ↗
Emil Kowalski@emilkowalski
The 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.

Aug 19, 2026View on X ↗

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 linear
Character 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
You Tab A Tab B Phone
el.animate([{ transform: from }, { transform: to }])
<animateMotion>Good
You Tab A Tab B Phone
<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, 240ms
Character swapGood
30 swaps/s

const 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.