{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-theme-toggler",
  "type": "registry:ui",
  "title": "Theme Toggler",
  "description": "Theme toggle with View Transitions and animated clip-path masks (circle, polygons, star), optional viewport-centered origin.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/magicui/animated-theme-toggler.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { Moon, Sun } from \"lucide-react\"\nimport { flushSync } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type TransitionVariant =\n  | \"circle\"\n  | \"square\"\n  | \"triangle\"\n  | \"diamond\"\n  | \"hexagon\"\n  | \"rectangle\"\n  | \"star\"\n\ninterface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<\"button\"> {\n  duration?: number\n  variant?: TransitionVariant\n  /** When true, the transition expands from the viewport center instead of the button center. */\n  fromCenter?: boolean\n  /**\n   * Controlled theme value. When provided, the parent owns persistence\n   * (e.g. `next-themes`) and this component will not write to localStorage.\n   */\n  theme?: \"light\" | \"dark\"\n  /** Called on toggle. Pair with `theme` for controlled usage. */\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\n// All coordinates are percentages of the snapshot reference box: Chrome 150\n// renders absolute px clip-path coordinates on ::view-transition-new(root)\n// unscaled on fractional display scales (e.g. Windows 150%) for the first\n// transition after load, so px values land at the wrong position (#989).\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  // circle() percentage radii resolve against hypot(w, h) / sqrt(2) of the reference box.\n  const toRadius = (r: number) =>\n    `${(r / (Math.hypot(viewportWidth, viewportHeight) / Math.SQRT2)) * 100}%`\n\n  switch (variant) {\n    case \"circle\":\n      return [\n        `circle(0% at ${point(cx, cy)})`,\n        `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`,\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 \"triangle\": {\n      const scale = maxRadius * 2.2\n      const dx = (Math.sqrt(3) / 2) * scale\n      const verts = [\n        point(cx, cy - scale),\n        point(cx + dx, cy + 0.5 * scale),\n        point(cx - dx, cy + 0.5 * scale),\n      ].join(\", \")\n      return [polygonCollapsed(point(cx, cy), 3), `polygon(${verts})`]\n    }\n    case \"diamond\": {\n      // Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.\n      const R = maxRadius * Math.SQRT2\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 * Math.SQRT2\n      const verts: string[] = []\n      for (let i = 0; i < 6; i++) {\n        const a = -Math.PI / 2 + (i * Math.PI) / 3\n        verts.push(point(cx + R * Math.cos(a), cy + R * Math.sin(a)))\n      }\n      return [\n        polygonCollapsed(point(cx, cy), 6),\n        `polygon(${verts.join(\", \")})`,\n      ]\n    }\n    case \"rectangle\": {\n      const halfW = Math.max(cx, viewportWidth - cx)\n      const halfH = Math.max(cy, viewportHeight - cy)\n      const end = [\n        point(cx - halfW, cy - halfH),\n        point(cx + halfW, cy - halfH),\n        point(cx + halfW, cy + halfH),\n        point(cx - halfW, cy + halfH),\n      ].join(\", \")\n      return [polygonCollapsed(point(cx, cy), 4), `polygon(${end})`]\n    }\n    case \"star\": {\n      // Small overscan so the last frames never leave a 1px seam before the transition group ends.\n      const R = maxRadius * Math.SQRT2 * 1.03\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 = -Math.PI / 2 + (i * 2 * Math.PI) / 5\n          verts.push(\n            point(\n              cx + radius * Math.cos(outerA),\n              cy + radius * Math.sin(outerA)\n            )\n          )\n          const innerA = outerA + Math.PI / 5\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(0% at ${point(cx, cy)})`,\n        `circle(${toRadius(maxRadius)} at ${point(cx, cy)})`,\n      ]\n  }\n}\n\nexport const AnimatedThemeToggler = ({\n  className,\n  duration = 400,\n  variant,\n  fromCenter = false,\n  theme,\n  onThemeChange,\n  ...props\n}: AnimatedThemeTogglerProps) => {\n  const shape = variant ?? \"circle\"\n  const isControlled = theme !== undefined\n  const [internalIsDark, setInternalIsDark] = useState(false)\n  const isDark = isControlled ? theme === \"dark\" : internalIsDark\n  const buttonRef = useRef<HTMLButtonElement>(null)\n  const isTransitioningRef = useRef(false)\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    return () => observer.disconnect()\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    // innerWidth/innerHeight (not visualViewport): percentages must resolve\n    // against the snapshot reference box, which includes classic scrollbars.\n    const viewportWidth = window.innerWidth\n    const viewportHeight = window.innerHeight\n\n    let x: number\n    let y: number\n    if (fromCenter) {\n      x = viewportWidth / 2\n      y = viewportHeight / 2\n    } else {\n      const { top, left, width, height } = button.getBoundingClientRect()\n      x = left + width / 2\n      y = top + height / 2\n    }\n\n    const maxRadius = Math.hypot(\n      Math.max(x, viewportWidth - x),\n      Math.max(y, viewportHeight - y)\n    )\n\n    const applyTheme = () => {\n      const newTheme = !isDark\n      // Always toggle the class synchronously so the View Transitions API\n      // snapshots the new theme inside the startViewTransition callback.\n      document.documentElement.classList.toggle(\"dark\")\n      if (isControlled) {\n        onThemeChange?.(newTheme ? \"dark\" : \"light\")\n      } else {\n        setInternalIsDark(newTheme)\n        localStorage.setItem(\"theme\", newTheme ? \"dark\" : \"light\")\n      }\n    }\n\n    if (typeof document.startViewTransition !== \"function\") {\n      applyTheme()\n      return\n    }\n\n    const clipPath = getThemeTransitionClipPaths(\n      shape,\n      x,\n      y,\n      maxRadius,\n      viewportWidth,\n      viewportHeight\n    )\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    // Pin the collapsed clip-path via CSS so Firefox does not paint the new\n    // theme unclipped between snapshot and the ready.then() JS animation.\n    root.style.setProperty(\"--magicui-theme-vt-clip-from\", clipPath[0])\n    const cleanup = () => {\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    }\n\n    isTransitioningRef.current = true\n    const transition = document.startViewTransition(() => {\n      flushSync(applyTheme)\n    })\n    if (typeof transition?.finished?.finally === \"function\") {\n      transition.finished.finally(cleanup).catch(() => {})\n    } else {\n      cleanup()\n    }\n\n    const ready = transition?.ready\n    if (ready && typeof ready.then === \"function\") {\n      ready\n        .then(() => {\n          document.documentElement.animate(\n            {\n              clipPath,\n            },\n            {\n              duration,\n              // Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.\n              easing: shape === \"star\" ? \"linear\" : \"ease-in-out\",\n              fill: \"forwards\",\n              pseudoElement: \"::view-transition-new(root)\",\n            }\n          )\n        })\n        .catch(() => {})\n    }\n  }, [shape, fromCenter, duration, isDark, isControlled, onThemeChange])\n\n  return (\n    <button\n      type=\"button\"\n      ref={buttonRef}\n      onClick={toggleTheme}\n      className={cn(className)}\n      {...props}\n    >\n      {isDark ? <Sun /> : <Moon />}\n      <span className=\"sr-only\">Toggle theme</span>\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "css": {
    "::view-transition-old(root), ::view-transition-new(root)": {
      "animation": "none",
      "mix-blend-mode": "normal"
    }
  }
}