view-transitions
in progress

View Transitions

One API call turns a jarring theme switch into a sonar-ring burst. The browser does the heavy lifting.

#css #animation #ux

↳ Published 6 May 2026

View Transitions
// the write-up

Theme toggles are usually instantaneous — not because the web can’t do better, but because nobody bothers. document.startViewTransition makes “bothering” a one-liner.

Wrap your theme update in one call and the browser captures before/after snapshots, handing you ::view-transition-old(root) and ::view-transition-new(root) pseudo-elements to animate however you like.

The technique

The toggle fires two things in quick succession.

First, a sonar ring: a thin circle spawns at the button centre, expands outward, and fades over 350ms. It gives the click physical presence — something visibly radiates from exactly where you touched.

Then, at 200ms (while the ring is still visible), ::view-transition-new(root) expands from circle(0%) to circle(160%) centred on the same origin. The overlap matters: the ring feels like it summons the burst rather than just preceding it.

function triggerThemeTransition(updateTheme, buttonEl, targetIsDark) {
  const { left, top, width, height } = buttonEl.getBoundingClientRect();
  const ox = left + width  / 2;
  const oy = top  + height / 2;

  document.documentElement.style.setProperty("--vt-ox", `${ox}px`);
  document.documentElement.style.setProperty("--vt-oy", `${oy}px`);

  // inject ::view-transition rules dynamically, then clean up
  const style = document.createElement("style");
  style.textContent = `
    ::view-transition-new(root) {
      animation: vt-burst 550ms cubic-bezier(0.4,0,0.2,1) 200ms both;
    }
    ::view-transition-old(root) { animation: none; }
  `;
  document.head.appendChild(style);

  document
    .startViewTransition(updateTheme)
    .finished.then(() => style.remove());
}

Try the controls

The demo exposes the three timing parameters. The defaults — ring: 350ms, burst: 550ms, overlap: 200ms — are the sweet spot for a premium feel. Push the overlap to 0 and the stages feel disconnected. Push it past 250ms and the ring is gone before the burst even starts.

Reduced motion

If prefers-reduced-motion: reduce is set, the ring is skipped and the browser runs its default crossfade — no custom keyframes. Firefox, which doesn’t support startViewTransition yet, falls back to an instant swap.