{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-toggle",
  "type": "registry:ui",
  "title": "Theme Toggle",
  "description": "A light / dark toggle with a sun-to-moon morph and an optional circular reveal using the View Transitions API.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/theme-toggle.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface ThemeToggleProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"children\" | \"defaultValue\"> {\n  /** Current theme (controlled). The component never stores or applies the theme itself. */\n  theme?: Theme;\n  /** Initial theme (uncontrolled). */\n  defaultTheme?: Theme;\n  /** Called with the theme the user picked. Apply it to your app here (class, data-theme, storage). */\n  onThemeChange?: (theme: Theme) => void;\n  /** Offer a third \"system\" option that follows prefers-color-scheme. */\n  includeSystem?: boolean;\n  /** A round icon button, or a sliding switch. */\n  variant?: \"icon\" | \"switch\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Reveal the new theme with a growing circle (View Transitions API, when supported). */\n  reveal?: boolean;\n  /** Length of the circular reveal in ms. */\n  revealDuration?: number;\n  /** Spring for the sun ↔ moon morph and the switch knob. */\n  spring?: Spring;\n  /** Sun colour and focus ring. */\n  accent?: string;\n  /** Accessible name of the two-state switch, or of the three-option group. Defaults to \"Dark mode\" / \"Theme\". */\n  label?: string;\n  disabled?: boolean;\n}\n\nconst SIZES = {\n  sm: { button: 32, track: [48, 26], glyph: 16 },\n  md: { button: 40, track: [60, 32], glyph: 20 },\n  lg: { button: 48, track: [72, 38], glyph: 24 },\n} as const;\n\nconst ORDER: Theme[] = [\"light\", \"dark\", \"system\"];\nconst NAMES: Record<Theme, string> = { light: \"Light\", dark: \"Dark\", system: \"System\" };\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** Eight sun rays between radius 7.5 and 10, rounded so server and client markup match. */\nconst RAYS = Array.from({ length: 8 }, (_, i) => {\n  const a = (i * Math.PI) / 4;\n  return { x1: round(12 + Math.cos(a) * 7.5), y1: round(12 + Math.sin(a) * 7.5), x2: round(12 + Math.cos(a) * 10), y2: round(12 + Math.sin(a) * 10) };\n});\n\nfunction subscribeMedia(query: string) {\n  return (callback: () => void) => {\n    const mq = window.matchMedia(query);\n    mq.addEventListener(\"change\", callback);\n    return () => mq.removeEventListener(\"change\", callback);\n  };\n}\nconst subscribeReducedMotion = subscribeMedia(\"(prefers-reduced-motion: reduce)\");\nconst subscribeDarkScheme = subscribeMedia(\"(prefers-color-scheme: dark)\");\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * Resolves \"system\" to \"light\" or \"dark\" using prefers-color-scheme. SSR-safe: the server\n * (and hydration) render \"light\" for \"system\", then the real preference is applied.\n */\nexport function useResolvedTheme(theme: Theme): \"light\" | \"dark\" {\n  const systemDark = React.useSyncExternalStore(\n    subscribeDarkScheme,\n    () => window.matchMedia(\"(prefers-color-scheme: dark)\").matches,\n    () => false,\n  );\n  return theme === \"system\" ? (systemDark ? \"dark\" : \"light\") : theme;\n}\n\n/** Sun that morphs into a moon: the core grows, a masking circle slides in to carve the crescent and the rays retract. */\nfunction SunMoonGlyph({ dark, size, transition }: { dark: boolean; size: number; transition: object }) {\n  // useId output can contain characters that break url(#…) references, so keep it to safe characters.\n  const maskId = `ttm-${React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  return (\n    <svg viewBox=\"0 0 24 24\" width={size} height={size} aria-hidden=\"true\" className=\"overflow-visible\">\n      <mask id={maskId}>\n        <rect width=\"24\" height=\"24\" fill=\"white\" />\n        <motion.circle r=\"7\" fill=\"black\" initial={false} animate={{ cx: dark ? 17 : 30, cy: dark ? 7.5 : -6 }} transition={transition} />\n      </mask>\n      <motion.circle cx=\"12\" cy=\"12\" fill=\"currentColor\" mask={`url(#${maskId})`} initial={false} animate={{ r: dark ? 8.5 : 5 }} transition={transition} />\n      <motion.g\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        style={{ transformOrigin: \"12px 12px\", transformBox: \"view-box\" }}\n        initial={false}\n        animate={{ rotate: dark ? 90 : 0, scale: dark ? 0.5 : 1, opacity: dark ? 0 : 1 }}\n        transition={transition}\n      >\n        {RAYS.map((ray, i) => (\n          <line key={i} {...ray} />\n        ))}\n      </motion.g>\n    </svg>\n  );\n}\n\nfunction MonitorGlyph({ size }: { size: number }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <rect x=\"3\" y=\"4\" width=\"18\" height=\"12\" rx=\"2\" />\n      <path d=\"M8 20h8M12 16v4\" />\n    </svg>\n  );\n}\n\n/**\n * A light / dark toggle with a sun ↔ moon morph and an optional circular reveal powered by the\n * View Transitions API (feature-detected; browsers without it, and reduced motion, switch instantly).\n * It only reports the chosen theme: applying and persisting it stays in your app.\n */\nexport function ThemeToggle({\n  theme: themeProp,\n  defaultTheme = \"light\",\n  onThemeChange,\n  includeSystem = false,\n  variant = \"icon\",\n  size = \"md\",\n  reveal = true,\n  revealDuration = 500,\n  spring = { stiffness: 260, damping: 22, mass: 0.8 },\n  accent = \"#FF7448\",\n  label,\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: ThemeToggleProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState<Theme>(defaultTheme);\n  const theme = themeProp ?? internal;\n  const name = label ?? (includeSystem ? \"Theme\" : \"Dark mode\");\n  const resolved = useResolvedTheme(theme);\n  const isDark = resolved === \"dark\";\n  const dims = SIZES[size];\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  /** Resolves the view transition's update callback once React has committed the new theme. */\n  const pendingCommit = React.useRef<(() => void) | null>(null);\n  React.useEffect(() => {\n    const done = pendingCommit.current;\n    if (!done) return;\n    pendingCommit.current = null;\n    // Let sibling and parent effects (which may apply the theme) run first.\n    const id = window.setTimeout(done, 0);\n    return () => window.clearTimeout(id);\n  }, [theme, resolved]);\n\n  const apply = (next: Theme) => {\n    if (themeProp === undefined) setInternal(next);\n    onThemeChange?.(next);\n  };\n\n  const change = (next: Theme, origin: HTMLElement | null) => {\n    if (next === theme || disabled) return;\n    const nextResolved = next === \"system\" ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\") : next;\n    const canReveal =\n      reveal && !reduceMotion && origin && nextResolved !== resolved && typeof document.startViewTransition === \"function\";\n    if (!canReveal) {\n      apply(next);\n      return;\n    }\n\n    const rect = origin.getBoundingClientRect();\n    const x = Math.round(rect.left + rect.width / 2);\n    const y = Math.round(rect.top + rect.height / 2);\n    const radius = Math.ceil(Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)));\n\n    // Replace the default cross-fade with a plain reveal for the duration of this transition only.\n    const override = document.createElement(\"style\");\n    override.textContent =\n      \"::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}\";\n    document.head.appendChild(override);\n\n    const transitionHandle = document.startViewTransition(\n      () =>\n        new Promise<void>((resolve) => {\n          pendingCommit.current = resolve;\n          apply(next);\n          // Safety net: never hold the page frozen if the parent ignores the change.\n          window.setTimeout(resolve, 300);\n        }),\n    );\n    transitionHandle.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: revealDuration, easing: \"cubic-bezier(0.22, 1, 0.36, 1)\", pseudoElement: \"::view-transition-new(root)\" },\n        );\n      })\n      .catch(() => {});\n    const cleanup = () => override.remove();\n    transitionHandle.finished.then(cleanup, cleanup);\n  };\n\n  const focusRing =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tt-accent)]\";\n  const glyphColour = isDark ? \"text-zinc-700 dark:text-zinc-100\" : \"text-[var(--tt-accent)]\";\n  const rootStyle = { [\"--tt-accent\" as string]: accent, ...style };\n\n  /* ------------------------------- icon variant ------------------------------ */\n  if (variant === \"icon\") {\n    const options = includeSystem ? ORDER : ([\"light\", \"dark\"] as Theme[]);\n    const next = includeSystem ? options[(options.indexOf(theme) + 1) % options.length] : isDark ? \"light\" : \"dark\";\n    return (\n      <div className={`inline-flex ${className}`} style={rootStyle} {...props}>\n        <button\n          type=\"button\"\n          // Two states: a real switch. Three states: a cycling button whose name states the current and next theme.\n          role={includeSystem ? undefined : \"switch\"}\n          aria-checked={includeSystem ? undefined : isDark}\n          aria-label={includeSystem ? `${name}: ${NAMES[theme]}. Switch to ${NAMES[next]}` : name}\n          title={includeSystem ? `${name}: ${NAMES[theme]}` : name}\n          disabled={disabled}\n          onClick={(event) => change(next, event.currentTarget)}\n          className={`relative grid place-items-center rounded-full border border-zinc-900/10 bg-white shadow-[0_1px_2px_rgb(0_0_0/0.05)] transition-colors duration-200 hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-zinc-900 dark:hover:bg-zinc-800 ${focusRing}`}\n          style={{ width: dims.button, height: dims.button }}\n        >\n          <span className={`inline-flex transition-colors duration-300 motion-reduce:transition-none ${glyphColour}`}>\n            <SunMoonGlyph dark={isDark} size={dims.glyph} transition={transition} />\n          </span>\n          {includeSystem && theme === \"system\" && (\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute -bottom-0.5 -right-0.5 grid size-[15px] place-items-center rounded-full border border-zinc-900/10 bg-zinc-900 font-mono text-[8px] font-semibold leading-none text-white dark:border-white/10 dark:bg-white dark:text-zinc-900\"\n            >\n              A\n            </span>\n          )}\n        </button>\n      </div>\n    );\n  }\n\n  /* ------------------------------ switch variant ----------------------------- */\n  const [trackW, trackH] = dims.track;\n  const pad = 3;\n  const knob = trackH - pad * 2;\n\n  if (!includeSystem) {\n    return (\n      <div className={`inline-flex ${className}`} style={rootStyle} {...props}>\n        <button\n          type=\"button\"\n          role=\"switch\"\n          aria-checked={isDark}\n          aria-label={name}\n          disabled={disabled}\n          onClick={(event) => change(isDark ? \"light\" : \"dark\", event.currentTarget)}\n          className={`relative rounded-full border transition-colors duration-300 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none ${focusRing} ${\n            isDark ? \"border-white/10 bg-zinc-900 dark:bg-zinc-950\" : \"border-zinc-900/10 bg-zinc-900/[0.06] dark:border-white/10 dark:bg-white/[0.12]\"\n          }`}\n          style={{ width: trackW, height: trackH }}\n        >\n          <motion.span\n            aria-hidden=\"true\"\n            className={`absolute left-[2px] top-[2px] grid place-items-center rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.2)] ${\n              isDark ? \"text-zinc-700\" : \"text-[var(--tt-accent)]\"\n            }`}\n            style={{ width: knob, height: knob }}\n            initial={false}\n            animate={{ x: isDark ? trackW - knob - 6 : 0 }}\n            transition={transition}\n          >\n            <SunMoonGlyph dark={isDark} size={Math.round(knob * 0.62)} transition={transition} />\n          </motion.span>\n        </button>\n      </div>\n    );\n  }\n\n  // Three options: a radio group whose knob slides between light, system and dark.\n  const segment = knob + 6;\n  const positions: Theme[] = [\"light\", \"system\", \"dark\"];\n  const index = positions.indexOf(theme);\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const delta = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 0;\n    if (!delta) return;\n    event.preventDefault();\n    const nextIndex = (index + delta + positions.length) % positions.length;\n    const target = event.currentTarget.querySelectorAll<HTMLButtonElement>(\"[role=radio]\")[nextIndex];\n    target?.focus();\n    change(positions[nextIndex], target ?? null);\n  };\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={name}\n      aria-disabled={disabled || undefined}\n      onKeyDown={handleKeyDown}\n      className={`relative inline-flex rounded-full border border-zinc-900/10 bg-zinc-900/[0.05] dark:border-white/10 dark:bg-white/[0.08] ${\n        disabled ? \"opacity-50\" : \"\"\n      } ${className}`}\n      style={{ ...rootStyle, padding: pad, height: trackH + 2 }}\n      {...props}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        className={`absolute grid place-items-center rounded-full bg-white shadow-[0_1px_3px_rgb(0_0_0/0.2)] ${\n          theme === \"system\" ? \"text-zinc-700\" : isDark ? \"text-zinc-700\" : \"text-[var(--tt-accent)]\"\n        }`}\n        style={{ width: knob, height: knob, left: pad + 3, top: pad }}\n        initial={false}\n        animate={{ x: index * segment }}\n        transition={transition}\n      >\n        {theme === \"system\" ? <MonitorGlyph size={Math.round(knob * 0.56)} /> : <SunMoonGlyph dark={isDark} size={Math.round(knob * 0.62)} transition={transition} />}\n      </motion.span>\n      {positions.map((option) => {\n        const checked = option === theme;\n        return (\n          <button\n            key={option}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={checked}\n            aria-label={NAMES[option]}\n            title={NAMES[option]}\n            tabIndex={checked ? 0 : -1}\n            disabled={disabled}\n            onClick={(event) => change(option, event.currentTarget)}\n            className={`relative grid place-items-center rounded-full text-zinc-400 transition-colors duration-200 hover:text-zinc-700 disabled:cursor-not-allowed dark:text-zinc-500 dark:hover:text-zinc-200 ${focusRing}`}\n            style={{ width: segment, height: knob }}\n          >\n            <span aria-hidden=\"true\" className={`transition-opacity duration-200 motion-reduce:transition-none ${checked ? \"opacity-0\" : \"opacity-100\"}`}>\n              {option === \"system\" ? (\n                <MonitorGlyph size={Math.round(knob * 0.5)} />\n              ) : (\n                <SunMoonGlyph dark={option === \"dark\"} size={Math.round(knob * 0.56)} transition={{ duration: 0 }} />\n              )}\n            </span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/theme-toggle"
}
