{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-switch",
  "type": "registry:ui",
  "title": "Theme switcher",
  "description": "Four smooth ways to move between light and dark appearance.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/theme-switch/theme-switch.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Moon, Sun } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { Button } from \"../button/button\";\nimport { cn } from \"../lib/utils\";\n\nexport type ThemeSwitchVariant = \"reveal\" | \"eclipse\" | \"split\" | \"rise\";\nexport type Theme = \"light\" | \"dark\";\n\nexport interface ThemeSwitchProps {\n  theme: Theme;\n  variant?: ThemeSwitchVariant;\n  onThemeChange: (next: Theme, variant: ThemeSwitchVariant, trigger: HTMLElement) => void;\n  label?: string;\n  iconOnly?: boolean;\n}\n\n/** Each variant nudges the icon the way its page transition travels, and eases back when the theme returns. */\nconst darkNudge: Record<ThemeSwitchVariant, string> = {\n  reveal: \"[transform:scale(1.08)]\",\n  eclipse: \"[transform:translateX(-2px)]\",\n  split: \"[transform:translateX(1px)]\",\n  rise: \"[transform:translateY(-2px)]\",\n};\n\n/** Rotation and scale ride the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconSpring = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n  filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n} as const;\nconst iconExit = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } as const;\nconst blur = `blur(${motionTokens.blur.subtle}px)`;\n\n/** A stored preference usually reaches the theme prop just after hydration. That correction swaps the icon in place;\n *  only changes after the first painted frames animate, so a page that loads in dark mode never spins its switches. */\nfunction useSettled() {\n  const [settled, setSettled] = useState(false);\n  useEffect(() => {\n    let second = 0;\n    const first = requestAnimationFrame(() => {\n      second = requestAnimationFrame(() => setSettled(true));\n    });\n    return () => {\n      cancelAnimationFrame(first);\n      cancelAnimationFrame(second);\n    };\n  }, []);\n  return settled;\n}\n\n/** Both icons turn the same way (clockwise into dark, back out of it), so the swap reads as one rotation rather than two fades. */\nfunction ThemeIcon({ theme, reduced, settled }: { theme: Theme; reduced: boolean; settled: boolean }) {\n  const Icon = theme === \"light\" ? Sun : Moon;\n  const angle = theme === \"light\" ? 30 : -30;\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        key={theme}\n        className=\"absolute grid size-[18px] place-items-center\"\n        initial={!settled ? false : reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7, rotate: angle, filter: blur }}\n        animate={{ opacity: 1, scale: 1, rotate: 0, filter: \"blur(0px)\" }}\n        exit={\n          !settled\n            ? { opacity: 0, transition: { duration: 0 } }\n            : reduced\n              ? { opacity: 0, transition: { duration: motionTokens.duration.instant } }\n              : { opacity: 0, scale: 0.7, rotate: angle, filter: blur, transition: iconExit }\n        }\n        transition={reduced ? { duration: motionTokens.duration.instant } : iconSpring}\n        aria-hidden=\"true\"\n      >\n        <Icon size={16} strokeWidth={1.75} />\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\nexport function ThemeSwitch({ theme, variant = \"reveal\", onThemeChange, label, iconOnly = false }: ThemeSwitchProps) {\n  const reduced = useReducedMotion() ?? false;\n  const settled = useSettled();\n  const next = theme === \"light\" ? \"dark\" : \"light\";\n  // The library Button owns the press scale; these classes only reshape it and add the hover edge.\n  const classes = cn(\n    \"relative w-auto px-3 enabled:hover:border-line-strong\",\n    iconOnly && \"size-[38px] min-h-[38px] min-w-[38px] p-0 enabled:hover:bg-hover enabled:hover:shadow-none\",\n  );\n\n  return (\n    <Button\n      type=\"button\"\n      size=\"sm\"\n      variant=\"secondary\"\n      className={classes}\n      data-theme={theme}\n      aria-label={label ?? `Switch to ${next} mode`}\n      aria-pressed={theme === \"dark\"}\n      onClick={(event) => onThemeChange(next, variant, event.currentTarget)}\n    >\n      {/* The nudge only eases once the switch has settled, so a theme that arrives during hydration lands without motion. */}\n      <span\n        className={cn(\n          \"relative inline-grid size-5 flex-[0_0_20px] place-items-center\",\n          settled && \"[transition:transform_var(--duration-spring)_var(--ease-spring)] motion-reduce:transition-none\",\n          theme === \"dark\" && darkNudge[variant],\n        )}\n      >\n        <ThemeIcon theme={theme} reduced={reduced} settled={settled} />\n      </span>\n      {!iconOnly && <span className=\"whitespace-nowrap\">Switch theme</span>}\n    </Button>\n  );\n}\n\nexport default ThemeSwitch;\n",
      "type": "registry:component",
      "target": "@components/iris/theme-switch/theme-switch.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/theme-switch",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "ThemeSwitch",
    "tags": [
      "theme",
      "appearance",
      "motion"
    ],
    "docs": "https://irisui.dev/components/theme-switch"
  }
}
