"use client";
import * as React from "react";
import { motion } from "motion/react";
export type Theme = "light" | "dark" | "system";
type Spring = { stiffness: number; damping: number; mass: number };
export interface ThemeToggleProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "children" | "defaultValue"> {
/** Current theme (controlled). The component never stores or applies the theme itself. */
theme?: Theme;
/** Initial theme (uncontrolled). */
defaultTheme?: Theme;
/** Called with the theme the user picked. Apply it to your app here (class, data-theme, storage). */
onThemeChange?: (theme: Theme) => void;
/** Offer a third "system" option that follows prefers-color-scheme. */
includeSystem?: boolean;
/** A round icon button, or a sliding switch. */
variant?: "icon" | "switch";
size?: "sm" | "md" | "lg";
/** Reveal the new theme with a growing circle (View Transitions API, when supported). */
reveal?: boolean;
/** Length of the circular reveal in ms. */
revealDuration?: number;
/** Spring for the sun ↔ moon morph and the switch knob. */
spring?: Spring;
/** Sun colour and focus ring. */
accent?: string;
/** Accessible name of the two-state switch, or of the three-option group. Defaults to "Dark mode" / "Theme". */
label?: string;
disabled?: boolean;
}
const SIZES = {
sm: { button: 32, track: [48, 26], glyph: 16 },
md: { button: 40, track: [60, 32], glyph: 20 },
lg: { button: 48, track: [72, 38], glyph: 24 },
} as const;
const ORDER: Theme[] = ["light", "dark", "system"];
const NAMES: Record<Theme, string> = { light: "Light", dark: "Dark", system: "System" };
const round = (n: number) => Math.round(n * 100) / 100;
/** Eight sun rays between radius 7.5 and 10, rounded so server and client markup match. */
const RAYS = Array.from({ length: 8 }, (_, i) => {
const a = (i * Math.PI) / 4;
return { x1: round(12 + Math.cos(a) * 7.5), y1: round(12 + Math.sin(a) * 7.5), x2: round(12 + Math.cos(a) * 10), y2: round(12 + Math.sin(a) * 10) };
});
function subscribeMedia(query: string) {
return (callback: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
};
}
const subscribeReducedMotion = subscribeMedia("(prefers-reduced-motion: reduce)");
const subscribeDarkScheme = subscribeMedia("(prefers-color-scheme: dark)");
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* Resolves "system" to "light" or "dark" using prefers-color-scheme. SSR-safe: the server
* (and hydration) render "light" for "system", then the real preference is applied.
*/
export function useResolvedTheme(theme: Theme): "light" | "dark" {
const systemDark = React.useSyncExternalStore(
subscribeDarkScheme,
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
() => false,
);
return theme === "system" ? (systemDark ? "dark" : "light") : theme;
}
/** Sun that morphs into a moon: the core grows, a masking circle slides in to carve the crescent and the rays retract. */
function SunMoonGlyph({ dark, size, transition }: { dark: boolean; size: number; transition: object }) {
// useId output can contain characters that break url(#…) references, so keep it to safe characters.
const maskId = `ttm-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="overflow-visible">
<mask id={maskId}>
<rect width="24" height="24" fill="white" />
<motion.circle r="7" fill="black" initial={false} animate={{ cx: dark ? 17 : 30, cy: dark ? 7.5 : -6 }} transition={transition} />
</mask>
<motion.circle cx="12" cy="12" fill="currentColor" mask={`url(#${maskId})`} initial={false} animate={{ r: dark ? 8.5 : 5 }} transition={transition} />
<motion.g
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ transformOrigin: "12px 12px", transformBox: "view-box" }}
initial={false}
animate={{ rotate: dark ? 90 : 0, scale: dark ? 0.5 : 1, opacity: dark ? 0 : 1 }}
transition={transition}
>
{RAYS.map((ray, i) => (
<line key={i} {...ray} />
))}
</motion.g>
</svg>
);
}
function MonitorGlyph({ size }: { size: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</svg>
);
}
/**
* A light / dark toggle with a sun ↔ moon morph and an optional circular reveal powered by the
* View Transitions API (feature-detected; browsers without it, and reduced motion, switch instantly).
* It only reports the chosen theme: applying and persisting it stays in your app.
*/
export function ThemeToggle({
theme: themeProp,
defaultTheme = "light",
onThemeChange,
includeSystem = false,
variant = "icon",
size = "md",
reveal = true,
revealDuration = 500,
spring = { stiffness: 260, damping: 22, mass: 0.8 },
accent = "#FF7448",
label,
disabled = false,
className = "",
style,
...props
}: ThemeToggleProps) {
const reduceMotion = usePrefersReducedMotion();
const [internal, setInternal] = React.useState<Theme>(defaultTheme);
const theme = themeProp ?? internal;
const name = label ?? (includeSystem ? "Theme" : "Dark mode");
const resolved = useResolvedTheme(theme);
const isDark = resolved === "dark";
const dims = SIZES[size];
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
/** Resolves the view transition's update callback once React has committed the new theme. */
const pendingCommit = React.useRef<(() => void) | null>(null);
React.useEffect(() => {
const done = pendingCommit.current;
if (!done) return;
pendingCommit.current = null;
// Let sibling and parent effects (which may apply the theme) run first.
const id = window.setTimeout(done, 0);
return () => window.clearTimeout(id);
}, [theme, resolved]);
const apply = (next: Theme) => {
if (themeProp === undefined) setInternal(next);
onThemeChange?.(next);
};
const change = (next: Theme, origin: HTMLElement | null) => {
if (next === theme || disabled) return;
const nextResolved = next === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : next;
const canReveal =
reveal && !reduceMotion && origin && nextResolved !== resolved && typeof document.startViewTransition === "function";
if (!canReveal) {
apply(next);
return;
}
const rect = origin.getBoundingClientRect();
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(rect.top + rect.height / 2);
const radius = Math.ceil(Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)));
// Replace the default cross-fade with a plain reveal for the duration of this transition only.
const override = document.createElement("style");
override.textContent =
"::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}";
document.head.appendChild(override);
const transitionHandle = document.startViewTransition(
() =>
new Promise<void>((resolve) => {
pendingCommit.current = resolve;
apply(next);
// Safety net: never hold the page frozen if the parent ignores the change.
window.setTimeout(resolve, 300);
}),
);
transitionHandle.ready
.then(() => {
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{ duration: revealDuration, easing: "cubic-bezier(0.22, 1, 0.36, 1)", pseudoElement: "::view-transition-new(root)" },
);
})
.catch(() => {});
const cleanup = () => override.remove();
transitionHandle.finished.then(cleanup, cleanup);
};
const focusRing =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tt-accent)]";
const glyphColour = isDark ? "text-zinc-700 dark:text-zinc-100" : "text-[var(--tt-accent)]";
const rootStyle = { ["--tt-accent" as string]: accent, ...style };
/* ------------------------------- icon variant ------------------------------ */
if (variant === "icon") {
const options = includeSystem ? ORDER : (["light", "dark"] as Theme[]);
const next = includeSystem ? options[(options.indexOf(theme) + 1) % options.length] : isDark ? "light" : "dark";
return (
<div className={`inline-flex ${className}`} style={rootStyle} {...props}>
<button
type="button"
// Two states: a real switch. Three states: a cycling button whose name states the current and next theme.
role={includeSystem ? undefined : "switch"}
aria-checked={includeSystem ? undefined : isDark}
aria-label={includeSystem ? `${name}: ${NAMES[theme]}. Switch to ${NAMES[next]}` : name}
title={includeSystem ? `${name}: ${NAMES[theme]}` : name}
disabled={disabled}
onClick={(event) => change(next, event.currentTarget)}
className={`relative grid place-items-center rounded-full border border-zinc-900/10 bg-white shadow-[0_1px_2px_rgb(0_0_0/0.05)] transition-colors duration-200 hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-zinc-900 dark:hover:bg-zinc-800 ${focusRing}`}
style={{ width: dims.button, height: dims.button }}
>
<span className={`inline-flex transition-colors duration-300 motion-reduce:transition-none ${glyphColour}`}>
<SunMoonGlyph dark={isDark} size={dims.glyph} transition={transition} />
</span>
{includeSystem && theme === "system" && (
<span
aria-hidden="true"
className="absolute -bottom-0.5 -right-0.5 grid size-[15px] place-items-center rounded-full border border-zinc-900/10 bg-zinc-900 font-mono text-[8px] font-semibold leading-none text-white dark:border-white/10 dark:bg-white dark:text-zinc-900"
>
A
</span>
)}
</button>
</div>
);
}
/* ------------------------------ switch variant ----------------------------- */
const [trackW, trackH] = dims.track;
const pad = 3;
const knob = trackH - pad * 2;
if (!includeSystem) {
return (
<div className={`inline-flex ${className}`} style={rootStyle} {...props}>
<button
type="button"
role="switch"
aria-checked={isDark}
aria-label={name}
disabled={disabled}
onClick={(event) => change(isDark ? "light" : "dark", event.currentTarget)}
className={`relative rounded-full border transition-colors duration-300 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none ${focusRing} ${
isDark ? "border-white/10 bg-zinc-900 dark:bg-zinc-950" : "border-zinc-900/10 bg-zinc-900/[0.06] dark:border-white/10 dark:bg-white/[0.12]"
}`}
style={{ width: trackW, height: trackH }}
>
<motion.span
aria-hidden="true"
className={`absolute left-[2px] top-[2px] grid place-items-center rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.2)] ${
isDark ? "text-zinc-700" : "text-[var(--tt-accent)]"
}`}
style={{ width: knob, height: knob }}
initial={false}
animate={{ x: isDark ? trackW - knob - 6 : 0 }}
transition={transition}
>
<SunMoonGlyph dark={isDark} size={Math.round(knob * 0.62)} transition={transition} />
</motion.span>
</button>
</div>
);
}
// Three options: a radio group whose knob slides between light, system and dark.
const segment = knob + 6;
const positions: Theme[] = ["light", "system", "dark"];
const index = positions.indexOf(theme);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const delta = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!delta) return;
event.preventDefault();
const nextIndex = (index + delta + positions.length) % positions.length;
const target = event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=radio]")[nextIndex];
target?.focus();
change(positions[nextIndex], target ?? null);
};
return (
<div
role="radiogroup"
aria-label={name}
aria-disabled={disabled || undefined}
onKeyDown={handleKeyDown}
className={`relative inline-flex rounded-full border border-zinc-900/10 bg-zinc-900/[0.05] dark:border-white/10 dark:bg-white/[0.08] ${
disabled ? "opacity-50" : ""
} ${className}`}
style={{ ...rootStyle, padding: pad, height: trackH + 2 }}
{...props}
>
<motion.span
aria-hidden="true"
className={`absolute grid place-items-center rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.2)] ${
theme === "system" ? "text-zinc-700" : isDark ? "text-zinc-700" : "text-[var(--tt-accent)]"
}`}
style={{ width: knob, height: knob, left: pad + 3, top: pad }}
initial={false}
animate={{ x: index * segment }}
transition={transition}
>
{theme === "system" ? <MonitorGlyph size={Math.round(knob * 0.56)} /> : <SunMoonGlyph dark={isDark} size={Math.round(knob * 0.62)} transition={transition} />}
</motion.span>
{positions.map((option) => {
const checked = option === theme;
return (
<button
key={option}
type="button"
role="radio"
aria-checked={checked}
aria-label={NAMES[option]}
title={NAMES[option]}
tabIndex={checked ? 0 : -1}
disabled={disabled}
onClick={(event) => change(option, event.currentTarget)}
className={`relative grid place-items-center rounded-full text-zinc-400 transition-colors duration-200 hover:text-zinc-700 disabled:cursor-not-allowed dark:text-zinc-500 dark:hover:text-zinc-200 ${focusRing}`}
style={{ width: segment, height: knob }}
>
<span aria-hidden="true" className={`transition-opacity duration-200 motion-reduce:transition-none ${checked ? "opacity-0" : "opacity-100"}`}>
{option === "system" ? (
<MonitorGlyph size={Math.round(knob * 0.5)} />
) : (
<SunMoonGlyph dark={option === "dark"} size={Math.round(knob * 0.56)} transition={{ duration: 0 }} />
)}
</span>
</button>
);
})}
</div>
);
}