# Theme Toggle

The sun eclipses into a moon, and the new theme spreads from the button as a circle.

**Category:** Display  
**Exports:** `ThemeToggle`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/theme-toggle.json
```
The file lands at `components/ui/theme-toggle.tsx`; import from `@/components/ui/theme-toggle`.

## API — Theme Toggle
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `theme` | `"light" \| "dark"` | — | Omit to let the toggle manage the <html> class. |
| `onThemeChange` | `(theme) => void` | — |  |
| `reveal` | `boolean` | `true` | Circular reveal via View Transitions. |
| `storageKey` | `string` | `"charcoal-theme"` |  |

## Example — Theme Toggle

```tsx
import { ThemeToggle } from "@/components/ui/theme-toggle";

export default function ThemeToggleDemo({ reveal = true, hint = "Click to switch the whole site." }: { reveal?: boolean; hint?: string }) {
  return (
    <div className="flex flex-col items-center gap-3">
      <ThemeToggle reveal={reveal} className="size-14 rounded-2xl border bg-background [&_svg]:size-6" />
      <p className="text-sm text-muted-foreground">{hint}</p>
    </div>
  );
}
```

## Source — components/ui/theme-toggle.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

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

export type ThemeToggleProps = Omit<React.ComponentProps<"button">, "onClick"> & {
  theme?: Theme;
  onThemeChange?: (theme: Theme) => void;
  /** localStorage key used when the toggle manages the theme itself. */
  storageKey?: string;
  /** Reveal the new theme with a circle growing out of the button (View Transitions API). */
  reveal?: boolean;
};

function readTheme(): Theme {
  if (typeof document === "undefined") return "dark";
  return document.documentElement.classList.contains("dark") ? "dark" : "light";
}

export function ThemeToggle({
  theme: themeProp,
  onThemeChange,
  storageKey = "charcoal-theme",
  reveal = true,
  className,
  ...props
}: ThemeToggleProps) {
  const reduce = useReducedMotion();
  const maskId = `moon${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`;
  const ref = React.useRef<HTMLButtonElement>(null);
  const [inner, setInner] = React.useState<Theme>("dark");
  React.useEffect(() => setInner(readTheme()), []);
  const theme = themeProp ?? inner;
  const dark = theme === "dark";

  const apply = (next: Theme) => {
    if (themeProp === undefined) {
      document.documentElement.classList.toggle("dark", next === "dark");
      try {
        localStorage.setItem(storageKey, next);
      } catch {}
      setInner(next);
    }
    onThemeChange?.(next);
  };

  const toggle = () => {
    const next: Theme = dark ? "light" : "dark";
    const doc = document as Document & { startViewTransition?: (cb: () => void) => { ready: Promise<void> } };
    if (!reveal || reduce || !doc.startViewTransition || !ref.current) return apply(next);

    const r = ref.current.getBoundingClientRect();
    const x = r.left + r.width / 2;
    const y = r.top + r.height / 2;
    const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
    const transition = doc.startViewTransition(() => apply(next));
    transition.ready.then(() => {
      document.documentElement.animate(
        { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
        { duration: 520, easing: "cubic-bezier(0.22, 1, 0.36, 1)", pseudoElement: "::view-transition-new(root)" },
      );
    });
  };

  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 300, damping: 22 };

  return (
    <button
      ref={ref}
      type="button"
      onClick={toggle}
      aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
      className={cn(
        "inline-flex size-9 cursor-pointer items-center justify-center rounded-lg text-foreground outline-none transition-colors",
        "hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      {...props}
    >
      <motion.svg viewBox="0 0 24 24" className="size-[18px]" animate={{ rotate: dark ? 40 : 90 }} transition={spring} aria-hidden>
        <mask id={maskId}>
          <rect width="24" height="24" fill="white" />
          <motion.circle r="7" fill="black" animate={{ cx: dark ? 17 : 30, cy: dark ? 7 : 0 }} transition={spring} />
        </mask>
        <motion.circle
          cx="12"
          cy="12"
          fill="currentColor"
          mask={`url(#${maskId})`}
          animate={{ r: dark ? 9 : 5 }}
          transition={spring}
        />
        <motion.g
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          animate={{ opacity: dark ? 0 : 1, scale: dark ? 0.5 : 1 }}
          style={{ originX: "12px", originY: "12px" }}
          transition={spring}
        >
          {Array.from({ length: 8 }, (_, i) => {
            const a = (i * Math.PI) / 4;
            return (
              <line
                key={i}
                x1={12 + Math.cos(a) * 8.5}
                y1={12 + Math.sin(a) * 8.5}
                x2={12 + Math.cos(a) * 10.5}
                y2={12 + Math.sin(a) * 10.5}
              />
            );
          })}
        </motion.g>
      </motion.svg>
    </button>
  );
}
```