A circular dark-mode wipe in 40 lines of CSS
Apr 2026 3 min read
Most theme toggles do this:
- Click button.
- Class flips on
<html>. - Colors snap.
It works. It also feels like a setting on a 2015 admin panel. I wanted the kind of toggle where the new theme sweeps in from the cursor like ink across paper. Turns out the browser already ships with the API for that. Most people just have not looked.
The five-line trick
function toggleTheme(event) {
if (!document.startViewTransition) {
setTheme(next)
return
}
document.startViewTransition(() => setTheme(next))
}document.startViewTransition snapshots the current page, runs your DOM mutation, snapshots the new page, then crossfades between them. By default the crossfade is a fade. With three lines of CSS, it becomes whatever you want.
The wipe
::view-transition-new(root) {
animation: wipe-in 0.45s cubic-bezier(0.4, 0, 0.2, 1);
clip-path: circle(0% at var(--x) var(--y));
}
@keyframes wipe-in {
to { clip-path: circle(150% at var(--x) var(--y)); }
}::view-transition-new(root) is a pseudo-element the browser creates for the new snapshot. Animate its clip-path from a zero-radius circle at the cursor to a 150% circle, and the new theme appears to bloom out of wherever you clicked.
The --x and --y come from the click handler:
button.addEventListener('click', (e) => {
document.documentElement.style.setProperty('--x', e.clientX + 'px')
document.documentElement.style.setProperty('--y', e.clientY + 'px')
toggleTheme(e)
})That is the whole feature. Forty lines if you count the keyframes and the no-flash-on-load script.
The catches
Safari and Firefox. The View Transitions API is Chromium-only on stable as of writing. Safari has it behind a flag. Firefox is in progress. That is why the function checks for document.startViewTransition and falls back to a plain swap. Users on other browsers get a normal toggle. Nothing breaks.
Flash of wrong theme. If you flip the class with React state, the page renders the wrong theme for one frame before the toggle runs. Move the initial theme read into an inline <script> in <head> that runs before hydration. next-themes does this for you, which is why I kept the dependency.
Reduced motion. Wrap the keyframes in @media (prefers-reduced-motion: no-preference). People who turn off animations get the instant swap, which is the whole point of the setting.
Why no library
There are libraries for this. I tried two. Both wanted me to wire up custom hooks, pass refs, and re-export their toggle component. For something that is twenty lines of working code, the API surface of the wrapper is bigger than the feature itself.
The View Transitions API is the abstraction. Adding a library on top is a tax.
Try it
Click the moon icon in the corner. The wipe happens from where you clicked. Forty lines of CSS doing what used to take a Framer Motion plugin.