Back to blog
Web Development5 min read

Creating a Smooth Theme Toggle Animation in React/Next.js

Learn how to build a stunning circular growth animation for your dark mode toggle using the View Transitions API. This step-by-step guide covers everything from basic implementation to advanced techniques for creating delightful theme transitions in React and Next.js applications.

Creating a Smooth Theme Toggle Animation in React/Next.js

Have you ever come across a website with a stunning dark mode transition where the theme appears to grow organically from the toggle switch? This elegant effect creates a truly delightful user experience. Let's build this modern interaction for your React or Next.js application using the View Transitions API.

Understanding the View Transitions API

Before diving into code, let's understand how this browser feature works:

  1. Capture current state: The browser takes a snapshot of your page
  2. Execute updates: Your code runs to change the page state
  3. Capture new state: Another snapshot is taken after updates
  4. Create pseudo-elements: Both snapshots load into special CSS pseudo-elements
  5. Animate transition: CSS animations blend between the old and new states
  6. Render result: Once complete, the browser displays the updated page

The beauty of this approach is that the browser handles the heavy lifting of creating smooth transitions between states.

Setting Up the Basic Toggle

Let's start with a simple dark mode toggle in React:

import { useState, useEffect } from 'react';

export default function ThemeToggle() {
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    if (isDark) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDark]);

  return (
    <div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
      <button 
        onClick={() => setIsDark(!isDark)}
        className="p-4 rounded-lg border"
      >
        {isDark ? '🌙' : '☀️'}
      </button>
    </div>
  );
}

This works, but there's no animation. Let's fix that.

Adding View Transitions

To use View Transitions, wrap your state update in document.startViewTransition():

const handleToggle = () => {
  document.startViewTransition(() => {
    setIsDark(!isDark);
  });
};

But there's a problem: React updates the DOM asynchronously. The transition might start before React actually updates the DOM, causing the animation to fail.

The flushSync Solution

React provides flushSync() to force synchronous DOM updates. This is one of the rare cases where using it is recommended:

import { flushSync } from 'react-dom';

const handleToggle = () => {
  document.startViewTransition(() => {
    flushSync(() => {
      setIsDark(!isDark);
    });
  });
};

Now you'll see a default fade animation between themes. Nice, but we want something more impressive.

Creating the Circular Growth Animation

The key to this impressive animation is using clip-path to create a circular mask that grows from the toggle button. Here's how:

Step 1: Get Button Position

Use a ref to track the button's position:

import { useRef } from 'react';

export default function ThemeToggle() {
  const [isDark, setIsDark] = useState(false);
  const buttonRef = useRef(null);

  // ... rest of code
}

Step 2: Calculate Circle Dimensions

We need to determine:

  • The center point of the button
  • The radius needed to cover the entire screen
const handleToggle = async () => {
  if (!buttonRef.current) return;

  await document.startViewTransition(() => {
    flushSync(() => {
      setIsDark(!isDark);
    });
  }).ready;

  // Get button dimensions and position
  const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
  
  // Calculate center of button
  const centerX = left + width / 2;
  const centerY = top + height / 2;
  
  // Calculate distances to screen edges
  const rightEdge = window.innerWidth - left;
  const bottomEdge = window.innerHeight - top;
  
  // Find the maximum radius needed to cover screen
  const radius = Math.hypot(
    Math.max(left, rightEdge),
    Math.max(top, bottomEdge)
  );
};

Step 3: Animate the Clip Path

Apply the animation to the new state pseudo-element:

document.documentElement.animate(
  {
    clipPath: [
      `circle(0px at ${centerX}px ${centerY}px)`,
      `circle(${radius}px at ${centerX}px ${centerY}px)`
    ]
  },
  {
    duration: 500,
    easing: 'ease-in-out',
    pseudoElement: '::view-transition-new(root)'
  }
);

Step 4: Disable Default Animations

Add this CSS to remove the default fade:

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

Handling Edge Cases

Make your implementation robust by checking for browser support and user preferences:

const handleToggle = async () => {
  // Check if browser supports View Transitions
  if (!document.startViewTransition) {
    setIsDark(!isDark);
    return;
  }

  // Respect user's motion preferences
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    setIsDark(!isDark);
    return;
  }

  // ... animation code
};

Complete Implementation

Here's the full working component:

import { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';

export default function ThemeToggle() {
  const [isDark, setIsDark] = useState(false);
  const buttonRef = useRef(null);

  useEffect(() => {
    if (isDark) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDark]);

  const handleToggle = async () => {
    if (
      !buttonRef.current ||
      !document.startViewTransition ||
      window.matchMedia('(prefers-reduced-motion: reduce)').matches
    ) {
      setIsDark(!isDark);
      return;
    }

    await document.startViewTransition(() => {
      flushSync(() => {
        setIsDark(!isDark);
      });
    }).ready;

    const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
    const centerX = left + width / 2;
    const centerY = top + height / 2;
    const rightEdge = window.innerWidth - left;
    const bottomEdge = window.innerHeight - top;
    const radius = Math.hypot(
      Math.max(left, rightEdge),
      Math.max(top, bottomEdge)
    );

    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${centerX}px ${centerY}px)`,
          `circle(${radius}px at ${centerX}px ${centerY}px)`
        ]
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)'
      }
    );
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950 transition-colors">
      <button
        ref={buttonRef}
        onClick={handleToggle}
        className="p-4 rounded-full border-2 hover:scale-110 transition-transform"
        aria-label="Toggle theme"
      >
        <span className="text-2xl">
          {isDark ? '🌙' : '☀️'}
        </span>
      </button>
    </div>
  );
}

This animation technique works anywhere on your page, making it perfect for creating engaging user experiences in React and Next.js applications.