Animating Theme Changes with View Transitions

Part of Theme Transition Motion & Performance. This guide replaces a per-element CSS transition on a theme swap with a single document-level cross-fade driven by the View Transition API, and keeps a working path for engines that do not support it.

How a view transition cross-fades a theme change Three stages: a snapshot of the old theme is captured, the DOM update applies the new theme, and the two snapshots cross-fade on the compositor as a pair of pseudo-elements. 1 · capture old frame becomes ::view-transition-old rendering is paused 2 · update callback sets data-theme styles recalculate once nothing is shown yet 3 · cross-fade old and new snapshots animate on the compositor cost is independent of DOM size Why the cost is flat Two bitmaps are blended by the GPU. A thousand elements changing colour costs the same as ten, because none of them animate.
The API turns a per-element animation problem into a two-layer blend, which is why the cost stops scaling with the size of the page.

Prerequisites Link to this section

  • A theme implemented as a value swap on a root attribute — see runtime theme switching for the state model this assumes.
  • Theme blocks limited to paint tokens, so the swap does not invalidate layout.
  • A no-flash inline resolver in <head> that sets the initial theme before first paint.
  • A build that can ship a small amount of feature-detected JavaScript; the fallback path must work with the script absent.
  • Playwright or an equivalent headless browser for the verification step.

Step-by-Step Implementation Link to this section

Step 1 — Wrap the theme commit in a view transition Link to this section

Intent: route the attribute change through startViewTransition where the API exists, and fall back to a direct commit where it does not.

// theme.js
function commitTheme(next) {
  document.documentElement.setAttribute('data-theme', next);
  try { localStorage.setItem('theme', next); } catch (_) {}
}

export function setTheme(next) {
  if (!document.startViewTransition) {
    commitTheme(next);            // no API: instant swap, still correct
    return;
  }
  document.startViewTransition(() => commitTheme(next));
}

Why this works: startViewTransition takes a callback, captures the current rendering before invoking it, applies the DOM change, captures the result, and animates between the two. The callback itself is synchronous and does exactly what the non-API path does, so there is a single definition of “what changing the theme means” and the transition is purely additive.

Step 2 — Name the transition so it can be styled independently Link to this section

Intent: give this transition its own name, so the theme cross-fade can be tuned without affecting view transitions used elsewhere for navigation.

export function setTheme(next) {
  if (!document.startViewTransition) { commitTheme(next); return; }

  document.documentElement.dataset.transition = 'theme';
  const t = document.startViewTransition(() => commitTheme(next));
  t.finished.finally(() => { delete document.documentElement.dataset.transition; });
}

Why this works: the data attribute is present for exactly the duration of the transition, so a stylesheet can scope its animation rules to html[data-transition="theme"]. Using finished rather than a timeout means the attribute is removed when the animation genuinely ends, including when it is interrupted by a second toggle.

Step 3 — Style the cross-fade Link to this section

Intent: replace the default cross-fade with a slightly longer, symmetric one, and make sure both snapshots are blended rather than one sliding over the other.

Midpoint luminance with plus-lighter blending versus explicit opacity Two rows sample the same cross-fade at four points. With the default additive blend the midpoint is near white; with explicit opacity keyframes the midpoint sits evenly between the two themes. Blend modes across the fade, sampled at four points default plus-lighter — the two frames add together 0% dark 33% 50% — bright flash 100% light explicit opacity keyframes, normal blending 0% dark 33% 50% — even midpoint 100% light The flash only appears when the two frames differ in luminance, which is exactly what a theme change is.
The default blend mode is tuned for transitions between similar frames. A theme change inverts luminance, which is the one case where additive blending is visibly wrong.
html[data-transition="theme"]::view-transition-old(root),
html[data-transition="theme"]::view-transition-new(root) {
  animation-duration: 220ms;
  animation-timing-function: ease;
  mix-blend-mode: normal;
}

/* Both layers fully occupy the viewport during the blend. */
html[data-transition="theme"]::view-transition-old(root) {
  animation-name: theme-fade-out;
}
html[data-transition="theme"]::view-transition-new(root) {
  animation-name: theme-fade-in;
}

