Animating Theme Switches with View Transitions

Part of View Transition Types & Nested Groups in Modern View Transitions & Scroll APIs.

The problem

A site’s dark-mode toggle fades the whole interface between themes. It is implemented with transition: background-color 300ms, color 300ms, border-color 300ms on dozens of component selectors. On a large page the switch stutters for the full 300ms, some components change instantly because they lack the transition, SVG icons lag behind text, and the transitions leak into ordinary state changes, so every hover now fades its colours too.

A theme switch is a whole-page visual change. Treating it as hundreds of individual property transitions is the expensive way to do it.

Root cause analysis: repaint everything, every frame

Per-property colour transitions repaint the page repeatedly. Changing theme tokens changes the computed colours of most elements. With colour transitions on those elements, every frame of the 300ms repaints everything whose colour is interpolating — effectively the whole viewport, sixty times — as analysed in why transition: all is a performance trap.

A view transition repaints once. document.startViewTransition(() => setTheme('dark')) captures the page in the light theme as an image, applies the theme change — one style recalculation and one repaint — captures the page in the dark theme, then animates between the two images on the compositor. The component styles need no transitions at all, so nothing leaks into hover states, and every element, including SVG and canvas content, switches together because it is all part of the snapshot.

The default is a cross-fade of the root. With no named elements, the whole page is the root group, and the default animation cross-fades old into new. That alone replaces the colour transitions.

A reveal is a clip on the new snapshot. For the popular “circle expanding from the toggle” effect, keep the old snapshot static and animate clip-path: circle() on ::view-transition-new(root) from a radius of zero at the button to a radius that covers the viewport. Disable the default fade and blending on both snapshots so the new theme is fully opaque inside the circle.

Types keep the effect scoped. Passing types: ['theme'] lets the reveal rules apply only to theme switches, leaving page navigations with their own animations — see choosing types for route direction.

Main-thread paint during a dark mode switchContent-heavy page on a mid-range laptop; 300 ms effect.Main-thread paint during a dark mode switchColour transitions on components940 ms totalInstant switch, no animation34 ms totalView transition cross-fade41 ms totalView transition circular reveal43 ms total
Content-heavy page on a mid-range laptop; 300 ms effect.

Step-by-step resolution

Replacing colour transitions with a snapshot revealComponents lose their transitions; the root gains one effect.Replacing colour transitions with a snapshot reveal1Remove theme-only colour transitions from component styles.Hovers stop fading colours2Read the toggle button's centre and the farthest viewport corner distance.Reveal origin and radius3Write them to custom properties on the root element.CSS can read them4Start a view transition with type theme and flip data-theme in the callback.Old and new themes captured5Animate clip-path on the new root snapshot from 0 to the full radius.Circle expands from the button6Skip the transition under reduced motion.
Components lose their transitions; the root gains one effect.

Production code pattern

const reduce = matchMedia('(prefers-reduced-motion: reduce)');

themeToggle.addEventListener('click', () => {
  const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
  const apply = () => {
    document.documentElement.dataset.theme = next;
    themeToggle.setAttribute('aria-pressed', String(next === 'dark'));
  };

  if (!document.startViewTransition || reduce.matches) return apply();

  const r = themeToggle.getBoundingClientRect();
  const x = r.left + r.width / 2;
  const y = r.top + r.height / 2;
  const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
  const root = document.documentElement.style;
  root.setProperty('--reveal-x', `${x}px`);
  root.setProperty('--reveal-y', `${y}px`);
  root.setProperty('--reveal-r', `${Math.ceil(radius)}px`);

  document.startViewTransition({ update: apply, types: ['theme'] });
});
html:active-view-transition-type(theme)::view-transition-old(root),
html:active-view-transition-type(theme)::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;                         /* old stays solid; new is opaque inside the circle */
}

html:active-view-transition-type(theme)::view-transition-new(root) {
  animation: theme-reveal 480ms var(--ease-decelerate) both;
}

@keyframes theme-reveal {
  from { clip-path: circle(0 at var(--reveal-x) var(--reveal-y)); }
  to   { clip-path: circle(var(--reveal-r) at var(--reveal-x) var(--reveal-y)); }
}

@media (prefers-reduced-motion: reduce) {
  html::view-transition-old(root),
  html::view-transition-new(root) { animation: none; }
}

Rendering Impact: one style recalculation and one paint for the theme itself; the reveal animates clip-path on a single root snapshot image. Clip-path animation on the snapshot may involve paint in some engines, but it is one image, not hundreds of elements.

The custom properties are set on document.documentElement before starting the transition, so the pseudo-elements, which live under the root element, inherit them. Because the keyframes reference var() values, each switch can reveal from wherever the toggle currently is, including a toggle inside a menu.

Component colour transitions against a root snapshotSame visual idea; very different work.Component colour transitions against a root snapshottransition on color and backgroundeverywhereEvery component repaints per frameSVG and canvas lag or snapColour fades leak into hoversSlow and inconsistentstartViewTransition + root animationTheme applied in one style passWhole page switches togetherNo component transitions neededFast and uniform
Same visual idea; very different work.

Persisting and syncing the theme

The transition is purely presentational; the theme choice still needs to persist and to follow the operating system on first visit. Write the choice to storage inside the update callback, and apply the stored or preferred theme before first paint with a small blocking script, so returning visitors never see the wrong theme flash in. When the operating system preference changes while the page is open, apply it without a view transition — an unrequested full-page reveal is surprising. Only user-initiated switches should animate.

Verification checklist

Constraints and trade-offs

  • Interaction is paused during the transition; keep it under about half a second.
  • Browsers without view transitions switch instantly, which is an acceptable fallback.
  • The root snapshot covers the viewport, so large screens capture large textures.
  • Elements with view-transition-name elsewhere on the page become separate groups and need their own theme handling.
  • Very bright circular reveals on dark backgrounds can be uncomfortable; consider a cross-fade as the default.

Frequently asked questions

Why is my dark mode transition slow?

Colour transitions on many components repaint large areas every frame. A view transition applies the theme once and animates between two snapshots instead.

How do I make a circular theme reveal?

Start a view transition, disable the default snapshot animations, and animate clip-path: circle() on ::view-transition-new(root) from zero radius at the toggle to a radius covering the viewport.

Will the theme reveal affect page navigation transitions?

Not if you pass a theme type when starting the transition and scope the rules with :active-view-transition-type(theme).

Should theme changes from the operating system animate?

No. Apply them instantly; only user-initiated switches should animate.