Meridian
A day/night theme switch built as a miniature diorama: the sun sets and the moon rises along a shallow arc, the sky crossfades through dusk, clouds drift out, and stars ignite staggered once the moon seats. A real role=switch with keyboard toggle; controlled or uncontrolled.
motionfree
"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface MeridianPalette {
/** Top sky gradient stop. */
skyTop: string;
/** Bottom sky gradient stop. */
skyBottom: string;
/** Celestial body core color (sun in day, moon in night). */
body: string;
}
export interface MeridianProps
extends Omit<React.ComponentPropsWithoutRef<"button">, "onChange"> {
/** Controlled night state — `true` is night/dark. */
checked?: boolean;
/** Initial night state when uncontrolled. @default false */
defaultChecked?: boolean;
/** Fires on every toggle with the next night state. */
onChange?: (checked: boolean) => void;
/** Size multiplier applied to the whole diorama. @default 1 */
scale?: number;
/**
* Seconds the arc + scene transition takes (spring visual duration).
* @default 0.6
*/
duration?: number;
/** Stars that ignite, staggered, once the moon seats. @default 7 */
starCount?: number;
/** Clouds that drift in the day sky. @default 3 */
cloudCount?: number;
/** Day palette overrides — warm sun on blue sky. */
dayColors?: Partial<MeridianPalette>;
/** Night palette overrides — cool moon on indigo sky. */
nightColors?: Partial<MeridianPalette>;
/** Accessible label for the switch. @default "Toggle dark mode" */
label?: string;
}
const DAY_DEFAULT: MeridianPalette = {
skyTop: "#3aa0e8",
skyBottom: "#a6d9f6",
body: "#ffd24d",
};
const NIGHT_DEFAULT: MeridianPalette = {
skyTop: "#111033",
skyBottom: "#2b2a5e",
body: "#e7ecfb",
};
/** Warm sunset band that peaks at mid-track as the two palettes cross. */
const DUSK_TOP = "#f0955a";
const DUSK_BOTTOM = "#6d5a9c";
/** Deterministic PRNG so scatter is identical on server and client. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface Star {
left: number;
top: number;
size: number;
cross: boolean;
twinkleDur: number;
twinkleDelay: number;
}
interface Cloud {
top: number;
left: number;
w: number;
driftDur: number;
driftDelay: number;
}
/**
* Meridian — a day/night theme switch built as a miniature diorama. The track
* is a sky and the thumb is the celestial body: toggling sends the sun setting
* and the moon rising along a shallow meridian arc that dips at mid-track while
* the sky crossfades day-blue → dusk → night-indigo. Clouds drift out ahead of
* the moon; stars ignite in a staggered scatter once it seats. A real
* `role="switch"` with keyboard toggle; the arc runs on transform only.
* Reduced motion collapses to an instant scene swap — no arc, no twinkle.
*/
export function Meridian({
checked,
defaultChecked = false,
onChange,
scale = 1,
duration = 0.6,
starCount = 7,
cloudCount = 3,
dayColors,
nightColors,
label = "Toggle dark mode",
className,
style,
disabled,
...rest
}: MeridianProps) {
const reducedMotion = useReducedMotion();
const isControlled = checked !== undefined;
const [internal, setInternal] = React.useState(defaultChecked);
const on = isControlled ? checked : internal;
const day = { ...DAY_DEFAULT, ...dayColors };
const night = { ...NIGHT_DEFAULT, ...nightColors };
// Geometry (px), all driven off the size multiplier.
const S = scale;
const W = 92 * S;
const H = 40 * S;
const PAD = 4 * S;
const THUMB = H - PAD * 2;
const TRAVEL = W - THUMB - PAD * 2;
const DIP = reducedMotion ? 0 : H * 0.16;
// Single spring drives the whole scene; every layer derives from it, so the
// arc, crossfade, and body swap stay locked in phase.
const progress = useMotionValue(on ? 1 : 0);
React.useEffect(() => {
// Reduced motion: snap instantly to the target (visualDuration:0 on a spring
// still resolves over several frames — it must be an immediate set, so the
// scene swaps with no arc/dusk/star-ignition, per the a11y contract).
if (reducedMotion) {
progress.set(on ? 1 : 0);
return;
}
const controls = animate(progress, on ? 1 : 0, {
type: "spring",
visualDuration: duration,
bounce: 0.28,
});
return controls.stop;
}, [on, reducedMotion, duration, progress]);
const x = useTransform(progress, [0, 1], [0, TRAVEL]);
const yArc = useTransform(progress, [0, 0.5, 1], [0, DIP, 0]);
const dayOpacity = useTransform(progress, [0, 1], [1, 0]);
const nightOpacity = useTransform(progress, [0, 1], [0, 1]);
const duskOpacity = useTransform(progress, [0, 0.5, 1], [0, 0.85, 0]);
const sunOpacity = useTransform(progress, [0, 0.55], [1, 0]);
const moonOpacity = useTransform(progress, [0.45, 1], [0, 1]);
const glowDay = useTransform(progress, [0, 1], [0.55, 0]);
const glowNight = useTransform(progress, [0, 1], [0, 0.5]);
const stars = React.useMemo<Star[]>(() => {
const rnd = mulberry32(0x51a2 + starCount);
return Array.from({ length: Math.max(0, starCount) }, () => {
const rx = rnd();
const ry = rnd();
return {
left: 8 + rx * 82,
top: 12 + ry * 52,
size: (1.4 + rnd() * 1.8) * S,
cross: rnd() > 0.62,
twinkleDur: 1.8 + rnd() * 2.2,
twinkleDelay: rnd() * 1.5,
};
});
}, [starCount, S]);
const clouds = React.useMemo<Cloud[]>(() => {
const rnd = mulberry32(0xc10d + cloudCount);
return Array.from({ length: Math.max(0, cloudCount) }, () => ({
top: 22 + rnd() * 34,
left: 30 + rnd() * 42,
w: (14 + rnd() * 12) * S,
driftDur: 7 + rnd() * 5,
driftDelay: rnd() * 3,
}));
}, [cloudCount, S]);
const seatDelay = reducedMotion ? 0 : duration * 0.62;
const starStagger = reducedMotion ? 0 : duration * 0.14;
const toggle = React.useCallback(() => {
if (disabled) return;
const next = !on;
if (!isControlled) setInternal(next);
onChange?.(next);
}, [disabled, on, isControlled, onChange]);
return (
<button
type="button"
role="switch"
aria-checked={on}
aria-label={label}
disabled={disabled}
onClick={toggle}
data-crucible="meridian"
data-state={on ? "night" : "day"}
className={cn(
"relative isolate inline-flex shrink-0 overflow-hidden rounded-full",
"cursor-pointer select-none outline-none",
"focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
style={{ width: W, height: H, ...style }}
{...rest}
>
{/* Sky — day, dusk, and night crossfaded by the same progress. */}
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
opacity: dayOpacity,
background: `linear-gradient(to bottom, ${day.skyTop}, ${day.skyBottom})`,
}}
/>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
opacity: nightOpacity,
background: `linear-gradient(to bottom, ${night.skyTop}, ${night.skyBottom})`,
}}
/>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
opacity: duskOpacity,
background: `linear-gradient(to bottom, ${DUSK_TOP}, ${DUSK_BOTTOM})`,
}}
/>
{/* Clouds drift out ahead of the rising moon. */}
{clouds.map((c, i) => (
<motion.span
key={`cloud-${i}`}
aria-hidden
className="pointer-events-none absolute"
style={{
top: `${c.top}%`,
left: `${c.left}%`,
width: c.w,
height: c.w * 0.42,
}}
animate={
on
? { opacity: 0, x: -14 * S, y: 3 * S }
: { opacity: 0.92, x: reducedMotion ? 0 : [0, 5 * S, 0], y: 0 }
}
transition={
on
? { duration: reducedMotion ? 0 : 0.4, ease: "easeIn" }
: reducedMotion
? { duration: 0 }
: {
opacity: { duration: 0.5, ease: "easeOut" },
x: {
duration: c.driftDur,
delay: c.driftDelay,
repeat: Infinity,
ease: "easeInOut",
},
}
}
>
<span
className="absolute bottom-0 left-0 rounded-full bg-white/90"
style={{ width: "100%", height: "62%", filter: "blur(0.4px)" }}
/>
<span
className="absolute rounded-full bg-white"
style={{
width: "48%",
height: "100%",
left: "22%",
bottom: "18%",
filter: "blur(0.4px)",
}}
/>
</motion.span>
))}
{/* Stars ignite, staggered, after the moon seats. */}
{stars.map((s, i) => (
<motion.span
key={`star-${i}`}
aria-hidden
className="pointer-events-none absolute rounded-full"
style={{
left: `${s.left}%`,
top: `${s.top}%`,
width: s.size,
height: s.size,
background: "#fdfdff",
boxShadow: s.cross
? `0 0 ${3 * S}px ${1 * S}px rgba(255,255,255,0.9)`
: `0 0 ${2 * S}px 0 rgba(255,255,255,0.7)`,
}}
animate={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.3 }}
transition={
on && !reducedMotion
? { delay: seatDelay + i * starStagger, duration: 0.5, ease: "easeOut" }
: { duration: reducedMotion ? 0 : 0.22 }
}
>
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-[#fdfdff]"
style={{
boxShadow: s.cross
? `0 0 ${3 * S}px ${1 * S}px rgba(255,255,255,0.9)`
: undefined,
}}
animate={
on && !reducedMotion ? { opacity: [1, 0.4, 1] } : { opacity: 1 }
}
transition={
on && !reducedMotion
? {
duration: s.twinkleDur,
delay: s.twinkleDelay,
repeat: Infinity,
ease: "easeInOut",
}
: { duration: 0 }
}
/>
</motion.span>
))}
{/* Thumb — the celestial body, arcing on transform only. */}
<motion.span
aria-hidden
className="absolute rounded-full"
style={{
left: PAD,
top: PAD,
width: THUMB,
height: THUMB,
x,
y: yArc,
}}
>
{/* Warm ambient glow (day) fades to cool (night). */}
<motion.span
className="absolute inset-0 rounded-full"
style={{
opacity: glowDay,
boxShadow: `0 0 ${10 * S}px ${2 * S}px ${day.body}, 0 0 ${20 * S}px ${4 * S}px rgba(255,190,90,0.55)`,
}}
/>
<motion.span
className="absolute inset-0 rounded-full"
style={{
opacity: glowNight,
boxShadow: `0 0 ${9 * S}px ${1 * S}px rgba(210,220,255,0.65)`,
}}
/>
{/* Sun */}
<motion.span
className="absolute inset-0 rounded-full"
style={{
opacity: sunOpacity,
background: `radial-gradient(circle at 38% 34%, #fff6d0 0%, ${day.body} 46%, #ff9a3d 100%)`,
boxShadow: `inset 0 0 ${4 * S}px 0 rgba(255,180,80,0.6)`,
}}
/>
{/* Moon — pale disc, seeded craters, a crescent inner shadow for form. */}
<motion.span
className="absolute inset-0 overflow-hidden rounded-full"
style={{
opacity: moonOpacity,
background: `radial-gradient(circle at 40% 36%, #ffffff 0%, ${night.body} 55%, #c4cbe6 100%)`,
boxShadow: `inset ${-3.5 * S}px ${-2.5 * S}px ${4 * S}px 0 rgba(70,80,120,0.45)`,
}}
>
<span
className="absolute rounded-full"
style={{
width: THUMB * 0.24,
height: THUMB * 0.24,
left: "22%",
top: "48%",
background: "#c8cee4",
boxShadow: "inset 0.5px 0.5px 1px rgba(90,98,130,0.5)",
}}
/>
<span
className="absolute rounded-full"
style={{
width: THUMB * 0.16,
height: THUMB * 0.16,
left: "56%",
top: "24%",
background: "#ccd2e6",
boxShadow: "inset 0.5px 0.5px 1px rgba(90,98,130,0.5)",
}}
/>
<span
className="absolute rounded-full"
style={{
width: THUMB * 0.13,
height: THUMB * 0.13,
left: "58%",
top: "62%",
background: "#c8cee4",
boxShadow: "inset 0.5px 0.5px 1px rgba(90,98,130,0.5)",
}}
/>
</motion.span>
</motion.span>
{/* Rim light + inner shadow so the pill reads as a glassy porthole. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
boxShadow:
"inset 0 1px 1px rgba(255,255,255,0.35), inset 0 -2px 6px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(0,0,0,0.14)",
}}
/>
</button>
);
}
Installation
CLI
npx shadcn@latest add @crucible/meridianManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | Controlled night state — true is night/dark. | |
| defaultChecked | boolean | false | Initial night state when uncontrolled. |
| onChange | (checked: boolean) => void | Fires on every toggle with the next night state. | |
| scale | number | 1 | Size multiplier applied to the whole diorama. |
| duration | number | 0.6 | Seconds the arc + scene transition takes (spring visual duration). |
| starCount | number | 7 | Stars that ignite, staggered, once the moon seats. |
| cloudCount | number | 3 | Clouds that drift in the day sky. |
| dayColors | Partial<MeridianPalette> | Day palette overrides — warm sun on blue sky. | |
| nightColors | Partial<MeridianPalette> | Night palette overrides — cool moon on indigo sky. | |
| label | string | "Toggle dark mode" | Accessible label for the switch. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"button">, "onChange"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.