{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"theme-toggle","type":"registry:component","title":"Theme toggle","description":"A sun that folds its rays away and turns into a crescent moon.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/theme-toggle.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useId, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// No bounce: an icon that overshoots reads as a wobble, not a morph. 0.4s\n// of visual duration because the morph is the whole point of the control;\n// the spring's tail settles invisibly, so it still feels quick.\nconst MORPH = { type: \"spring\", visualDuration: 0.4, bounce: 0 } as const;\n// Rays go around the dial one by one; 8 x 18ms keeps the sweep under 150ms.\nconst RAY_STAGGER = 0.018;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\n// The sun's disc swells into the fuller moon disc.\nconst SUN_R = 4.5;\nconst MOON_R = 8;\n// Out, the rays clear the disc by 3px; in, they tuck under the moon.\nconst RAY_OUT = { inner: 7.5, outer: 10 };\nconst RAY_IN = { inner: 3.5, outer: 5 };\n\n// The bite circle slides along one straight line (5deg above 3 o'clock) so\n// it reads as sliding in, not sweeping. Parked 20px out it never touches\n// the sun; 7.5px out it leaves a classic crescent. The -40deg tilt then\n// carries the bite round to the familiar top right.\nconst BITE_R = 7.5;\nconst biteAt = (d: number) => ({\n  cx: 12 + d * Math.cos((-5 * Math.PI) / 180),\n  cy: 12 + d * Math.sin((-5 * Math.PI) / 180),\n});\nconst BITE_OUT = biteAt(20);\nconst BITE_IN = biteAt(7.5);\nconst MOON_TILT = -40;\n\nconst RAYS = Array.from({ length: 8 }, (_, i) => {\n  const a = (i * Math.PI) / 4;\n  return { cos: Math.cos(a), sin: Math.sin(a) };\n});\n\nexport function ThemeToggle({\n  dark,\n  onChange,\n  size = \"md\",\n  label = \"Dark mode\",\n  className,\n}: {\n  dark: boolean;\n  onChange: (dark: boolean) => void;\n  size?: \"md\" | \"lg\";\n  label?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  // React's ids can carry characters that break inside url(#...).\n  const maskId = `moon-${useId().replace(/[^\\w-]/g, \"\")}`;\n  // Reduced motion lands on each shape instantly; only the rays' opacity\n  // still fades so the swap is not a hard cut.\n  const morph = reduceMotion ? { duration: 0 } : MORPH;\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-pressed={dark}\n      onClick={() => onChange(!dark)}\n      className={cn(\n        \"relative flex touch-manipulation items-center justify-center rounded-full bg-muted text-foreground shadow-raised outline-hidden select-none\",\n        \"transition-[scale,background-color] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-[background-color]\",\n        \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n        \"hover:bg-background\",\n        size === \"lg\" ? \"size-14\" : \"size-10\",\n        className,\n      )}\n    >\n      <motion.svg\n        aria-hidden\n        viewBox=\"0 0 24 24\"\n        className={size === \"lg\" ? \"size-7\" : \"size-5\"}\n        initial={false}\n        animate={{ rotate: dark ? MOON_TILT : 0 }}\n        transition={morph}\n      >\n        <defs>\n          <mask\n            id={maskId}\n            maskUnits=\"userSpaceOnUse\"\n            x=\"0\"\n            y=\"0\"\n            width=\"24\"\n            height=\"24\"\n          >\n            <rect width=\"24\" height=\"24\" fill=\"white\" />\n            <motion.circle\n              r={BITE_R}\n              fill=\"black\"\n              initial={false}\n              animate={dark ? BITE_IN : BITE_OUT}\n              transition={morph}\n            />\n          </mask>\n        </defs>\n        <motion.circle\n          cx=\"12\"\n          cy=\"12\"\n          fill=\"currentColor\"\n          mask={`url(#${maskId})`}\n          initial={false}\n          animate={{ r: dark ? MOON_R : SUN_R }}\n          transition={morph}\n        />\n        <g stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\">\n          {RAYS.map((ray, i) => {\n            const { inner, outer } = dark ? RAY_IN : RAY_OUT;\n            // Retract in dial order. Extend in reverse, 60ms late, once the\n            // disc has shrunk enough to give them room.\n            const delay = reduceMotion\n              ? 0\n              : dark\n                ? i * RAY_STAGGER\n                : 0.06 + (RAYS.length - 1 - i) * RAY_STAGGER;\n            return (\n              <motion.line\n                key={i}\n                initial={false}\n                animate={{\n                  x1: 12 + ray.cos * inner,\n                  y1: 12 + ray.sin * inner,\n                  x2: 12 + ray.cos * outer,\n                  y2: 12 + ray.sin * outer,\n                  opacity: dark ? 0 : 1,\n                }}\n                transition={{\n                  ...morph,\n                  delay,\n                  // Gone before they reach the disc going in; solid early\n                  // going out, so they read as growing rather than fading.\n                  opacity: {\n                    duration: dark ? 0.12 : 0.18,\n                    ease: EASE_OUT,\n                    delay,\n                  },\n                }}\n              />\n            );\n          })}\n        </g>\n      </motion.svg>\n    </button>\n  );\n}\n\nexport default function ThemeToggleDemo() {\n  const [dark, setDark] = useState(false);\n  return (\n    <div className=\"flex w-[min(360px,100%)] flex-col items-center gap-6\">\n      <div className=\"flex items-end gap-10\">\n        <figure className=\"flex flex-col items-center gap-3\">\n          <ThemeToggle dark={dark} onChange={setDark} />\n          <figcaption className=\"text-xs text-muted-foreground tabular-nums\">\n            40px\n          </figcaption>\n        </figure>\n        <figure className=\"flex flex-col items-center gap-3\">\n          <ThemeToggle dark={dark} onChange={setDark} size=\"lg\" />\n          <figcaption className=\"text-xs text-muted-foreground tabular-nums\">\n            56px\n          </figcaption>\n        </figure>\n      </div>\n      <p className=\"text-center text-sm text-pretty text-muted-foreground\">\n        Demo state only. The lab&apos;s own theme stays as it is.\n      </p>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/theme-toggle"}