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