# Building a Dark Mode Toggle with a Circular Wipe Animation in React

*Dmytro Bobryshev · 28 May 2026*

> 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.

> **Interactive Demo** — theme-wipe
> *Interactive example ☝️*

## How it works

- `next-themes` flips 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-path` from the clicked button's center out to the farthest screen corner.
- `flushSync` makes 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:

```tsx
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:

```tsx
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:

```tsx
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**:

```tsx
/**
 * 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

```tsx
'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-themes` does 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`.
- **`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.
- **Per-click **`view-transition-name` lets 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.

## Resources

- [MDN: View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
- [MDN: ](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition)[`Document.startViewTransition()`](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition)
- [MDN: ](https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path)[`clip-path`](https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path)
- [Chrome for Developers: Smooth transitions with the View Transition API](https://developer.chrome.com/docs/web-platform/view-transitions)
- [next-themes](https://github.com/pacocoursey/next-themes)