dmdm
AboutProjectsBlogContacts
Let's chatLet's chat

dmdm

Software Engineer & Vercel Enthusiast. Building fast, reactive web experiences.

Pages

  • About
  • Projects
  • Blog
  • Contacts

Connect

  • LinkedIn
  • GitHub
  • Twitter/X
  • Email

More

  • RSS Feed
  • Sitemap
© 2026 Dmytro Bobryshev. All rights reserved.Last updated by Dmytro on Jun 23, 2026, 05:07 PM
Back to blog
Dark mode cover

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

Dmytro Bobryshev28 May 2026•4 min read

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.

ReactNext.jsDark modeView transitionsCSS 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.

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:

theme-toggle.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:

theme-toggle.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:

theme-toggle.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:

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

theme-toggle.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
  • MDN: Document.startViewTransition()
  • MDN: clip-path
  • Chrome for Developers: Smooth transitions with the View Transition API
  • next-themes
Like what you read?

Let's build something together

Have a project in mind or just want to chat? I'm always open to new ideas and collaborations.

Get in touchGet in touchView projectsView projects
PreviousBuild a Hover-Expanding Arrow in React with Tailwind CSSNextAdaptive Animated Underline Title with React and Motion