Buttons

Theme Toggle

Dark-mode toggle with a View Transitions circular reveal from the button where supported, persisting the dark class to localStorage.

Install

npx shadcn@latest add @paragon/theme-toggle

theme-toggle.tsx

"use client";

import * as React from "react";
import { flushSync } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Moon, Sun } from "lucide-react";
import { cn } from "@/lib/utils";

type Theme = "light" | "dark";

export interface ThemeToggleProps extends React.ComponentProps<"button"> {
  /** Skips the circular reveal and icon motion; theme still swaps. */
  static?: boolean;
}

/**
 * Dark-mode toggle. Where the View Transitions API is supported, the new
 * theme reveals in a circle growing from the button (the default cross-fade
 * is suppressed and the clip is driven via WAAPI on the ::view-transition
 * pseudo); everywhere else — and under prefers-reduced-motion — it's a plain
 * instant swap. Toggles the `dark` class on <html> and persists to
 * localStorage("theme"). The sun/moon swap uses the house icon-swap idiom.
 */
export function ThemeToggle({
  className,
  static: isStatic = false,
  onClick,
  ...props
}: ThemeToggleProps) {
  const reducedMotion = useReducedMotion();
  const ref = React.useRef<HTMLButtonElement>(null);
  // Theme is read from the DOM after mount — the server can't know it.
  const [theme, setTheme] = React.useState<Theme | null>(null);

  React.useEffect(() => {
    setTheme(
      document.documentElement.classList.contains("dark") ? "dark" : "light",
    );
  }, []);

  const apply = (next: Theme) => {
    setTheme(next);
    document.documentElement.classList.toggle("dark", next === "dark");
    try {
      localStorage.setItem("theme", next);
    } catch {
      // Storage unavailable (private mode) — the toggle still works.
    }
  };

  const toggle = () => {
    const next: Theme = (theme ?? "light") === "dark" ? "light" : "dark";
    const doc = document as Document & {
      startViewTransition?: (callback: () => void) => { ready: Promise<void> };
    };
    if (!doc.startViewTransition || reducedMotion || isStatic) {
      apply(next);
      return;
    }
    const rect = ref.current?.getBoundingClientRect();
    const x = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
    const y = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
    const radius = Math.hypot(
      Math.max(x, window.innerWidth - x),
      Math.max(y, window.innerHeight - y),
    );
    const transition = doc.startViewTransition(() => {
      flushSync(() => 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: 400,
            easing: "cubic-bezier(0.22, 1, 0.36, 1)",
            pseudoElement: "::view-transition-new(root)",
          },
        );
      })
      .catch(() => {
        // Transition was skipped (rapid toggling) — theme already applied.
      });
  };

  return (
    <>
      {/* Suppress the default cross-fade so the circle is the only motion. */}
      <style href="paragon-theme-toggle" precedence="paragon">{`
        ::view-transition-old(root),
        ::view-transition-new(root) {
          animation: none;
          mix-blend-mode: normal;
        }
      `}</style>
      <button
        ref={ref}
        type="button"
        data-slot="theme-toggle"
        aria-label={
          (theme ?? "light") === "dark"
            ? "Switch to light theme"
            : "Switch to dark theme"
        }
        onClick={(event) => {
          onClick?.(event);
          if (!event.defaultPrevented) toggle();
        }}
        className={cn(
          "relative flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
          !isStatic && "pressable",
          className,
        )}
        {...props}
      >
        {theme === null ? (
          <span aria-hidden className="size-4" />
        ) : (
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={theme}
              className="flex"
              initial={{
                opacity: 0,
                scale: isStatic || reducedMotion ? 1 : 0.25,
                filter: "blur(4px)",
              }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{
                opacity: 0,
                scale: isStatic || reducedMotion ? 1 : 0.25,
                filter: "blur(4px)",
              }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              {theme === "dark" ? (
                <Moon className="size-4" />
              ) : (
                <Sun className="size-4" />
              )}
            </motion.span>
          </AnimatePresence>
        )}
      </button>
    </>
  );
}