Ever since we introduced dark mode, the toggle felt abrupt. The classic icon swap was functional, but it never sold the illusion of the UI being re-lit. This update revisits the interaction with the new View Transitions API so the page now wipes open into light mode and gently shrinks back into darkness.
The Design Goal
Dark mode should feel like dimming the lights, while returning to light mode should feel like flipping them fully on. That meant two requirements:
- Light → Dark: the light layer collapses into the toggle, revealing the existing dark snapshot underneath.
- Dark → Light: the light layer should radiate outward from the toggle, taking over the screen.
We needed a single toggle component that could treat each direction differently while still respecting reduced motion preferences and gracefully degrading on browsers without View Transitions support.
Building the Animation
The heart of the change lives in components/theme-toggle.tsx
. We still rely on next-themes
for state management, but we now orchestrate the transition by:
- Capturing the toggle button geometry so we can center the circular wipe.
- Starting a view transition and synchronously flipping the theme with
flushSync
. - Storing a
data-theme-transition
flag on the<html>
element so CSS can stack the correct pseudo element on top. - Animating both the
::view-transition-new(root)
and::view-transition-old(root)
pseudo elements with tailored clip-paths.
const clipKeyframes = [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
];
if (newTheme === "light") {
root.animate(
{ clipPath: clipKeyframes },
{ ...animationOptions, pseudoElement: "::view-transition-new(root)" }
);
root.animate(
{ clipPath: fullyOpenClip },
{ ...animationOptions, pseudoElement: "::view-transition-old(root)" }
);
return;
}
root.animate(
{ clipPath: [...clipKeyframes].reverse() },
{ ...animationOptions, pseudoElement: "::view-transition-old(root)" }
);
root.animate(
{ clipPath: fullyOpenClip },
{ ...animationOptions, pseudoElement: "::view-transition-new(root)" }
);
The additional animation with the fullyOpenClip
keeps the opposite layer fixed at full visibility, preventing any mid-transition flicker as the circle contracts.
Controlling the Layer Stack
The View Transitions API renders snapshots as pseudo elements, so we used a tiny CSS helper in app/globals.css
to set their z-index
depending on the direction. The JavaScript toggle sets data-theme-transition="to-light"
(or "to-dark"
) before the theme change and removes it once the transition finishes.
html[data-theme-transition="to-light"]::view-transition-new(root) {
z-index: 1;
}
html[data-theme-transition="to-dark"]::view-transition-old(root) {
z-index: 1;
}
That pairing keeps the “active” snapshot on top: the expanding light layer or the persistent dark layer during the shrink.
Progressive Enhancement Considerations
- Reduced motion: We exit early and flip themes normally when the user requests reduced motion.
- Unsupported browsers: If
startViewTransition
is missing, the toggle remains a traditional instantaneous swap. - State cleanup: We remove
data-theme-transition
in afinally
clause so subsequent toggles can run cleanly even if an animation is interrupted.
Try It Yourself
Tap the toggle in this website's header to see both directions in action:
- Starting in dark mode, a light circle expands outward to reveal the bright palette.
- Switching back to dark mode, the light snapshot collapses into the button without flashing the background.
If the animation feels too intense, enable the system-level “Reduce Motion” preference—our toggle respects it automatically.
What’s Next
This experiment unlocked a pattern we can reuse for other transitions, like section reveals or onboarding flows. It also reminded us how powerful the View Transitions API can be when paired with fine-grained control from the Web Animations API. Expect more playful, yet respectful, motion design updates soon.
Comments