{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-toggle",
  "type": "registry:ui",
  "title": "Theme Toggle",
  "description": "View Transitions API theme toggle with geometric clip-path ripples, optional obsidian candy styling, and mode synchronization.",
  "dependencies": [
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/theme-toggle.tsx",
      "content": "\"use client\";\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Moon, Sun } from \"lucide-react\";\nimport { flushSync } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TransitionVariant =\n  | \"circle\"\n  | \"square\"\n  | \"triangle\"\n  | \"diamond\"\n  | \"hexagon\"\n  | \"rectangle\"\n  | \"star\";\n\nexport interface ThemeToggleProps\n  extends React.ComponentPropsWithoutRef<\"button\"> {\n  duration?: number;\n  variant?: TransitionVariant;\n  fromCenter?: boolean;\n  candy?: boolean;\n  theme?: \"light\" | \"dark\";\n  onThemeChange?: (theme: \"light\" | \"dark\") => void;\n}\n\nfunction polygonCollapsed(point: string, vertexCount: number): string {\n  const pairs = Array.from({ length: vertexCount }, () => point).join(\", \");\n  return `polygon(${pairs})`;\n}\n\nfunction getThemeTransitionClipPaths(\n  variant: TransitionVariant,\n  cx: number,\n  cy: number,\n  maxRadius: number,\n  viewportWidth: number,\n  viewportHeight: number,\n): [string, string] {\n  const toX = (x: number) => `${(x / viewportWidth) * 100}%`;\n  const toY = (y: number) => `${(y / viewportHeight) * 100}%`;\n  const point = (x: number, y: number) => `${toX(x)} ${toY(y)}`;\n  const cxPx = Math.round(cx);\n  const cyPx = Math.round(cy);\n  const rPx = Math.ceil(maxRadius * 1.12);\n\n  switch (variant) {\n    case \"circle\":\n      return [\n        `circle(0px at ${cxPx}px ${cyPx}px)`,\n        `circle(${rPx}px at ${cxPx}px ${cyPx}px)`,\n      ];\n    case \"square\": {\n      const halfW = Math.max(cx, viewportWidth - cx);\n      const halfH = Math.max(cy, viewportHeight - cy);\n      const halfSide = Math.max(halfW, halfH) * 1.05;\n      const end = [\n        point(cx - halfSide, cy - halfSide),\n        point(cx + halfSide, cy - halfSide),\n        point(cx + halfSide, cy + halfSide),\n        point(cx - halfSide, cy + halfSide),\n      ].join(\", \");\n      return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`];\n    }\n    case \"rectangle\": {\n      const w = Math.max(cx, viewportWidth - cx) * 1.05;\n      const h = Math.max(cy, viewportHeight - cy) * 1.05;\n      const end = [\n        point(cx - w, cy - h),\n        point(cx + w, cy - h),\n        point(cx + w, cy + h),\n        point(cx - w, cy + h),\n      ].join(\", \");\n      return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`];\n    }\n    case \"triangle\": {\n      const R = maxRadius * 1.5;\n      const start = [\n        point(cx, cy),\n        point(cx, cy),\n        point(cx, cy),\n      ].join(\", \");\n      const end = [\n        point(cx, cy - R),\n        point(cx + R * Math.cos(Math.PI / 6), cy + R * Math.sin(Math.PI / 6)),\n        point(cx - R * Math.cos(Math.PI / 6), cy + R * Math.sin(Math.PI / 6)),\n      ].join(\", \");\n      return [`polygon(${start})`, `polygon(${end})`];\n    }\n    case \"diamond\": {\n      const R = maxRadius * 1.1;\n      const end = [\n        point(cx, cy - R),\n        point(cx + R, cy),\n        point(cx, cy + R),\n        point(cx - R, cy),\n      ].join(\", \");\n      return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`];\n    }\n    case \"hexagon\": {\n      const R = maxRadius * 1.1;\n      const verts: string[] = [];\n      for (let i = 0; i < 6; i++) {\n        const a = (i * Math.PI) / 3 - Math.PI / 6;\n        verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a)));\n      }\n      return [polygonCollapsed(point(cx, cy), 6), `polygon(${verts.join(\", \")})`];\n    }\n    case \"star\": {\n      const R = maxRadius * 1.25;\n      const innerRatio = 0.42;\n      const starPolygon = (radius: number) => {\n        const verts: string[] = [];\n        for (let i = 0; i < 5; i++) {\n          const outerA = (i * 2 * Math.PI) / 5 - Math.PI / 2;\n          const innerA = outerA + Math.PI / 5;\n          verts.push(\n            point(cx + radius * Math.cos(outerA), cy + radius * Math.sin(outerA)),\n          );\n          verts.push(\n            point(\n              cx + radius * innerRatio * Math.cos(innerA),\n              cy + radius * innerRatio * Math.sin(innerA),\n            ),\n          );\n        }\n        return `polygon(${verts.join(\", \")})`;\n      };\n      const startR = Math.max(2, R * 0.025);\n      return [starPolygon(startR), starPolygon(R)];\n    }\n    default:\n      return [\n        `circle(0px at ${cxPx}px ${cyPx}px)`,\n        `circle(${rPx}px at ${cxPx}px ${cyPx}px)`,\n      ];\n  }\n}\n\nexport const ThemeToggle = ({\n  className,\n  duration = 450,\n  variant,\n  fromCenter = false,\n  candy = false,\n  theme,\n  onThemeChange,\n  ...props\n}: ThemeToggleProps) => {\n  const shape = variant ?? \"circle\";\n  const isControlled = theme !== undefined;\n  const [internalIsDark, setInternalIsDark] = useState(true);\n  const isDark = isControlled ? theme === \"dark\" : internalIsDark;\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const isTransitioningRef = useRef(false);\n  const activeAnimRef = useRef<Animation | null>(null);\n\n  const cancelAnim = useCallback(() => {\n    activeAnimRef.current?.cancel();\n    activeAnimRef.current = null;\n  }, []);\n\n  useEffect(() => {\n    return () => {\n      cancelAnim();\n      const root = document.documentElement;\n      if (root.dataset.magicuiThemeVt !== \"active\") return;\n      delete root.dataset.magicuiThemeVt;\n      root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\");\n      root.style.removeProperty(\"--magicui-theme-vt-clip-from\");\n    };\n  }, [cancelAnim]);\n\n  useEffect(() => {\n    if (isControlled) return;\n\n    const updateTheme = () => {\n      setInternalIsDark(document.documentElement.classList.contains(\"dark\"));\n    };\n\n    updateTheme();\n\n    const observer = new MutationObserver(updateTheme);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const handleStorage = (e: StorageEvent) => {\n      if (e.key === \"theme\") {\n        updateTheme();\n      }\n    };\n\n    const handleThemeChange = () => {\n      updateTheme();\n    };\n\n    window.addEventListener(\"storage\", handleStorage);\n    window.addEventListener(\"theme-change\", handleThemeChange);\n\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"storage\", handleStorage);\n      window.removeEventListener(\"theme-change\", handleThemeChange);\n    };\n  }, [isControlled]);\n\n  const toggleTheme = useCallback(() => {\n    const button = buttonRef.current;\n    if (\n      !button ||\n      isTransitioningRef.current ||\n      document.documentElement.dataset.magicuiThemeVt === \"active\"\n    )\n      return;\n\n    const viewportWidth = window.innerWidth;\n    const viewportHeight = window.innerHeight;\n\n    let cx: number;\n    let cy: number;\n\n    if (fromCenter) {\n      cx = viewportWidth / 2;\n      cy = viewportHeight / 2;\n    } else {\n      const rect = button.getBoundingClientRect();\n      cx = rect.left + rect.width / 2;\n      cy = rect.top + rect.height / 2;\n    }\n\n    const maxRadius = Math.hypot(\n      Math.max(cx, viewportWidth - cx),\n      Math.max(cy, viewportHeight - cy),\n    );\n\n    const clipPath = getThemeTransitionClipPaths(\n      shape,\n      cx,\n      cy,\n      maxRadius,\n      viewportWidth,\n      viewportHeight,\n    );\n\n    const applyTheme = () => {\n      const isCurrentlyDark =\n        document.documentElement.classList.contains(\"dark\");\n      const nextIsDark = !isCurrentlyDark;\n      if (nextIsDark) {\n        document.documentElement.classList.add(\"dark\");\n        document.documentElement.style.colorScheme = \"dark\";\n      } else {\n        document.documentElement.classList.remove(\"dark\");\n        document.documentElement.style.colorScheme = \"light\";\n      }\n      if (isControlled) {\n        onThemeChange?.(nextIsDark ? \"dark\" : \"light\");\n      } else {\n        setInternalIsDark(nextIsDark);\n      }\n      try {\n        localStorage.setItem(\"theme\", nextIsDark ? \"dark\" : \"light\");\n      } catch {}\n      window.dispatchEvent(\n        new StorageEvent(\"storage\", {\n          key: \"theme\",\n          newValue: nextIsDark ? \"dark\" : \"light\",\n        }),\n      );\n      window.dispatchEvent(\n        new CustomEvent(\"theme-change\", {\n          detail: { theme: nextIsDark ? \"dark\" : \"light\" },\n        }),\n      );\n    };\n\n    if (\n      typeof document === \"undefined\" ||\n      !(\"startViewTransition\" in document) ||\n      typeof document.startViewTransition !== \"function\" ||\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    ) {\n      applyTheme();\n      return;\n    }\n\n    const canAnimatePseudo = (() => {\n      try {\n        const test = document.documentElement.animate(\n          { opacity: [1, 1] },\n          { duration: 1, pseudoElement: \"::view-transition-new(root)\" },\n        );\n        test.cancel();\n        return true;\n      } catch {\n        return false;\n      }\n    })();\n\n    if (!canAnimatePseudo) {\n      try {\n        document.startViewTransition(() => {\n          flushSync(applyTheme);\n        });\n      } catch {\n        applyTheme();\n      }\n      return;\n    }\n\n    cancelAnim();\n\n    const root = document.documentElement;\n    root.dataset.magicuiThemeVt = \"active\";\n    root.style.setProperty(\n      \"--magicui-theme-toggle-vt-duration\",\n      `${duration}ms`,\n    );\n    root.style.setProperty(\"--magicui-theme-vt-clip-from\", clipPath[0]);\n\n    let safetyTimer: ReturnType<typeof setTimeout> | undefined;\n\n    const cleanup = () => {\n      if (safetyTimer) {\n        clearTimeout(safetyTimer);\n        safetyTimer = undefined;\n      }\n      isTransitioningRef.current = false;\n      delete root.dataset.magicuiThemeVt;\n      root.style.removeProperty(\"--magicui-theme-toggle-vt-duration\");\n      root.style.removeProperty(\"--magicui-theme-vt-clip-from\");\n      cancelAnim();\n    };\n\n    isTransitioningRef.current = true;\n    safetyTimer = setTimeout(cleanup, duration + 250);\n\n    let transition: ViewTransition | undefined;\n    try {\n      transition = document.startViewTransition(() => {\n        flushSync(applyTheme);\n      });\n    } catch {\n      cleanup();\n      applyTheme();\n      return;\n    }\n\n    if (typeof transition?.finished?.finally === \"function\") {\n      transition.finished.finally(cleanup).catch(() => {\n        cleanup();\n      });\n    } else {\n      cleanup();\n    }\n\n    const ready = transition?.ready;\n    if (ready && typeof ready.then === \"function\") {\n      ready\n        .then(() => {\n          const anim = document.documentElement.animate(\n            {\n              clipPath,\n            },\n            {\n              duration,\n              easing: shape === \"star\" ? \"linear\" : \"ease-in-out\",\n              fill: \"forwards\",\n              pseudoElement: \"::view-transition-new(root)\",\n            },\n          );\n          activeAnimRef.current = anim;\n        })\n        .catch(() => {});\n    }\n  }, [shape, fromCenter, duration, isControlled, onThemeChange, cancelAnim]);\n\n  const candyClasses = candy\n    ? \"relative inline-flex items-center justify-center font-medium leading-none tracking-[0.01em] select-none overflow-hidden cursor-pointer transition-all duration-200 ease-out active:scale-[0.98] active:brightness-95 hover:brightness-110 bg-[radial-gradient(120%_80%_at_50%_70%,#3f3f46_0%,#18181b_100%)] text-zinc-100 border border-white/10 shadow-[0px_4px_20px_-6px_rgba(0,0,0,0.4),inset_0px_1px_3px_0px_rgba(255,255,255,0.25),inset_0px_-2px_4px_0px_rgba(0,0,0,0.2)] hover:shadow-[0px_6px_24px_-4px_rgba(0,0,0,0.5),inset_0px_1px_3px_0px_rgba(255,255,255,0.35),inset_0px_-2px_4px_0px_rgba(0,0,0,0.2)] after:absolute after:top-0 after:left-[15%] after:right-[15%] after:h-px after:bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.55),transparent)] after:pointer-events-none\"\n    : \"\";\n\n  return (\n    <button\n      type=\"button\"\n      ref={buttonRef}\n      onClick={toggleTheme}\n      className={cn(\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500 focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:focus-visible:ring-zinc-300\",\n        className,\n        candyClasses,\n      )}\n      aria-label={isDark ? \"Switch to light theme\" : \"Switch to dark theme\"}\n      {...props}\n    >\n      {candy && (\n        <span className=\"absolute inset-x-0 top-0 h-1/2 rounded-t-[inherit] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,transparent_100%)] pointer-events-none\" />\n      )}\n      <span className=\"relative z-10 flex items-center justify-center\">\n        {isDark ? (\n          <Sun\n            className={cn(\n              \"h-4 w-4 transition-transform duration-300 hover:rotate-45\",\n              candy\n                ? \"text-zinc-100 drop-shadow-[0_1px_2px_rgba(0,0,0,0.4)]\"\n                : \"text-foreground\",\n            )}\n          />\n        ) : (\n          <Moon\n            className={cn(\n              \"h-4 w-4 transition-transform duration-300 hover:-rotate-12\",\n              candy\n                ? \"text-zinc-100 drop-shadow-[0_1px_2px_rgba(0,0,0,0.4)]\"\n                : \"text-foreground\",\n            )}\n          />\n        )}\n      </span>\n    </button>\n  );\n};\n\nexport const AnimatedThemeToggler = ThemeToggle;\nexport default ThemeToggle;\n",
      "type": "registry:ui",
      "target": "@ui/theme-toggle.tsx"
    }
  ]
}
