{
  "slug": "theme-toggle",
  "name": "Theme Toggle",
  "description": "View Transitions API theme toggle with geometric clip-path ripples, optional obsidian candy styling, and mode synchronization.",
  "summary": "A theme toggle for switching between dark and light modes. Uses the View Transitions API with circle and polygon clip paths that expand across the viewport. Set candy for an obsidian button with a specular line and glass sheen; without it the button is unstyled apart from className.",
  "category": "sliders-and-toggles",
  "tags": [
    "theme",
    "toggle",
    "switch",
    "dark-mode",
    "view-transitions",
    "button",
    "candy",
    "morph",
    "ripple"
  ],
  "dependencies": [
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-09-26",
  "updatedDate": "2026-09-26",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Hardware-accelerated native View Transitions API with polygon and circular clipping path morphing",
    "Seven geometric ripple shape transitions: circle, square, triangle, diamond, hexagon, rectangle, and star",
    "Optional obsidian candy button style (candy prop) with radial gradient, specular line and glass sheen",
    "Uncontrolled mode follows the html .dark class via MutationObserver, storage and theme-change events, and saves the choice to localStorage \"theme\"",
    "Graceful degradation when the View Transitions API is unsupported or reduced motion is active"
  ],
  "anatomy": [
    "<button> (Unstyled unless candy, which adds the obsidian gradient)",
    "::after (Specular line along the top edge, candy only)",
    "<span> (Glass sheen, candy only)",
    "<span> (Animated Lucide Sun and Moon icons with rotation micro-interactions)"
  ],
  "physics": {
    "engine": "View Transitions API & CSS Radial Shaders",
    "description": "Screen-wide clip-path expansion through the View Transitions API, plus CSS press compression in candy mode.",
    "parameters": [
      {
        "label": "Ripple Duration",
        "value": "450ms ease-in-out (linear for star), duration prop"
      },
      {
        "label": "Clip Geometry",
        "value": "Computed from the button center, or the viewport center with fromCenter"
      },
      {
        "label": "Glass Sheen",
        "value": "180deg linear-gradient(rgba(255,255,255,0.12) 0%, transparent 100%)"
      },
      {
        "label": "Tactile Compression",
        "value": "scale-[0.98] on press, 200ms ease-out, candy only"
      }
    ]
  },
  "accessibility": {
    "role": "button",
    "aria": "aria-label=\"Toggle theme\". The current theme is not exposed (no aria-pressed or live region).",
    "reducedMotion": "Suppresses screen-wide clip path ripple when prefers-reduced-motion is active.",
    "keyboard": [
      {
        "key": "Tab",
        "description": "Focus the toggle."
      },
      {
        "key": "Enter / Space",
        "description": "Triggers theme transition with ripple morph."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Navigation bars, studio headers, and command palettes where smooth theme switching is desired",
      "Interactive component libraries showcasing dark and light design tokens",
      "Creative landing pages and portfolios featuring expressive micro-interactions"
    ],
    "bestPractices": [
      "Use candy mode for tactile obsidian hero action matching GitHub candy button",
      "Set fromCenter to expand the ripple from the middle of the viewport",
      "Respect user prefers-reduced-motion preference"
    ]
  },
  "props": [
    {
      "name": "variant",
      "type": "\"circle\" | \"square\" | \"triangle\" | \"diamond\" | \"hexagon\" | \"rectangle\" | \"star\"",
      "defaultValue": "\"circle\"",
      "description": "Geometric shape of the View Transition clip-path ripple across the viewport."
    },
    {
      "name": "duration",
      "type": "number",
      "defaultValue": "450",
      "description": "Duration of the view transition animation in milliseconds."
    },
    {
      "name": "fromCenter",
      "type": "boolean",
      "defaultValue": "false",
      "description": "When true, ripple emanates from the center of the viewport instead of the button click position."
    },
    {
      "name": "candy",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Renders the tactile obsidian candy button style with specular reflections and glass sheen."
    },
    {
      "name": "theme",
      "type": "\"light\" | \"dark\"",
      "defaultValue": "undefined",
      "description": "Optional controlled theme state. When omitted, automatically synchronizes with document.documentElement."
    },
    {
      "name": "onThemeChange",
      "type": "(theme: \"light\" | \"dark\") => void",
      "defaultValue": "undefined",
      "description": "Callback fired whenever the theme changes."
    }
  ],
  "usage": "import { ThemeToggle } from \"@/components/ui/theme-toggle\";\n\nexport function ThemeToggleDemo() {\n  return <ThemeToggle variant=\"circle\" candy />;\n}",
  "files": [
    {
      "name": "theme-toggle.tsx",
      "path": "registry/ui/theme-toggle.tsx",
      "code": "\"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",
      "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"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "Theme Toggle"
}
