Smooth Theme Switching: Crossfades, Circular Reveals and No Flash

A theme toggle is one of the few moments where the entire page changes at once. Done abruptly, every surface flips in a single frame, which is jarring in a dark room and can read as a glitch. Done badly in the other direction — a * { transition: all 0.3s } rule — every element animates independently on slightly different schedules, hovers start lagging all over the site, and low-end devices stutter through the switch. The narrow problem here is making the switch feel deliberate: either a scoped crossfade of the main surfaces, or a single composited view transition such as a circular reveal from the toggle, without a flash of the wrong theme on load. It belongs to Color & Theme Transitions in the CSS-Only Micro-Interactions & Animations guide.

Why a theme switch is a special animation

Most transitions involve one component. A theme switch involves every themed property of every element on the page changing in the same frame: backgrounds, text colours, borders, shadows, SVG fills. Animating all of those individually means potentially thousands of concurrent transitions, each needing style recalculation and repaint per frame. It also means every other colour change on the site — hovers, focus rings, validation states — gains the same transition, because the rule cannot tell a theme switch from any other change.

There are two ways around that. The first limits the crossfade to the handful of large surfaces that dominate what the eye sees, and lets small details change instantly. The second avoids per-element animation entirely: a view transition snapshots the page before and after the change and animates between two images on the compositor. Both are covered here.

Three ways to animate a theme switch A universal transition animates thousands of elements and slows hovers everywhere. A scoped transition animates a few large surfaces. A view transition animates two snapshots of the page, old and new, as images. How much work each approach asks of the browser * { transition } every element animates hovers lag site-wide frames drop on switch avoid Scoped surfaces body, header, cards details switch instantly works everywhere good baseline View transition two snapshots animate on the compositor enables reveals best effect Use the view transition where supported and the scoped crossfade as its fallback.

The complete implementation

The page below combines all three pieces: a no-flash initial theme, a scoped crossfade fallback, and a circular view-transition reveal that expands from the toggle button. The script is minimal and only orchestrates; every visual is CSS.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Theme switch</title>
<script>
  // 1. No flash: apply the stored or system theme before first paint.
  try {
    const stored = localStorage.getItem('theme');
    const dark = stored ? stored === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.dataset.theme = dark ? 'dark' : 'light';
  } catch (e) {}
