{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"theme-toggle","type":"registry:component","title":"Theme toggle","description":"The sun sets into a moon, and the new theme spreads from the button in a circle.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/theme-toggle.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { flushSync } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * THEME TOGGLE: the sun sets into a moon, and the room follows\n *\n *   icon       one round button: the sun's rays fold in and turn\n *              away while a shadow slides across the disc and\n *              carves the moon (and back at dawn)\n *   pill       the same icon with a label that morphs, \"Light\"\n *              into \"Dark\"\n *   segmented  Light, System and Dark, with a thumb thrown to the\n *              choice; its icon morphs the same way\n *   reveal     the new theme spreads out from the button in a\n *              circle (View Transitions); elsewhere it switches\n *              without a flash of animated colours\n *\n * On its own it remembers the choice, follows the system when\n * asked to and sets the dark class on <html>. Pass value and\n * onValueChange to drive it from next-themes or your own state.\n * ───────────────────────────────────────────────────────── */\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  variant?: \"icon\" | \"pill\" | \"segmented\";\n  value?: Theme;\n  defaultValue?: Theme;\n  onValueChange?: (theme: Theme) => void;\n  /** Where an uncontrolled toggle remembers the choice. */\n  storageKey?: string;\n  /** How an uncontrolled toggle marks dark mode on <html>: the \"dark\" class, or data-theme. */\n  attribute?: \"class\" | \"data-theme\";\n  /** Spread the new theme from the button in a circle, where the browser can. */\n  reveal?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\nconst OPTIONS: { value: Theme; label: string }[] = [\n  { value: \"light\", label: \"Light\" },\n  { value: \"system\", label: \"System\" },\n  { value: \"dark\", label: \"Dark\" },\n];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst darkQuery = \"(prefers-color-scheme: dark)\";\nconst subscribeQuery = (q: string) => (onChange: () => void) => {\n  const query = window.matchMedia(q);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useMedia = (q: string) =>\n  React.useSyncExternalStore(subscribeQuery(q), () => window.matchMedia(q).matches, () => false);\n\n/**\n * The sun and moon are one drawing: a disc that grows into the moon, eight rays that fold in,\n * and a shadow (a masked-out circle) that slides across to carve the crescent.\n */\nfunction SunMoon({ dark, reduced, className = \"size-[18px]\" }: { dark: boolean; reduced: boolean; className?: string }) {\n  const id = React.useId().replace(/:/g, \"\");\n  const t = (ms: number, ease = EASE) => (reduced ? \"none\" : `transform ${ms}ms ${ease}, opacity ${Math.round(ms * 0.6)}ms ${EASE}`);\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" className={className}>\n      <mask id={`${id}-shadow`}>\n        <rect width=\"24\" height=\"24\" fill=\"white\" />\n        <circle cx=\"12\" cy=\"12\" r=\"8\" fill=\"black\" style={{ transform: dark ? \"translate(5px,-5px)\" : \"translate(14px,-14px)\", transition: t(560) }} />\n      </mask>\n      <circle\n        cx=\"12\"\n        cy=\"12\"\n        r=\"8\"\n        fill=\"currentColor\"\n        mask={`url(#${id}-shadow)`}\n        style={{ transformOrigin: \"12px 12px\", transform: dark ? \"scale(1.05)\" : \"scale(0.55)\", transition: t(560, THROW) }}\n      />\n      <g\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        style={{ transformOrigin: \"12px 12px\", transform: dark ? \"rotate(-90deg) scale(0.4)\" : \"none\", opacity: dark ? 0 : 1, transition: t(520) }}\n      >\n        {[0, 45, 90, 135, 180, 225, 270, 315].map((a) => (\n          <line key={a} x1=\"12\" y1=\"2.5\" x2=\"12\" y2=\"4.5\" transform={`rotate(${a} 12 12)`} />\n        ))}\n      </g>\n    </svg>\n  );\n}\n\nfunction SystemIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\" className=\"size-4\">\n      <rect x=\"3\" y=\"4.5\" width=\"18\" height=\"12.5\" rx=\"2.5\" stroke=\"currentColor\" strokeWidth=\"1.7\" />\n      <path d=\"M9 20.5h6M12 17v3.5\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/** Switch themes inside a View Transition, revealed as a circle growing out of the button. */\nfunction withReveal(from: HTMLElement | null, apply: () => void, enabled: boolean) {\n  const doc = document as Document & { startViewTransition?: (cb: () => void) => { ready: Promise<void>; finished: Promise<void> } };\n  if (!enabled || !from || !doc.startViewTransition) {\n    // No transition API: switch with colour transitions held for a frame, so nothing flickers mid-change.\n    const hold = document.createElement(\"style\");\n    hold.textContent = \"*,*::before,*::after{transition:none!important}\";\n    document.head.appendChild(hold);\n    apply();\n    void document.body.offsetHeight;\n    requestAnimationFrame(() => hold.remove());\n    return;\n  }\n  const r = from.getBoundingClientRect();\n  const x = r.left + r.width / 2;\n  const y = r.top + r.height / 2;\n  const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));\n  // Only the circle should move: switch off the browser's default crossfade for this one transition.\n  const style = document.createElement(\"style\");\n  style.textContent = \"::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}\";\n  document.head.appendChild(style);\n  const transition = doc.startViewTransition(() => flushSync(apply));\n  transition.ready\n    .then(() =>\n      document.documentElement.animate(\n        { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },\n        { duration: 620, easing: \"cubic-bezier(0.65,0,0.35,1)\", pseudoElement: \"::view-transition-new(root)\" }\n      )\n    )\n    .catch(() => {});\n  transition.finished.finally(() => style.remove());\n}\n\nexport function ThemeToggle({\n  variant = \"icon\",\n  value,\n  defaultValue = \"system\",\n  onValueChange,\n  storageKey = \"theme\",\n  attribute = \"class\",\n  reveal = true,\n  className = \"\",\n  ...props\n}: ThemeToggleProps) {\n  const reduced = useMedia(reducedQuery);\n  const systemDark = useMedia(darkQuery);\n  const controlled = value !== undefined;\n  const [own, setOwn] = React.useState<Theme>(defaultValue);\n  const [ready, setReady] = React.useState(false);\n  const theme = value ?? own;\n  const resolved = theme === \"system\" ? (systemDark ? \"dark\" : \"light\") : theme;\n  const dark = resolved === \"dark\";\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const segRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [thumb, setThumb] = React.useState({ left: 0, width: 0, ready: false });\n\n  // Uncontrolled: pick up the remembered choice once mounted (the first paint never animates).\n  React.useEffect(() => {\n    if (!controlled) {\n      try {\n        const saved = localStorage.getItem(storageKey) as Theme | null;\n        if (saved === \"light\" || saved === \"dark\" || saved === \"system\") setOwn(saved);\n      } catch {}\n    }\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // Uncontrolled: mark <html> and follow the system while on \"system\".\n  React.useEffect(() => {\n    if (controlled) return;\n    const root = document.documentElement;\n    if (attribute === \"class\") root.classList.toggle(\"dark\", dark);\n    else root.setAttribute(\"data-theme\", resolved);\n    root.style.colorScheme = resolved;\n  }, [controlled, dark, resolved, attribute]);\n\n  const choose = (next: Theme) => {\n    if (next === theme) return;\n    const nextDark = (next === \"system\" ? systemDark : next === \"dark\") !== dark;\n    const apply = () => {\n      if (!controlled) {\n        setOwn(next);\n        try {\n          localStorage.setItem(storageKey, next);\n        } catch {}\n        const root = document.documentElement;\n        const r = next === \"system\" ? (systemDark ? \"dark\" : \"light\") : next;\n        if (attribute === \"class\") root.classList.toggle(\"dark\", r === \"dark\");\n        else root.setAttribute(\"data-theme\", r);\n        root.style.colorScheme = r;\n      }\n      onValueChange?.(next);\n    };\n    // Only reveal when the colours actually change (System to Light on a light system doesn't).\n    withReveal(rootRef.current, apply, reveal && !reduced && nextDark);\n  };\n\n  // Segmented: the thumb sits under the choice.\n  const index = Math.max(0, OPTIONS.findIndex((o) => o.value === theme));\n  React.useLayoutEffect(() => {\n    if (variant !== \"segmented\") return;\n    const measure = () => {\n      const el = segRefs.current[index];\n      if (!el) return;\n      setThumb((t) => (t.left === el.offsetLeft && t.width === el.offsetWidth ? t : { left: el.offsetLeft, width: el.offsetWidth, ready: t.width > 0 }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    segRefs.current.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [variant, index]);\n\n  const motion = reduced || !ready;\n  const label = dark ? \"Dark\" : \"Light\";\n\n  if (variant === \"segmented\") {\n    return (\n      <div\n        ref={rootRef}\n        role=\"radiogroup\"\n        aria-label=\"Theme\"\n        onKeyDown={(e) => {\n          const moves: Record<string, number> = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: OPTIONS.length - 1 };\n          if (!(e.key in moves)) return;\n          e.preventDefault();\n          const next = (moves[e.key] + OPTIONS.length) % OPTIONS.length;\n          choose(OPTIONS[next].value);\n          segRefs.current[next]?.focus();\n        }}\n        className={`relative inline-flex h-9 items-center rounded-full bg-muted p-0.5 ${className}`}\n        {...props}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n          style={{\n            width: thumb.width,\n            transform: `translateX(${thumb.left}px)`,\n            opacity: thumb.width ? 1 : 0,\n            transition: thumb.ready && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\",\n          }}\n        />\n        {OPTIONS.map((o, i) => {\n          const selected = o.value === theme;\n          return (\n            <button\n              key={o.value}\n              ref={(el) => {\n                segRefs.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={selected}\n              aria-label={o.label}\n              tabIndex={selected ? 0 : -1}\n              onClick={() => choose(o.value)}\n              className={`relative flex h-8 w-10 items-center justify-center rounded-full transition-colors duration-300 ${FOCUS} ${selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"}`}\n            >\n              {o.value === \"system\" ? <SystemIcon /> : <SunMoon dark={o.value === \"dark\"} reduced={motion} className=\"size-4\" />}\n            </button>\n          );\n        })}\n      </div>\n    );\n  }\n\n  return (\n    <div ref={rootRef} className={`inline-flex ${className}`} {...props}>\n      <button\n        type=\"button\"\n        // The pill's name starts with the word it shows, so voice control can say it.\n        aria-label={variant === \"pill\" ? `${label} theme, switch to ${dark ? \"light\" : \"dark\"}` : `Switch to ${dark ? \"light\" : \"dark\"} theme`}\n        aria-pressed={dark}\n        onClick={() => choose(dark ? \"light\" : \"dark\")}\n        className={`inline-flex h-9 items-center justify-center rounded-full text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-[background-color,box-shadow] duration-300 hover:bg-accent active:scale-[0.96] ${FOCUS} ${\n          variant === \"pill\" ? \"gap-2 pl-2.5 pr-3.5 text-[13px] font-medium\" : \"w-9\"\n        }`}\n      >\n        <SunMoon dark={dark} reduced={motion} />\n        {variant === \"pill\" && <TextMorph>{label}</TextMorph>}\n      </button>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/theme-toggle"}