@keyframes theme-fade-out { from { opacity: 1 } to { opacity: 0 } }
@keyframes theme-fade-in  { from { opacity: 0 } to { opacity: 1 } }

Why this works: the default user-agent animation already cross-fades, but it also applies mix-blend-mode: plus-lighter, which is designed for transitions where the two frames are largely identical. A light-to-dark theme change inverts most of the luminance, and plus-lighter blending produces a visible bright flash midway through. Explicit opacity keyframes with normal blending avoid it.

Step 4 — Honour reduced motion before anything else Link to this section

Intent: a visitor who has asked for reduced motion should get an instant swap, not a shorter animation.

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

export function setTheme(next) {
  if (reducedMotion.matches || !document.startViewTransition) {
    commitTheme(next);
    return;
  }
  document.documentElement.dataset.transition = 'theme';
  const t = document.startViewTransition(() => commitTheme(next));
  t.finished.finally(() => { delete document.documentElement.dataset.transition; });
}
@media (prefers-reduced-motion: reduce) {
  html[data-transition="theme"]::view-transition-old(root),
  html[data-transition="theme"]::view-transition-new(root) {
    animation: none;
  }
}

Why this works: the check is duplicated deliberately. The JavaScript guard skips the API entirely, which is the cheapest possible path. The CSS guard covers the case where a transition was started before the preference changed, or where the script and the stylesheet disagree because one of them was cached. Neither guard alone is sufficient in practice.

Step 5 — Keep the scoped CSS fallback for engines without the API Link to this section

Intent: engines that lack startViewTransition should still get a softened change on large surfaces rather than a hard flash.

@supports not (view-transition-name: none) {
  @media (prefers-reduced-motion: no-preference) {
    html.theme-changing body,
    html.theme-changing .site-header,
    html.theme-changing .card {
      transition: background-color 180ms ease, border-color 180ms ease;
    }
  }
}
if (!document.startViewTransition && !reducedMotion.matches) {
  document.documentElement.classList.add('theme-changing');
  commitTheme(next);
  setTimeout(() => document.documentElement.classList.remove('theme-changing'), 200);
}

Why this works: the @supports guard means the two mechanisms can never both be active, which matters because a CSS transition running inside a view transition snapshot produces a half-transitioned capture. Restricting the fallback to a handful of large surfaces keeps its cost close to the API path.

Step 6 — Exclude elements that must not be captured Link to this section

Intent: some elements — a video, a live chart, a map canvas — look wrong when frozen as a snapshot and blended.

/* Opt individual subtrees out of the root snapshot. */
.live-canvas,
.embedded-map {
  view-transition-name: none;
}

Why this works: assigning view-transition-name: none keeps the element painting normally during the transition rather than being captured into the root snapshot. The result is that the surrounding page cross-fades while the live element continues to update — which is both cheaper and closer to what a viewer expects.

Verification Link to this section

Confirm three things: that the API path is actually being taken, that no layout stage occurs, and that reduced motion bypasses the animation entirely.

// tests/theme-transition.spec.mjs
import { test, expect } from '@playwright/test';

test('theme toggle uses a view transition and never triggers layout', async ({ page }) => {
  await page.goto('/');

  // Record that startViewTransition was called at least once.
  await page.evaluate(() => {
    window.__vt = 0;
    const original = document.startViewTransition?.bind(document);
    if (original) {
      document.startViewTransition = (cb) => { window.__vt++; return original(cb); };
    }
  });

  await page.getByRole('button', { name: /dark theme/i }).click();
  await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
  expect(await page.evaluate(() => window.__vt)).toBeGreaterThan(0);
});

test('reduced motion skips the transition', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');
  await page.evaluate(() => { window.__vt = 0;
    const o = document.startViewTransition?.bind(document);
    if (o) document.startViewTransition = (cb) => { window.__vt++; return o(cb); };
  });
  await page.getByRole('button', { name: /dark theme/i }).click();
  expect(await page.evaluate(() => window.__vt)).toBe(0);
});

In a manual check, the tell that the API is working is that the DevTools Performance trace shows a single compositor animation rather than a series of paint events, and that the element count in the Layers panel drops to two for the duration of the blend.

