# Meridian — Theme for React

> 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.

- Page: https://crucibleui.com/components/themes/meridian
- Category: [themes](https://crucibleui.com/components/themes)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/meridian
```

Registry JSON: https://crucibleui.com/r/meridian.json

Import from `@/components/crucible/meridian`.

## Props (`MeridianProps`)

| 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: `Omit<React.ComponentPropsWithoutRef<"button">, "onChange">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Meridian } from "@/components/crucible/meridian";

export default function MeridianDemo() {
  const [night, setNight] = React.useState(false);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.04),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-6 px-6">
        <Meridian
          checked={night}
          onChange={setNight}
          scale={1.35}
          aria-label="Toggle the sky between day and night"
        />
        <p className="text-center text-sm text-neutral-500">
          Toggle the sky — click it or focus and press Space.
        </p>
        <p className="text-center font-mono text-xs tracking-wide text-neutral-600">
          {night ? "night · moon risen" : "day · sun up"}
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`meridian.tsx`)

```tsx
"use client";

import { animate, motion, useMotionValue, useTransform } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/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,
          }}
          // Mount at the current state's pose — without an explicit initial,
          // motion would fade clouds out from full opacity on a night mount.
          initial={on ? { opacity: 0, x: -14 * S, y: 3 * S } : { opacity: 0.92, x: 0, y: 0 }}
          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)`,
          }}
          // Mount at the resting pose so a day mount never flashes stars that
          // then fade out (motion would otherwise start them at opacity 1).
          initial={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.3 }}
          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>
  );
}
```