</script>
<style>
  :root {
    --surface: #ffffff;
    --text: #0f172a;
    --card: #f1f5f9;
    color-scheme: light;
  }
  :root[data-theme="dark"] {
    --surface: #0f172a;
    --text: #e2e8f0;
    --card: #1e293b;
    color-scheme: dark;
  }

  body { margin: 0; padding: 1.5rem; font: 16px/1.6 system-ui, sans-serif; background: var(--surface); color: var(--text); }
  .card { padding: 1rem; border-radius: 12px; background: var(--card); }

  /* 2. Fallback crossfade, scoped to the big surfaces only. The class is
     added just for the duration of a switch, so hovers are unaffected. */
  :root.theme-fading body,
  :root.theme-fading .card {
    transition: background-color 250ms ease, color 250ms ease;
  }

  /* 3. Circular reveal via view transition. The new snapshot is clipped
     to a circle that grows from the toggle's position. */
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none;
    mix-blend-mode: normal;
  }
  ::view-transition-new(root) {
    animation: theme-reveal 450ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  @keyframes theme-reveal {
    from { clip-path: circle(0 at var(--x, 50%) var(--y, 50%)); }
    to   { clip-path: circle(150% at var(--x, 50%) var(--y, 50%)); }
  }

  /* Reduced motion: no sweeping reveal, just a quick crossfade. */
  @media (prefers-reduced-motion: reduce) {
    ::view-transition-new(root) { animation: fade-in 150ms ease; }
    @keyframes fade-in { from { opacity: 0; } }
  }

  .toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
</style>
</head>
<body>
  <button class="toggle" type="button" aria-pressed="false">Dark theme</button>
  <div class="card"><p>Content that changes with the theme.</p></div>

  <script>
    const btn = document.querySelector('.toggle');
    const root = document.documentElement;
    btn.setAttribute('aria-pressed', String(root.dataset.theme === 'dark'));

    btn.addEventListener('click', (event) => {
      const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
      const apply = () => {
        root.dataset.theme = next;
        btn.setAttribute('aria-pressed', String(next === 'dark'));
        try { localStorage.setItem('theme', next); } catch (e) {}
      };

      // Centre the reveal on the button.
      const r = btn.getBoundingClientRect();
      root.style.setProperty('--x', `${r.left + r.width / 2}px`);
      root.style.setProperty('--y', `${r.top + r.height / 2}px`);

      if (document.startViewTransition) {
        document.startViewTransition(apply);
      } else {
        root.classList.add('theme-fading');
        apply();
        setTimeout(() => root.classList.remove('theme-fading'), 300);
      }
    });
  </script>
</body>
</html>

The theme-fading class is the detail that separates a good fallback from a bad one. The colour transition exists only while the switch is happening, so the rest of the time hovers and focus states change at their own speed. Without the class, the surfaces would crossfade on any colour change, including ones unrelated to theming.

The key technique: animating snapshots, not elements

A view transition works in three steps. The browser captures the current page as an image, the callback applies the DOM change, and the browser captures the new state. It then renders a pseudo-element tree — ::view-transition-old(root) showing the old image and ::view-transition-new(root) showing the new one — and animates them. Because these are images, the animation costs the same whether the page has ten elements or ten thousand.

View transition steps for a circular reveal Step one captures the light page. Step two applies the dark theme. Step three captures the dark page. Step four draws the old snapshot underneath and the new snapshot on top, clipped to a circle that grows from the toggle position until it covers the screen. Old image under, new image revealed on top 1. snapshot old 2. apply theme 3. snapshot new 4. reveal ::view-transition-new(root) { clip-path: circle(r at x y) } The page underneath is not animating at all: only one clipped image grows.

The default view transition is a crossfade. Setting animation: none on both snapshot pseudo-elements and then giving the new one a clip-path animation replaces the crossfade with the reveal. mix-blend-mode: normal removes the default blending the browser applies during the crossfade, so the new image is drawn opaquely on top of the old. The radius of 150% is generous enough to cover the far corner of the viewport from any starting point. For more on the pseudo-element tree, see Same-Document View Transitions.

Details that make the switch feel finished

A few small decisions separate a polished theme switch from one that merely works.

Images and illustrations. Photographs usually look fine in both themes, but illustrations drawn on white, logos with dark text and screenshots of light interfaces glare on a dark page. Swap them with <picture> and media="(prefers-color-scheme: dark)" where the theme follows the system, or with a data-theme selector on a background image where a toggle is used. Inline SVGs that use currentColor adapt automatically, which is one reason to prefer them for diagrams.

Third-party embeds. Maps, video players and comment widgets inside iframes do not inherit the page's theme. Many accept a theme parameter; those that do not will stay light, so frame them with a border so the contrast change reads as intentional.

The browser's own UI. The color-scheme property themes scrollbars, form controls and the default canvas, and the theme-color meta tag tints the mobile browser toolbar. Update the meta tag's content during the switch, or provide two tags with media attributes for light and dark, so the toolbar does not stay bright above a dark page.

Timing. Keep the reveal under half a second. A theme switch is a preference change, not an event worth celebrating, and users who toggle back and forth to compare themes will find a long reveal tiresome.

Accessibility notes

Three things keep the switch accessible. The toggle is a real <button> with aria-pressed, so screen readers announce "Dark theme, toggle button, pressed". The choice is persisted so it survives reloads, and the inline head script applies it before paint — a flash of a bright theme when a user has chosen dark is not merely cosmetic for people with light sensitivity. And the circular reveal is replaced with a short fade under prefers-reduced-motion, following the approach in View Transitions With Reduced Motion.

Variation: a pure-CSS toggle with :has()

If the only goal is a crossfade and the preference need not persist, the toggle can be a checkbox and the theme selected with :has(), with no script at all.

:root:has(#theme-switch:checked) {
  --surface: #0f172a;
  --text: #e2e8f0;
  --card: #1e293b;
  color-scheme: dark;
}

body, .card {
  transition: background-color 250ms ease, color 250ms ease;
}

@media (prefers-reduced-motion: reduce) {
  body, .card { transition: none; }
}

The demo applies this version to a wrapper element rather than :root, because the demo frame is a small embedded document; the mechanism is identical.

Live demoScript-free theme switch with a scoped crossfade
Tick the switch: only the panel surface and text crossfade over 250ms, while the rest of the frame is unaffected.

The trade-offs are real: the choice resets on reload, the transition applies to all colour changes on those surfaces, and there is no view transition without script. It suits demos and single-page tools more than production sites, but it shows how little of the effect genuinely requires JavaScript.

Browser support

Same-document view transitions (document.startViewTransition() and the ::view-transition-* pseudo-elements) are supported in Chrome and Edge 111+, Firefox 144+ and Safari 18+; elsewhere the scoped crossfade fallback runs. Colour transitions and color-scheme are universal. :has() for the script-free variant is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+. prefers-reduced-motion is supported in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+.

FAQ

Why does my page flash the wrong theme on load? The stored theme is applied after the first paint, usually by a script at the end of the body or in a framework's mount hook. Apply it in a tiny inline script in the head, before any styles render, so the first frame already uses the correct theme.

Should I put a transition on every element for the theme change? No. A universal selector with a colour transition makes every element animate on every hover and state change too, and the thousands of simultaneous transitions during a theme switch can drop frames. Transition only the large surfaces, or use a view transition that animates one snapshot.

How does a view transition animate a theme switch?document.startViewTransition takes a snapshot of the page, applies the theme change, takes another snapshot, and animates between the two images. Because it animates two flat images rather than every element's colour, it is cheap and allows effects such as a circular reveal from the toggle button.

What should reduced-motion users see when switching themes? A near-instant change, or a very short crossfade. The circular wipe and any sweeping reveal count as motion. A brief opacity crossfade is generally acceptable because it involves no movement.

Related articles

More pages in the same section.