A theme toggle that reveals the new theme with a circle growing from the button you clicked, built on the View Transitions API with a clean fallback.
Most dark mode toggles just snap the colors over. This one reveals the new theme: a circle grows from the exact button you clicked and wipes across the whole page. It runs on the browser's View Transitions API, falls back to an instant switch when that API is missing, and lets the sun/moon icon morph smoothly on top of the wipe.
next-themes flips the real theme (setTheme); everything else is just animation.clip-path from the clicked button's center out to the farthest screen corner.flushSync makes React commit the theme change inside the transition callback.Before any animation, handle the browser that can't do view transitions. If startViewTransition is missing, just switch and return:
const nextTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
if (typeof document.startViewTransition !== 'function') {
setTheme(nextTheme)
return
}This is progressive enhancement: everyone gets a working toggle, and browsers that support the API get the wipe on top. The animation is a bonus, never a requirement.
The wipe is a circle, so we need two things: where it starts and how big it has to grow. It starts at the center of the clicked button, like a ripple in a pond starting where you dropped the stone:
const { top, left, width, height } = button.getBoundingClientRect()
const cx = left + width / 2
const cy = top + height / 2
const maxRadius = Math.hypot(
Math.max(cx, viewportWidth - cx),
Math.max(cy, viewportHeight - cy),
)To cover the whole screen, the circle must reach the farthest corner from that center. Math.max picks the longer distance on each axis, and Math.hypot turns those two distances into the diagonal. That diagonal is the radius that guarantees full coverage.
Drive the wipe with the View Transitions API
Now the core. startViewTransition takes a callback that updates the DOM. The browser snapshots the old page, runs your callback, snapshots the new page, then hands you promises to animate between them:
const transition = document.startViewTransition(() => {
flushSync(() => {
setTheme(nextTheme)
})
})
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
],
},
{
duration: 400,
easing: 'ease-out',
fill: 'forwards',
pseudoElement: '::view-transition-new(root)',
},
)
})Two details make this work. First, flushSync forces React to apply the theme change synchronously inside the callback, so the "after" snapshot actually shows the new theme. Without it, React might batch the update and the browser would snapshot the old colors. Second, we wait for transition.ready (the snapshots now exist) and animate the new page's layer, ::view-transition-new(root), from a zero-radius circle to the full one. The new theme appears to wipe in over the old.
The sun and moon already cross-fade with a rotate-and-scale transition. To carry that across the wipe, the icon needs its own view transition layer:
/**
* Name only the clicked icon so it animates as its own layer. Set per-click
* since duplicate names (header + mobile sheet) would abort the transition.
*/
const icon = iconRef.current
icon?.style.setProperty('view-transition-name', 'theme-toggle-icon')
// ...start the transition...
transition.finished.finally(() => {
icon?.style.removeProperty('view-transition-name')
})A view-transition-name lifts an element out of the root snapshot into its own animated layer, so the icon morphs smoothly instead of being covered by the wipe. The catch: that name must be unique on the page, but this toggle is rendered twice (in the header and in the mobile sheet). So we set the name only on the icon that was actually clicked, right before the transition, and remove it once transition.finished settles. Two elements sharing one name would abort the whole transition.
'use client'
import { Moon02Icon, Sun02Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { useTranslations } from 'next-intl'
import { useTheme } from 'next-themes'
import { useCallback, useRef } from 'react'
import { flushSync } from 'react-dom'
import { cn } from '@/lib/utils'
type Props = {
size?: number
className?: string
}
export function ThemeToggle(props: Props) {
const { size = 18, className } = props
const t10s = useTranslations('Layout.nav')
const { resolvedTheme, setTheme } = useTheme()
const buttonRef = useRef<HTMLButtonElement>(null)
const iconRef = useRef<HTMLSpanElement>(null)
const toggleTheme = useCallback(() => {
const button = buttonRef.current
if (!button) return
const nextTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
if (typeof document.startViewTransition !== 'function') {
setTheme(nextTheme)
return
}
const { top, left, width, height } = button.getBoundingClientRect()
const cx = left + width / 2
const cy = top + height / 2
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
const maxRadius = Math.hypot(Math.max(cx, viewportWidth - cx), Math.max(cy, viewportHeight - cy))
const icon = iconRef.current
icon?.style.setProperty('view-transition-name', 'theme-toggle-icon')
const transition = document.startViewTransition(() => {
flushSync(() => {
setTheme(nextTheme)
})
})
transition.finished.finally(() => {
icon?.style.removeProperty('view-transition-name')
})
transition.ready
.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
],
},
{
duration: 400,
easing: 'ease-out',
fill: 'forwards',
pseudoElement: '::view-transition-new(root)',
},
)
})
.catch((error: unknown) => {
if (process.env.NODE_ENV === 'development') {
console.debug('View transition skipped:', error)
}
})
}, [resolvedTheme, setTheme])
return (
<button
ref={buttonRef}
type="button"
onClick={toggleTheme}
className={cn(
'flex cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground',
className,
)}
>
<span ref={iconRef} className="relative inline-flex items-center justify-center">
<HugeiconsIcon
icon={Sun02Icon}
size={size}
className="scale-100 rotate-0 transition-transform duration-300 dark:scale-0 dark:-rotate-90"
/>
<HugeiconsIcon
icon={Moon02Icon}
size={size}
className="absolute scale-0 rotate-90 transition-transform duration-300 dark:scale-100 dark:rotate-0"
/>
</span>
<span className="sr-only">{t10s('themeToggle')}</span>
</button>
)
}next-themes does the real switch; the View Transitions API does the show.Math.hypot.flushSync keeps React's DOM update inside the transition so the snapshot is correct.::view-transition-new(root) is the layer we grow the clip-path on.view-transition-name lets the icon morph, and avoids duplicate-name aborts.A few lines of measuring and one clip-path animation turn a plain toggle into something people notice. Go add it to your own theme switch.