Building a Dark Mode Toggle with a Circular Wipe Animation in React
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.
- React
- Next.js
- Dark mode
- View transitions
- CSS Animation
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.
How it works
next-themesflips the real theme (setTheme); everything else is just animation.- The View Transitions API snapshots the page before and after the switch, so we can animate between them.
- We grow a circular
clip-pathfrom the clicked button's center out to the farthest screen corner. flushSyncmakes React commit the theme change inside the transition callback.- The icon gets its own named layer, so it morphs instead of being wiped over.
- No API support? It switches instantly. Nothing breaks.
Flip the theme, with a fallback first
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.
Find the circle's center and radius
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.
Let the icon morph, not get wiped over
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.
The full component
'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>
)
}Summary
next-themesdoes the real switch; the View Transitions API does the show.- Center + radius: start the circle at the button, size it to the farthest corner with
Math.hypot. flushSynckeeps React's DOM update inside the transition so the snapshot is correct.::view-transition-new(root)is the layer we grow theclip-pathon.- Per-click
view-transition-namelets the icon morph, and avoids duplicate-name aborts. - Feature-detect first so unsupported browsers still get a working toggle.
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.