Dark-mode toggle with a View Transitions circular reveal from the button where supported, persisting the dark class to localStorage.
npx shadcn@latest add @paragon/theme-toggle"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>
</>
);
}