{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"theme-toggle","type":"registry:component","title":"Theme Toggle","description":"Light and dark switch that reveals the new theme as a circle growing from the center of the screen or from the button itself.","author":"Ryan","dependencies":["clsx","lucide-react","motion","next-themes","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/theme-toggle.tsx","type":"registry:component","target":"@components/motion/theme-toggle.tsx","content":"\"use client\";\n// easeui.dev/components/motion/theme-toggle\n\nimport { Moon, Sun } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useTheme } from \"next-themes\";\nimport {\n  type ComponentPropsWithoutRef,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** \"circle\" grows from the center of the screen, \"ripple\" grows from the button. */\nexport type ThemeToggleVariant = \"circle\" | \"ripple\";\n\nexport interface ThemeToggleProps\n  extends Omit<ComponentPropsWithoutRef<\"button\">, \"children\" | \"onClick\"> {\n  /** Where the new theme spreads from. Default \"circle\". */\n  variant?: ThemeToggleVariant;\n  iconClassName?: string;\n}\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst STYLE_ID = \"easeui-theme-reveal\";\n\n// The new theme is revealed as a growing circle. The old page stays still\n// underneath, so nothing on screen appears to move.\nconst REVEAL_CSS = `\nhtml[data-theme-reveal]::view-transition-old(root),\nhtml[data-theme-reveal]::view-transition-new(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\nhtml[data-theme-reveal]::view-transition-new(root) {\n  animation: easeui-theme-reveal 400ms cubic-bezier(0.23, 1, 0.32, 1);\n}\n@keyframes easeui-theme-reveal {\n  from { clip-path: circle(0px at var(--reveal-x) var(--reveal-y)); }\n  to { clip-path: circle(var(--reveal-r) at var(--reveal-x) var(--reveal-y)); }\n}\n`;\n\ntype Point = { x: number; y: number };\n\n/** Distance from a point to the farthest corner of the viewport. */\nfunction coverRadius({ x, y }: Point) {\n  return Math.ceil(\n    Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)),\n  );\n}\n\nexport function useThemeToggle({ variant = \"circle\" }: { variant?: ThemeToggleVariant } = {}) {\n  const { resolvedTheme, setTheme } = useTheme();\n  const reduce = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n    const existing = document.getElementById(STYLE_ID);\n    if (existing) {\n      existing.textContent = REVEAL_CSS;\n      return;\n    }\n    const style = document.createElement(\"style\");\n    style.id = STYLE_ID;\n    style.textContent = REVEAL_CSS;\n    document.head.appendChild(style);\n  }, []);\n\n  const toggle = useCallback(\n    (origin?: Point) => {\n      const next = resolvedTheme === \"dark\" ? \"light\" : \"dark\";\n      const doc = document as Document & {\n        startViewTransition?: (update: () => void) => { finished: Promise<void> };\n      };\n      if (reduce || !doc.startViewTransition) {\n        setTheme(next);\n        return;\n      }\n\n      const point =\n        variant === \"ripple\" && origin\n          ? origin\n          : { x: window.innerWidth / 2, y: window.innerHeight / 2 };\n      const root = document.documentElement;\n      root.style.setProperty(\"--reveal-x\", `${point.x}px`);\n      root.style.setProperty(\"--reveal-y\", `${point.y}px`);\n      root.style.setProperty(\"--reveal-r\", `${coverRadius(point)}px`);\n      root.dataset.themeReveal = \"\";\n\n      doc\n        .startViewTransition(() => setTheme(next))\n        .finished.finally(() => {\n          delete root.dataset.themeReveal;\n        });\n    },\n    [reduce, resolvedTheme, setTheme, variant],\n  );\n\n  return { isDark: mounted && resolvedTheme === \"dark\", mounted, toggle };\n}\n\nexport function ThemeToggle({\n  variant = \"circle\",\n  className,\n  iconClassName,\n  ...props\n}: ThemeToggleProps) {\n  const { isDark, mounted, toggle } = useThemeToggle({ variant });\n  const reduce = useReducedMotion();\n  const Icon = isDark ? Sun : Moon;\n\n  return (\n    <button\n      type=\"button\"\n      aria-label={isDark ? \"Switch to light mode\" : \"Switch to dark mode\"}\n      onClick={(event) => {\n        const rect = event.currentTarget.getBoundingClientRect();\n        toggle({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });\n      }}\n      className={cn(\n        \"relative inline-flex touch-manipulation items-center justify-center overflow-hidden\",\n        className,\n      )}\n      {...props}\n    >\n      {mounted ? (\n        // The icon swaps by fading and scaling in place.\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span\n            key={isDark ? \"sun\" : \"moon\"}\n            initial={reduce ? false : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.6, transition: { duration: reduce ? 0 : 0.1, ease: EASE } }}\n            transition={{ duration: reduce ? 0 : 0.15, ease: EASE }}\n            className=\"inline-flex\"\n          >\n            <Icon aria-hidden=\"true\" className={cn(\"h-4 w-4\", iconClassName)} />\n          </motion.span>\n        </AnimatePresence>\n      ) : (\n        <span aria-hidden=\"true\" className={cn(\"h-4 w-4\", iconClassName)} />\n      )}\n    </button>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}