Talisson Costa
← All components

Theme Toggle

Light/dark switch where the new theme grows in a circle from the click, with a sun/moon morph.

  • View Transitions API
  • clip-path
  • next-themes
  • reduced motion

The new theme grows from where you click. With reduced motion, it just switches. Uses next-themes with attribute="class", the shadcn/ui default.

Install

npx shadcn@latest add https://tcosta.dev/r/theme-toggle.json

Or copy the files below into components/theme-toggle/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need next-themes.

Source

theme-toggle.tsx
'use client';

import type { ComponentProps, MouseEvent } from 'react';
import { cn } from '@/lib/utils';
import { MoonIcon, SunIcon } from './icons';
import { useThemeTransition } from './use-theme-transition';

type ThemeToggleProps = Omit<ComponentProps<'button'>, 'children'>;

export function ThemeToggle({ className, onClick, ...props }: ThemeToggleProps) {
  const toggleTheme = useThemeTransition();

  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    onClick?.(event);
    if (event.defaultPrevented) return;
    // Keyboard clicks have no pointer position: grow from the button's center instead.
    const rect = event.currentTarget.getBoundingClientRect();
    const fromPointer = event.clientX !== 0 || event.clientY !== 0;
    toggleTheme({
      x: fromPointer ? event.clientX : rect.left + rect.width / 2,
      y: fromPointer ? event.clientY : rect.top + rect.height / 2,
    });
  };

  return (
    <button
      type="button"
      aria-label="Toggle theme"
      onClick={handleClick}
      className={cn(
        'relative inline-flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors outline-none',
        'hover:bg-accent hover:text-accent-foreground',
        'focus-visible:ring-[3px] focus-visible:ring-ring/50',
        className,
      )}
      {...props}
    >
      <SunIcon />
      <MoonIcon />
    </button>
  );
}
use-theme-transition.ts
import { useTheme } from 'next-themes';

type Origin = { x: number; y: number };

// Radius that covers the whole viewport from the origin, so the circle ends past every corner.
function coverRadius({ x, y }: Origin) {
  return Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
}

/** Switches light/dark, revealing the new theme in a circle that grows from `origin`. */
export function useThemeTransition() {
  const { resolvedTheme, setTheme } = useTheme();

  return async (origin: Origin) => {
    const next = resolvedTheme === 'dark' ? 'light' : 'dark';
    const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

    if (!document.startViewTransition || reduceMotion) {
      setTheme(next);
      return;
    }

    // Apply the class ourselves so the new snapshot is ready immediately; next-themes persists it.
    const transition = document.startViewTransition(() => {
      document.documentElement.classList.toggle('dark', next === 'dark');
      setTheme(next);
    });
    await transition.ready;

    const { x, y } = origin;
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${coverRadius(origin)}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
        pseudoElement: '::view-transition-new(root)',
      },
    );
  };
}
icons.tsx
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';

function Icon({ className, children }: ComponentProps<'svg'>) {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className={cn(
        'transition-[scale,rotate,opacity] duration-300 motion-reduce:transition-none',
        className,
      )}
    >
      {children}
    </svg>
  );
}

// Both icons are always rendered and swapped with `dark:` classes: no hydration mismatch.
export function SunIcon() {
  return (
    <Icon className="scale-100 rotate-0 dark:scale-50 dark:-rotate-90 dark:opacity-0">
      <circle cx="12" cy="12" r="4" />
      <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
    </Icon>
  );
}

export function MoonIcon() {
  return (
    <Icon className="absolute scale-50 rotate-90 opacity-0 dark:scale-100 dark:rotate-0 dark:opacity-100">
      <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
    </Icon>
  );
}
index.ts
export { ThemeToggle } from './theme-toggle';