Theme switching with a blur, using the View Transitions API
Most dark mode toggles flip a class and every colour on the page changes in the same frame. It works, but it feels abrupt. On this site the switch cross-fades the old page into the new one through a short blur, and it takes surprisingly little code.
Avoid the flash first
Before animating anything, the page has to load in the right theme. If the theme is applied after React hydrates, visitors who prefer dark mode see a white flash. The fix is a tiny script in the document head that runs before the first paint:
const saved = localStorage.getItem('theme');
const dark = saved
? saved === 'dark'
: matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);Because the script changes the <html> element before React renders, the root layout also needs suppressHydrationWarning on that element.
Let the browser take the snapshots
document.startViewTransition() captures the current page, runs a callback that updates the DOM, captures the result and animates between the two snapshots. The callback only has to flip the class and remember the choice:
document.startViewTransition(() => {
root.classList.toggle('dark', nextIsDark);
localStorage.setItem('theme', nextIsDark ? 'dark' : 'light');
});By default the snapshots cross-fade with mix-blend-mode: plus-lighter. Switching to a normal blend and adding a blur gives a softer hand-off, where the page passes through grey instead of jumping between black and white:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 350ms;
mix-blend-mode: normal;
}
::view-transition-old(root) { animation-name: fade-out; }
::view-transition-new(root) { animation-name: fade-in; }
@keyframes fade-out { to { opacity: 0; filter: blur(8px); } }
@keyframes fade-in { from { opacity: 0; filter: blur(8px); } }Keep it optional
The animation is an enhancement, so the toggle should still work without it:
- No API support: if
document.startViewTransitionis missing, apply the change directly. - Reduced motion: when
prefers-reduced-motion: reducematches, skip the transition entirely. - Icons: the sun and moon are both rendered and swapped with CSS, so the server and client markup never disagree.
The result is a theme switch that feels deliberate without adding a dependency or a single line of animation JavaScript.