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.
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.
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 |
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.
Related Link to this section
- Theme Transition Motion & Performance — the parent area, covering the cost model this technique optimises
- Respecting Reduced Motion in Theme Switches — the full accessibility treatment of theme animation
- Persisting a Theme Choice Without a Flash — the resolver that must run before this transition ever fires