View Transitions
One API call turns a jarring theme switch into a sonar-ring burst. The browser does the heavy lifting.
↳ Published 6 May 2026
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.