Skip to main content
The ThemeToggle component provides an elegant way to switch between light and dark modes with a smooth circular reveal animation and optional sound effect.

Overview

Key features:
  • Circular reveal transition using View Transition API
  • Animated sun/moon icons with rotation
  • Sound effect on toggle
  • Fixed positioning in bottom-right corner
  • Spring animation on mount
  • Hover and tap effects

Component Structure

The ThemeToggle is a simple wrapper around the AnimatedThemeToggler:
ThemeToggle.jsx
import { AnimatedThemeToggler } from './ui/animated-theme-toggler';

const ThemeToggle = () => {
  return <AnimatedThemeToggler />;
};

export default ThemeToggle;

AnimatedThemeToggler Component

animated-theme-toggler.jsx
import { useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { flushSync } from 'react-dom';
import { useTheme } from '../../contexts/ThemeContext';
import { toggleSound } from '../../assets';

export function AnimatedThemeToggler({ duration = 400 }) {
  const { isDark, toggleTheme } = useTheme();
  const buttonRef = useRef(null);

  const handleToggle = useCallback(async () => {
    // Play sound effect
    const audio = new Audio(toggleSound);
    audio.volume = 0.5;
    audio.play().catch(() => {});

    // Check for View Transition API support
    if (!buttonRef.current || !document.startViewTransition) {
      toggleTheme();
      return;
    }

    // Start view transition
    await document.startViewTransition(() => {
      flushSync(() => {
        toggleTheme();
      });
    }).ready;

    // Calculate circular reveal animation
    const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
    const x = left + width / 2;
    const y = top + height / 2;
    const maxRadius = Math.hypot(
      Math.max(left, window.innerWidth - left),
      Math.max(top, window.innerHeight - top)
    );

    // Animate the transition
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${maxRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  }, [toggleTheme, duration]);

  return (
    <motion.button
      ref={buttonRef}
      onClick={handleToggle}
      className="fixed bottom-8 right-8 z-50 w-14 h-14 
        rounded-full bg-slate-700 dark:bg-slate-700 shadow-lg 
        flex items-center justify-center 
        border-2 border-slate-600 dark:border-slate-600"
      whileHover={{ scale: 1.1 }}
      whileTap={{ scale: 0.9 }}
      initial={{ scale: 0, opacity: 0 }}
      animate={{ scale: 1, opacity: 1 }}
      transition={{ type: 'spring', stiffness: 260, damping: 20 }}
    >
      <AnimatePresence mode="wait">
        {isDark ? (
          <motion.svg
            key="sun"
            initial={{ y: -20, opacity: 0, rotate: -90 }}
            animate={{ y: 0, opacity: 1, rotate: 0 }}
            exit={{ y: 20, opacity: 0, rotate: 90 }}
            transition={{ duration: 0.2 }}
            className="w-6 h-6 text-yellow-300"
            fill="currentColor"
            viewBox="0 0 20 20"
          >
            <path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
          </motion.svg>
        ) : (
          <motion.svg
            key="moon"
            initial={{ y: 20, opacity: 0, rotate: 90 }}
            animate={{ y: 0, opacity: 1, rotate: 0 }}
            exit={{ y: -20, opacity: 0, rotate: -90 }}
            transition={{ duration: 0.2 }}
            className="w-6 h-6 text-slate-200"
            fill="currentColor"
            viewBox="0 0 20 20"
          >
            <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
          </motion.svg>
        )}
      </AnimatePresence>
    </motion.button>
  );
}

Key Features Explained

1. View Transition API

Creates a smooth circular reveal effect:
const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
const x = left + width / 2;  // Center X
const y = top + height / 2;  // Center Y

// Calculate maximum radius to cover entire viewport
const maxRadius = Math.hypot(
  Math.max(left, window.innerWidth - left),
  Math.max(top, window.innerHeight - top)
);

// Animate from 0 to max radius
document.documentElement.animate(
  {
    clipPath: [
      `circle(0px at ${x}px ${y}px)`,
      `circle(${maxRadius}px at ${x}px ${y}px)`,
    ],
  },
  {
    duration: 400,
    easing: 'ease-in-out',
    pseudoElement: '::view-transition-new(root)',
  }
);
The View Transition API is a modern feature. The component falls back to instant theme change if not supported.

2. Sound Effect

const audio = new Audio(toggleSound);
audio.volume = 0.5;
audio.play().catch(() => {});
The .catch(() => {}) prevents errors if the browser blocks autoplay.

3. Icon Animations

Icons animate in/out with rotation:
{isDark ? (
  <motion.svg
    key="sun"
    initial={{ y: -20, opacity: 0, rotate: -90 }}
    animate={{ y: 0, opacity: 1, rotate: 0 }}
    exit={{ y: 20, opacity: 0, rotate: 90 }}
    transition={{ duration: 0.2 }}
  >
    {/* Sun icon */}
  </motion.svg>
) : (
  <motion.svg
    key="moon"
    initial={{ y: 20, opacity: 0, rotate: 90 }}
    animate={{ y: 0, opacity: 1, rotate: 0 }}
    exit={{ y: -20, opacity: 0, rotate: -90 }}
    transition={{ duration: 0.2 }}
  >
    {/* Moon icon */}
  </motion.svg>
)}

4. Button Animations

<motion.button
  whileHover={{ scale: 1.1 }}      // Grows on hover
  whileTap={{ scale: 0.9 }}        // Shrinks on click
  initial={{ scale: 0, opacity: 0 }} // Starts hidden
  animate={{ scale: 1, opacity: 1 }} // Animates in
  transition={{ type: 'spring', stiffness: 260, damping: 20 }}
>

Theme Context Integration

The component uses a ThemeContext:
const { isDark, toggleTheme } = useTheme();
Typical ThemeContext implementation:
ThemeContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext();

export const ThemeProvider = ({ children }) => {
  const [isDark, setIsDark] = useState(() => {
    const saved = localStorage.getItem('theme');
    return saved === 'dark' || 
      (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
  });

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

  const toggleTheme = () => setIsDark(!isDark);

  return (
    <ThemeContext.Provider value={{ isDark, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = () => useContext(ThemeContext);

Positioning

fixed bottom-8 right-8 z-50
  • fixed - Stays in place while scrolling
  • bottom-8 right-8 - 32px from bottom-right corner
  • z-50 - High z-index to stay above other content

Browser Support

Full support with circular reveal animation
  • Chrome 111+
  • Edge 111+
  • Safari 18+

Customization Options

Change Duration

<AnimatedThemeToggler duration={600} />

Change Position

className="fixed top-8 left-8 z-50 ..."

Change Colors

className="... bg-blue-600 dark:bg-blue-800 
  border-blue-500 dark:border-blue-600"

Disable Sound

Remove the audio playback:
const handleToggle = useCallback(async () => {
  // Remove these lines:
  // const audio = new Audio(toggleSound);
  // audio.volume = 0.5;
  // audio.play().catch(() => {});
  
  // Rest of the code...
}, [toggleTheme, duration]);

Dependencies

  • framer-motion - Icon animations
  • react-dom - flushSync for View Transition API
  • Custom ThemeContext - Theme state management

Accessibility

  • Keyboard accessible (button element)
  • Visual feedback on hover/focus
  • Semantic button element
  • Consider adding aria-label:
<motion.button
  aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
  // ... rest of props
>

Build docs developers (and LLMs) love