"use client";
import * as React from "react";
import { motion } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
type SceneSwitchSize = "sm" | "md" | "lg";
export interface SceneSwitchProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"onChange" | "value" | "defaultValue" | "children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Controlled state. */
checked?: boolean;
/** Initial state when uncontrolled. */
defaultChecked?: boolean;
/** Called with the next state whenever the switch is toggled. */
onCheckedChange?: (checked: boolean) => void;
/** Names the switch. Rendered as a visible <label> unless `showLabel` is false (then it becomes aria-label). */
label?: string;
/** Show the label next to the switch. */
showLabel?: boolean;
/** Side of the switch the label sits on. */
labelPosition?: "start" | "end";
/** Which scene represents "on". The other scene is "off". */
checkedScene?: "night" | "day";
size?: SceneSwitchSize;
/** Stars twinkle at night (paused offscreen, in hidden tabs and for reduced motion). */
twinkle?: boolean;
/** Spring for the sun / moon disc's travel. */
spring?: Spring;
/** Focus ring colour. */
accent?: string;
}
const SIZES: Record<SceneSwitchSize, { w: number; h: number; pad: number; text: string }> = {
sm: { w: 56, h: 30, pad: 3, text: "text-[13px]" },
md: { w: 76, h: 40, pad: 4, text: "text-sm" },
lg: { w: 100, h: 52, pad: 5, text: "text-base" },
};
// Star field in a 76 × 40 box, on the side away from the disc. Fixed values keep SSR deterministic.
const STARS: { x: number; y: number; r: number; sparkle?: boolean }[] = [
{ x: 11, y: 11, r: 2.4, sparkle: true },
{ x: 22, y: 25, r: 0.9 },
{ x: 27, y: 9, r: 1.1 },
{ x: 33, y: 20, r: 1.8, sparkle: true },
{ x: 8, y: 28, r: 0.8 },
{ x: 17, y: 17, r: 0.7 },
{ x: 39, y: 31, r: 0.9 },
];
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
function useDocumentHidden() {
return React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
}
/** Four-point sparkle centred on (x, y). */
function sparklePath(x: number, y: number, r: number) {
const k = r * 0.28;
const f = (n: number) => Number(n.toFixed(2));
return `M${f(x)} ${f(y - r)}Q${f(x + k)} ${f(y - k)} ${f(x + r)} ${f(y)}Q${f(x + k)} ${f(y + k)} ${f(x)} ${f(y + r)}Q${f(x - k)} ${f(y + k)} ${f(x - r)} ${f(y)}Q${f(x - k)} ${f(y - k)} ${f(x)} ${f(y - r)}Z`;
}
/**
* An illustrated on / off switch: the track is a tiny sky — sun and soft clouds by day, moon and
* twinkling stars by night — and the thumb is the sun disc that springs across and turns into the moon.
* It is a real <button role="switch"> with aria-checked and is not tied to any theme: pass any label.
*/
export const SceneSwitch = React.forwardRef<HTMLButtonElement, SceneSwitchProps>(function SceneSwitch(
{
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
label = "Night mode",
showLabel = true,
labelPosition = "end",
checkedScene = "night",
size = "md",
twinkle = true,
spring = { stiffness: 380, damping: 26, mass: 0.9 },
accent = "#FF7448",
disabled,
id: idProp,
className = "",
style,
onClick,
...props
},
forwardedRef,
) {
const autoId = React.useId();
const id = idProp ?? `scene-switch-${autoId}`;
const reduceMotion = usePrefersReducedMotion();
const documentHidden = useDocumentHidden();
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const starsRef = React.useRef<SVGGElement>(null);
const [inView, setInView] = React.useState(false);
const [uncontrolled, setUncontrolled] = React.useState(defaultChecked);
const isControlled = checkedProp !== undefined;
const checked = isControlled ? checkedProp : uncontrolled;
const night = checkedScene === "night" ? checked : !checked;
const s = SIZES[size] ?? SIZES.md;
const thumb = s.h - s.pad * 2;
const travel = s.w - thumb - s.pad * 2;
const setRefs = React.useCallback(
(node: HTMLButtonElement | null) => {
buttonRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
// Twinkling only runs while the switch is on screen.
React.useEffect(() => {
const node = buttonRef.current;
if (!node || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
io.observe(node);
return () => io.disconnect();
}, []);
const twinkling = twinkle && night && inView && !documentHidden && !reduceMotion;
React.useEffect(() => {
const group = starsRef.current;
if (!twinkling || !group || typeof group.animate !== "function") return;
const animations = Array.from(group.querySelectorAll<SVGElement>("[data-star]")).map((star, i) =>
star.animate([{ opacity: 1 }, { opacity: 0.25 }, { opacity: 1 }], {
duration: 1700 + ((i * 397) % 1300),
delay: (i * 233) % 900,
iterations: Infinity,
easing: "ease-in-out",
}),
);
return () => animations.forEach((a) => a.cancel());
}, [twinkling]);
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) return;
const next = !checked;
if (!isControlled) setUncontrolled(next);
onCheckedChange?.(next);
};
const travelTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const fade = reduceMotion ? { duration: 0 } : { duration: 0.45, ease: [0.4, 0, 0.2, 1] as const };
const drift = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 160, damping: 20 };
// Decorations sit on the side away from the disc; mirror them when "on" is the day scene.
const mirrored = checkedScene === "day";
const control = (
<button
{...props}
ref={setRefs}
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={showLabel ? props["aria-label"] : (props["aria-label"] ?? label)}
disabled={disabled}
onClick={toggle}
className="group/scene relative inline-flex shrink-0 cursor-pointer overflow-hidden rounded-full outline-hidden [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ss-accent)] disabled:cursor-not-allowed disabled:opacity-45"
style={{ width: s.w, height: s.h }}
>
<span aria-hidden="true" className="pointer-events-none absolute inset-0">
{/* Skies: day underneath, night cross-fades over it. */}
<span className="absolute inset-0 bg-[linear-gradient(180deg,#5FB3EA_0%,#8DCEF5_100%)]" />
<motion.span
className="absolute inset-0 bg-[linear-gradient(180deg,#151B33_0%,#26305A_100%)]"
initial={false}
animate={{ opacity: night ? 1 : 0 }}
transition={fade}
/>
<svg
viewBox="0 0 76 40"
preserveAspectRatio="xMidYMid slice"
className={`absolute inset-0 size-full ${mirrored ? "-scale-x-100" : ""}`}
>
{/* Stars on the left (the moon rests on the right). */}
<motion.g
ref={starsRef}
initial={false}
animate={{ opacity: night ? 1 : 0, y: night ? 0 : -10 }}
transition={drift}
fill="#F4F1FF"
>
{STARS.map((star, i) =>
star.sparkle ? (
<path key={i} data-star="" d={sparklePath(star.x, star.y, star.r)} />
) : (
<circle key={i} data-star="" cx={star.x} cy={star.y} r={star.r} />
),
)}
</motion.g>
{/* Clouds on the right (the sun rests on the left). */}
<motion.g initial={false} animate={{ y: night ? 22 : 0, opacity: night ? 0 : 1 }} transition={drift}>
<g fill="#FFFFFF" opacity="0.55">
<circle cx="52" cy="25" r="7" />
<circle cx="61" cy="20" r="8.5" />
<circle cx="71" cy="17" r="8" />
<circle cx="80" cy="22" r="9" />
</g>
<g fill="#FFFFFF">
<circle cx="46" cy="36" r="7" />
<circle cx="55" cy="31" r="8" />
<circle cx="65" cy="30" r="7.5" />
<circle cx="74" cy="27" r="8.5" />
<rect x="40" y="34" width="44" height="12" />
</g>
</motion.g>
</svg>
{/* Depth: inner shadow along the top edge and a hairline rim. */}
<span className="absolute inset-0 rounded-full shadow-[inset_0_2px_5px_rgb(0_0_0/0.28),inset_0_0_0_1px_rgb(0_0_0/0.1)]" />
{/* The disc: sun by day, moon by night, with light halos that travel with it. */}
<motion.span
className="absolute"
style={{ left: s.pad, top: s.pad, width: thumb, height: thumb }}
initial={false}
animate={{ x: checked ? travel : 0 }}
transition={travelTransition}
>
{[3.3, 2.55, 1.8].map((scale) => (
<span
key={scale}
className="absolute left-1/2 top-1/2 rounded-full bg-white/[0.09]"
style={{ width: thumb * scale, height: thumb * scale, marginLeft: (-thumb * scale) / 2, marginTop: (-thumb * scale) / 2 }}
/>
))}
<span className="absolute inset-0 overflow-hidden rounded-full shadow-[0_1px_2px_rgb(0_0_0/0.3),0_4px_10px_-3px_rgb(0_0_0/0.4)]">
<span className="absolute inset-0 bg-[radial-gradient(circle_at_35%_30%,#FFE58A_0%,#FFC93C_55%,#F2A922_100%)] shadow-[inset_-2px_-3px_4px_rgb(190_110_0/0.35)]" />
<motion.span
className="absolute inset-0 bg-[radial-gradient(circle_at_35%_30%,#F7F6FB_0%,#DCDDE8_60%,#BFC2D2_100%)] shadow-[inset_-2px_-3px_4px_rgb(60_64_90/0.35)]"
initial={false}
animate={{ opacity: night ? 1 : 0 }}
transition={fade}
/>
{/* Craters roll in as the sun turns into the moon. */}
<motion.svg
viewBox="0 0 20 20"
className="absolute inset-0 size-full"
initial={false}
animate={{ opacity: night ? 1 : 0, rotate: night ? 0 : -110, scale: night ? 1 : 0.6 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 200, damping: 22 }}
>
<g fill="#A9ADC1">
<circle cx="12.6" cy="6.4" r="2.3" />
<circle cx="6.6" cy="11.4" r="1.6" />
<circle cx="12.4" cy="13.8" r="1.2" />
</g>
</motion.svg>
</span>
</motion.span>
</span>
</button>
);
return (
<span
className={`inline-flex items-center gap-3 ${labelPosition === "start" ? "flex-row-reverse" : ""} ${className}`}
style={{ ["--ss-accent" as string]: accent, ...style }}
>
{control}
{showLabel ? (
<label
htmlFor={id}
className={`select-none font-medium tracking-tight text-zinc-900 dark:text-zinc-100 ${s.text} ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
>
{label}
</label>
) : null}
</span>
);
});