Circular Reveal Theme Toggle in Next.js (View Transitions)

5 min read

TL;DR Call document.startViewTransition(), toggle the dark class on <html> inside the callback (with flushSync for React state), then in transition.ready animate clipPath on ::view-transition-new(root) from circle(0px) to the farthest corner. Turn off the default fade in CSS, start from the button's center, and skip it when the API is missing or reduced motion is on.

On this page
  1. How the circular reveal works
  2. Step 1: turn off the default cross-fade
  3. Step 2: the theme switch
  4. Step 3: the toggle button
  5. Browser support
  6. What AI tools get wrong here
  7. A toggle with more going on
  8. FAQ
  9. How do I make a circular dark mode transition in Next.js?
  10. Why does my view transition show the old theme first?
  11. Does the View Transitions API work in Firefox and Safari?
  12. Should the theme reveal respect prefers-reduced-motion?

You click the moon and the dark theme spreads out from the button in a circle until it covers the page. It looks like it needs an animation library. It doesn't. The browser's View Transitions API does the hard part: it takes a picture of the page, lets you switch the theme, and then lets you animate the new picture in with a growing circle. The whole thing is about 50 lines of TypeScript and one block of CSS.

I make qodesign, which sells animated React components. The Day / Night Toggle linked at the end is one of ours and it's free. The code in this post was written for the post and uses no library. The toggle component itself uses Framer Motion.

This post assumes you already set the theme before the first paint, so the page doesn't flash. If you haven't, do that first: Fix the Dark Mode Flash in Next.js has the inline script, and the code below uses the same theme key in localStorage and the same dark class on <html>.

How the circular reveal works

document.startViewTransition(callback) runs in three steps:

  1. The browser takes a snapshot of the page as it is now (the old view).
  2. It calls your callback. You change the DOM, here by toggling the dark class.
  3. It snapshots the new page and builds two pseudo-elements on top of it, ::view-transition-old(root) and ::view-transition-new(root). By default it cross-fades them.

The trick is to turn off the cross-fade and animate a clip-path on the new view instead, from a circle of size 0 at the button to a circle big enough to cover the whole screen. The old view sits underneath, so it looks like the new theme is spreading over the old one.

Day / Night Toggle running live. A theme switch with a sun that sets into a starry night.

Step 1: turn off the default cross-fade

In app/globals.css, next to your Tailwind import and the class-based dark variant:

css
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

/* Turn off the default cross-fade for the page snapshot */
::view-transition-image-pair(root) {
  isolation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
  display: block;
}

Without this block you get the circle and a fade at the same time, and the old theme bleeds through the edge of the circle.

Step 2: the theme switch

Put this in lib/theme-transition.ts:

ts
import { flushSync } from "react-dom";

export type Theme = "light" | "dark";

export function applyTheme(theme: Theme) {
  const root = document.documentElement;
  root.classList.toggle("dark", theme === "dark");
  root.style.colorScheme = theme;
  try {
    localStorage.setItem("theme", theme);
  } catch {}
}

export function switchThemeWithReveal(
  theme: Theme,
  origin: { x: number; y: number },
  onUpdate?: () => void,
) {
  const update = () => {
    applyTheme(theme);
    // Commit React state now, so the new snapshot shows the new icon too
    if (onUpdate) flushSync(onUpdate);
  };

  const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (typeof document.startViewTransition !== "function" || reduceMotion) {
    update();
    return;
  }

  const { x, y } = origin;
  // Distance to the farthest corner, so the circle covers the whole screen
  const radius = Math.hypot(
    Math.max(x, window.innerWidth - x),
    Math.max(y, window.innerHeight - y),
  );

  const transition = document.startViewTransition(update);

  transition.ready
    .then(() => {
      document.documentElement.animate(
        {
          clipPath: [
            `circle(0px at ${x}px ${y}px)`,
            `circle(${radius}px at ${x}px ${y}px)`,
          ],
        },
        {
          duration: 450,
          easing: "cubic-bezier(0.2, 0, 0, 1)",
          pseudoElement: "::view-transition-new(root)",
        },
      );
    })
    .catch(() => {
      // The transition was skipped; the theme has still changed
    });
}

The parts that matter:

  • The update callback changes the DOM itself: the class, color-scheme and the saved value. The browser takes the new snapshot right after the callback returns, so anything that changes later is not in it.
  • flushSync is there for React state. If your button's icon comes from useState, a normal setState would render after the snapshot, and the new view would still show the old icon. flushSync makes React commit the update before the callback returns. React's docs warn that it can hurt performance, which is fine for one click on a toggle, but don't reach for it elsewhere.
  • animate() runs inside transition.ready. Before that promise resolves the pseudo-elements don't exist yet, so there is nothing to animate.
  • The radius is the distance from the button to the farthest corner of the viewport. Use half the width, or 50%, and the corners stay in the old theme until the animation ends.
  • If the browser has no startViewTransition, or the user asked for reduced motion, the theme just switches. No animation, no error.
  • ready rejects when the transition can't start, for example when two elements share a view-transition-name. The catch keeps that from turning into an unhandled promise error. The theme has already changed by then.

Step 3: the toggle button

components/theme-toggle.tsx:

tsx
"use client";

