· 7 min read
Looking back: View Transitions on the old theme toggle
- #nextjs
- #view-transitions
- #css
- #animations
- #accessibility
Contents
What it was
Until October 2026 this site had a light theme, a dark theme and a toggle in the header to switch between them. Clicking the toggle did not just swap colors. It used the View Transitions API to reveal the new theme as a circle growing from the point you clicked, until it covered the whole page.
The toggle is gone now, along with the CSS and the component that ran it. I am keeping this post because the technique still holds: the feature detection, the two reduced-motion layers and the keyboard fallback apply to any transition you wrap in startViewTransition. The code below is what lived in globals.css and in the toggle component until the redesign.
Browser support
The View Transitions API landed in Chromium 111 Chrome 111+, Safari 18 Safari 18+, and Firefox 144 Firefox 144+. That covers a wide majority of browsers in 2026, but "wide majority" is not "all." Recruiters on an older corporate-issued laptop, or on a phone that has not updated in a year, got the instant theme swap that existed before any of this code was written. That was progressive enhancement by design, not by accident.
The entire View Transitions block lived inside an @supports (view-transition-name: root) guard in CSS and an explicit feature-detect in the JavaScript handler. Browsers that did not understand startViewTransition took the early-return path and called setTheme directly. No polyfill, no shim, no fallback library. The pre-existing behavior was already the right fallback.
I reached for the API because the theme toggle was the most visually prominent moment on the site. Every other interaction was text or a link. The toggle was the only place where something dramatic happened, and a reveal from the click point made the switch feel like a direct result of the click.
How the radial reveal worked
The mental model behind startViewTransition is snapshot-based. When you call document.startViewTransition(callback), the browser immediately captures a screenshot of the current page, the OLD snapshot. Then it runs the callback, which here called setTheme. The DOM then reflected the new theme. The browser captures a second screenshot, the NEW snapshot. CSS animations control how the transition from OLD to NEW plays out on screen.
The NEW snapshot sits on top of the OLD snapshot in a stacked compositing context. The default browser behavior cross-fades them at roughly equal opacity over about 250 milliseconds. That is fine for most transitions, but it washes out on a palette swap where both surfaces have solid backgrounds. The reveal felt muddy.
I overrode the default to use clip-path instead of opacity. The OLD snapshot stayed fully visible and fully opaque, with animation: none on ::view-transition-old(root). The NEW snapshot started as a circle with radius 0% centered on the click coordinates and expanded to 150%, large enough to cover the viewport wherever the toggle sat. The new theme wiped in from the toggle button, not from the edges of the screen.
The 150% value was not arbitrary. At the maximum distance from a corner-positioned toggle to the far corner of the viewport, you need just under 142% to guarantee full coverage at any viewport size. I rounded to 150% as a safe upper bound.
Setting up the clip-path animation
The CSS sat in globals.css inside a compound guard: @media (prefers-reduced-motion: no-preference) wrapping @supports (view-transition-name: root). Both guards had to be true. If the user preferred reduced motion, this block did not apply, and the reduced-motion fallback block took over (covered in the next section).
@media (prefers-reduced-motion: no-preference) {
@supports (view-transition-name: root) {
/* OLD snapshot stays put; NEW wipes in on top. */
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation: vt-reveal 420ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
mix-blend-mode: normal;
z-index: 1;
}
@keyframes vt-reveal {
from {
clip-path: circle(0% at var(--vt-x, 50%) var(--vt-y, 50%));
}
to {
clip-path: circle(150% at var(--vt-x, 50%) var(--vt-y, 50%));
}
}
}
}The --vt-x and --vt-y custom properties were set by the JavaScript handler immediately before calling startViewTransition. The defaults (50% 50%) meant that if the handler failed to set them, the reveal started from the center of the viewport instead of a corner, which was an acceptable fallback.
The mix-blend-mode: normal override on both pseudo-elements was required. Some browsers apply a non-normal blend mode by default to the OLD snapshot, which produces a visible artifact when the OLD sits under a partially transparent NEW. Setting both to normal removed the artifact.
The 420ms duration and the easing (cubic-bezier(0.4, 0, 0.2, 1) is Material Design's "standard" easing) were calibrated by eye against several viewport sizes. Faster felt rushed; slower felt like a loading state rather than a transition.
Reduced-motion fallback
I implemented two layers of reduced-motion protection, and both were necessary.
The first layer was JavaScript: if window.matchMedia('(prefers-reduced-motion: reduce)').matches, the handler called setTheme and returned without calling startViewTransition at all. This is the cleanest accommodation there is. The snapshot machinery never runs, no pseudo-elements are created, and the theme change is instant. Zero motion, zero GPU work.
The second layer was CSS. The Phase 1 globals already had a * { transition: none; animation: none } block scoped to prefers-reduced-motion: reduce. That block does not cover ::view-transition-old and ::view-transition-new. Those pseudo-elements live in the UA shadow tree and are invisible to the universal selector. Without an explicit override, a browser that ran startViewTransition would play its default ~250ms cross-fade even for a reduced-motion user.
The CSS layer was defense in depth. If the JavaScript layer failed (unlikely, but possible under some race condition or a future browser change), the CSS fallback kept the transition to a calm 200ms opacity fade instead of a viewport-wide reveal.
@media (prefers-reduced-motion: reduce) {
@supports (view-transition-name: root) {
::view-transition-old(root),
::view-transition-new(root) {
animation: vt-fade 200ms linear forwards !important;
mix-blend-mode: normal;
}
/* OLD fades 1 → 0; NEW fades 0 → 1 (reverse). */
::view-transition-new(root) {
animation-direction: reverse !important;
}
@keyframes vt-fade {
from { opacity: 1; }
to { opacity: 0; }
}
}
}The animation-direction: reverse on ::view-transition-new is the part that is easiest to get wrong. vt-fade animates opacity from 1 to 0. Applied forward to both pseudo-elements, OLD fades out and NEW also fades out, leaving the user looking at the compositing background with both snapshots invisible for an instant. Reversing NEW makes it animate from 0 to 1, which is a proper cross-fade: OLD dissolves while NEW appears.
Light and dark palette swap
The toggle called setTheme('dark') or setTheme('light'). Under the hood, next-themes writes or removes the .dark class on the <html> element in one synchronous tick and updates localStorage at the same time. That was the entire mechanism. No React re-render was needed at the root.
Tailwind v4 resolved the OKLCH CSS variables under the @custom-variant dark (&:where(.dark, .dark *)) rule. When .dark landed on <html>, every descendant picked up the dark palette: the dark background, its own primary color and the adjusted text tokens. When .dark was removed, the light palette came back.
The View Transitions snapshot mechanism captured the visual state before and after this class swap. The OLD snapshot was the full light page, the NEW snapshot the full dark page, or the other way around. The clip-path animation revealed NEW from the click point, so the new palette spread out from wherever the toggle was on screen.
The toggle was the most ornate UX moment on the site, and the reveal amplified that without turning into a gimmick.
The toggle handler
The full onClick implementation is short enough to walk through in one block. Every line existed for a reason that was not obvious during implementation.
const onClick = (event: MouseEvent<HTMLButtonElement>) => {
const next = isDark ? 'light' : 'dark';
// Feature-detect (Safari <18 / Firefox <144 / older Chromium) AND
// bypass on prefers-reduced-motion (CSS handles the visual fade; we
// skip the snapshot machinery to honor "reduce" intent at the JS layer).
if (
typeof document === 'undefined' ||
!('startViewTransition' in document) ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setTheme(next);
return;
}
// Click-coord capture with keyboard fallback.
// Keyboard activation (Enter/Space) sends clientX:0/clientY:0; the
// getBoundingClientRect fallback computes button center so the radial
// reveal originates from the toggle, not the viewport top-left.
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX || rect.left + rect.width / 2;
const y = event.clientY || rect.top + rect.height / 2;
document.documentElement.style.setProperty('--vt-x', `${x}px`);
document.documentElement.style.setProperty('--vt-y', `${y}px`);
// setTheme is synchronous — next-themes writes attribute + localStorage
// in one tick. startViewTransition takes the OLD snapshot, runs the
// callback, then animates to NEW.
document.startViewTransition(() => {
setTheme(next);
});
};The typeof document === 'undefined' guard was for the SSR path. Next.js renders client components on the server too, and document does not exist there. Without the guard, the startViewTransition check would throw a ReferenceError in Node.js.
The keyboard fallback for click coordinates was the detail I nearly missed. When a user tabs to the toggle and activates it with Enter or Space, the browser fires a click event with clientX: 0 and clientY: 0. Without the fallback, the reveal would start from the top-left corner of the viewport, which looked broken anywhere the toggle was not at the top left. The getBoundingClientRect fallback computes the center of the button, which is where the keyboard user is looking.
The custom properties were set on document.documentElement, the <html> element, right before startViewTransition. The browser reads them during the CSS animation, after the snapshot is taken and the callback has run. The assignment is synchronous within the task, so the properties were always set before the animation began.
Why I removed it
The October 2026 redesign replaced the two themes with one fixed palette: ink, paper and a signal blue, alternating by section. With one palette there was nothing left to toggle, so the toggle, its View Transitions CSS and the component all went out in the same change. The site's motion budget moved to a Three.js cube field behind the hero on desktop, and the header now holds a control that pauses every looping animation instead of a theme switch.
What I would reuse is the scaffolding, not the reveal. Any site-wide transition, such as route changes, modal entries or panel slides, can use the same startViewTransition wrapper with the same feature-detect and reduced-motion check. Browsers without the API get the instant change, reduced-motion users get a short fade, and everyone else gets the animation. The keyframes are the part that changes; the JavaScript around them stays the same.