Troubleshooting Link to this section

Symptom Likely cause Fix
A bright flash midway through the fade The default plus-lighter blend mode on the pseudo-elements Declare explicit opacity keyframes with mix-blend-mode: normal, as in step 3
The page appears to freeze for a moment before fading Expensive work inside the startViewTransition callback The callback must only swap the attribute; move any data fetching or re-rendering outside it
Scroll position jumps during the transition The snapshot captured a different scroll offset than the live page Avoid changing scroll position in the same task; if a scroll is needed, perform it after finished resolves
Nothing animates, but the theme changes The data-transition attribute is removed before the animation starts, or the selector does not match Confirm the attribute is present during the transition by pausing in DevTools; check the selector specificity
A second toggle mid-transition leaves the attribute behind Using a timeout instead of the finished promise Clean up in finished.finally(), which resolves on interruption as well as completion
Three checks when a theme view transition does not animate A symptom branching into three checks: whether the API exists, whether the attribute is set inside the callback, and whether reduced motion is active, with a note that a persistent emulation setting is the usual cause. Nothing animates API present? log document.startViewTransition attribute set during? pause inside the callback reduced motion on? check the emulation setting Reduced motion is the answer more often than the API Emulation settings persist between DevTools sessions, so a machine that once tested reduced motion keeps skipping the fade.
Check reduced motion first. A DevTools emulation left enabled from a previous session silently disables the animation and looks exactly like an unsupported engine.

What the Snapshot Actually Captures Link to this section

The mental model that makes the rest of this technique predictable is that a view transition works on images, not on elements. When the transition starts, the browser paints the current state of the document into a snapshot and holds it. The callback then runs and the document changes. The browser paints again, producing a second snapshot. What animates is the pair of images, exposed as the ::view-transition-old(root) and ::view-transition-new(root) pseudo-elements.

Several consequences follow directly from that, and each explains a behaviour that otherwise looks like a bug.

Anything that was mid-animation when the snapshot was taken is frozen at whatever frame it happened to be on, for the duration of the cross-fade. A spinner appears to stop and then jump. This is usually acceptable for a 200 millisecond theme change and clearly unacceptable for a longer one, which is a good argument for keeping the duration short.

Content that is scrolled out of view is not in the snapshot, because it was not painted. If the page scrolls during the transition, the incoming snapshot covers a different region than the outgoing one and the blend looks like a slide. Avoid changing scroll position in the same task as the theme commit.

Elements with position: fixed are captured as part of the root snapshot at their painted position, which is generally what you want for a header, and occasionally surprising for a floating element that is itself animating.

Finally, because both snapshots are full-viewport images layered on top of the live document, the live document is not interactive during the transition. Pointer events land on the pseudo-elements, which do not respond. Again, at 200 milliseconds this is invisible; at a second it is a bug report about an unresponsive interface.

Choosing a duration Link to this section

The temptation is to make the fade longer because it looks better in isolation. Resist it. A theme change is not a navigation, and the visitor has already got what they asked for the moment they click the toggle — the animation is only there to prevent a jarring luminance flip. Somewhere between 150 and 250 milliseconds does that job. Beyond about 300 milliseconds the interface feels sluggish, the frozen-animation artefacts become noticeable, and the non-interactive window starts to matter.

Migration Note Link to this section

Moving from a global CSS transition to this approach is a two-step change and both steps are independently shippable.

First, replace the global rule with the scoped fallback. Search the stylesheets for transition declarations on *, :root, html or body * and delete them, then add the explicit surface list from step 5. On its own this typically halves the cost of a theme switch on a large page, and it changes nothing visually on the surfaces that matter.

Second, add the API path in front of it. Because the fallback is guarded by @supports not (view-transition-name: none), adding the API path automatically disables the fallback on engines that support it, so there is no window where both run.

If the codebase already uses view transitions for page navigation, name them: an unnamed transition for navigation and an unnamed one for theming will share the same pseudo-element selectors and their animation rules will collide. The data-transition attribute in step 2 is the smallest way to keep them separate.