import { useEffect, useState } from "react";
import { switchThemeWithReveal } from "@/lib/theme-transition";

export function ThemeToggle() {
  // null until we have read the class the head script set
  const [dark, setDark] = useState<boolean | null>(null);

  useEffect(() => {
    setDark(document.documentElement.classList.contains("dark"));
  }, []);

  return (
    <button
      type="button"
      aria-label="Dark mode"
      aria-pressed={dark ?? false}
      disabled={dark === null}
      onClick={(event) => {
        // Use the button's center, not the click point: a keyboard
        // press reports the click at 0,0
        const rect = event.currentTarget.getBoundingClientRect();
        const next = !dark;
        switchThemeWithReveal(
          next ? "dark" : "light",
          { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
          () => setDark(next),
        );
      }}
      className="grid size-10 place-items-center rounded-full border border-zinc-200 text-zinc-700 transition-colors hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:border-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-900 dark:focus-visible:outline-zinc-100"
    >
      <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
        {dark ? (
          <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
        ) : (
          <>
            <circle cx="12" cy="12" r="4" />
            <path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
          </>
        )}
      </svg>
    </button>
  );
}

The circle starts from the center of the button rather than from the mouse position. When someone presses Enter or Space on a focused button, the click event usually carries no pointer position (it comes through as 0,0), so a reveal that reads clientX and clientY starts in the top left corner for keyboard users. The button's own rectangle works for both.

The button starts as disabled with dark set to null because the server can't know the theme. After hydration the effect reads the class the head script already set. If you use next-themes, read resolvedTheme there instead, and make sure the class change happens inside the transition callback, not in an effect that runs later.

aria-pressed tells screen readers whether dark mode is on, and the label stays the same in both states. More on motion settings in prefers-reduced-motion in React.

Browser support

From MDN's compatibility data, the callback form of document.startViewTransition() works in Chrome and Edge 111+, Safari 18+ and Firefox 144+. The newer options form, startViewTransition({ update, types }), needs Chrome 125, Safari 18.2 or Firefox 147, which is why this post uses the plain callback. In an older browser the typeof check skips the animation and the theme switches instantly, so nothing breaks.

What AI tools get wrong here

Ask Cursor, v0 or Lovable for "a dark mode toggle with a circular reveal" and these are the problems to look for in what comes back:

  1. No CSS reset for the root pseudo-elements, so the circle and the default fade run together.
  2. The theme changes in a useEffect or through state that renders later, so the circle reveals the old theme and then the page snaps to the new one.
  3. document.documentElement.animate(...) called right after startViewTransition instead of inside transition.ready.
  4. A radius of 50% or half the width, which leaves the corners uncovered.
  5. A call to document.startViewTransition with no check, which throws in browsers that don't have it, and no reduced motion check.
  6. The circle starting from the mouse position, which is 0,0 for keyboard users.

A prompt that avoids them:

text
Add a dark mode toggle with a circular reveal using the View Transitions API, no animation library.
- Keep the existing inline head script that sets the "dark" class on <html> from localStorage "theme".
- In the startViewTransition callback, toggle the "dark" class on document.documentElement, set style.colorScheme and save to localStorage. If React state drives the icon, update it with flushSync inside the callback.
- In transition.ready, animate clipPath on pseudoElement "::view-transition-new(root)" from circle(0px) to a radius equal to the distance to the farthest viewport corner, centered on the toggle button's bounding rect.
- Add CSS: ::view-transition-old(root), ::view-transition-new(root) { animation: none; mix-blend-mode: normal; display: block; }
- If startViewTransition is missing or prefers-reduced-motion is reduce, switch the theme with no animation.
- Use a button with aria-pressed and a fixed aria-label.

A toggle with more going on

The button above is deliberately plain so the reveal is the star. If you want the toggle itself to animate too, the Day / Night Toggle is free on qodesign: the sun sets into a cratered moon and stars fall in. It takes a controlled night prop and an onChange(night) callback, so you can call switchThemeWithReveal from onChange and pass it the toggle's center. There's a Next.js guide for wiring it up. More free pieces are at /free.

FAQ

How do I make a circular dark mode transition in Next.js?

Call document.startViewTransition() and toggle the dark class on <html> inside its callback. Then, in transition.ready, animate clipPath on ::view-transition-new(root) from circle(0px) to a circle that reaches the farthest corner of the viewport, and turn off the default fade with animation: none on the root view transition pseudo-elements.

Why does my view transition show the old theme first?

The DOM changed after the browser took the new snapshot. This happens when the theme is applied in a useEffect or by a React state update that renders later. Change the class directly in the callback, and wrap any setState the UI depends on in flushSync.

Does the View Transitions API work in Firefox and Safari?

Yes for same-page transitions with a callback: Safari 18 and Firefox 144 added it, per MDN's compatibility data. For moving between pages instead, page transitions in the Next.js App Router covers React's <ViewTransition>, which Next.js 16 drives on every client navigation. Chrome and Edge have had it since version 111. Check for document.startViewTransition before calling it so older browsers just switch the theme.

Should the theme reveal respect prefers-reduced-motion?

Yes. A full-screen wipe is a large motion. Check matchMedia("(prefers-reduced-motion: reduce)") and switch the theme instantly when it matches.