{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-switch-eclipse",
  "type": "registry:ui",
  "title": "Eclipse",
  "description": "The next appearance crosses the page like an eclipse.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/button.json"
  ],
  "files": [
    {
      "path": "registry/components/theme-switch/theme-switch.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./theme-switch.module.css\";\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/** Rotation and scale ride the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconSpring = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } 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(() => { second = requestAnimationFrame(() => setSettled(true)); });\n    return () => { cancelAnimationFrame(first); cancelAnimationFrame(second); };\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={styles.icon}\n        initial={!settled ? false : reduced ? { opacity: 0 } : { opacity: 0, scale: .7, rotate: angle, filter: blur }}\n        animate={{ opacity: 1, scale: 1, rotate: 0, filter: \"blur(0px)\" }}\n        exit={!settled ? { opacity: 0, transition: { duration: 0 } } : reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: .7, rotate: angle, filter: blur, transition: iconExit }}\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  const classes = [styles.themeSwitch, styles[variant], iconOnly ? styles.iconOnly : \"\", settled ? styles.settled : \"\"].join(\" \");\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      <span className={styles.iconWrap}><ThemeIcon theme={theme} reduced={reduced} settled={settled} /></span>\n      {!iconOnly && <span className={styles.label}>Switch theme</span>}\n    </Button>\n  );\n}\n\nexport default ThemeSwitch;\n"
    },
    {
      "path": "registry/components/theme-switch/theme-switch.module.css",
      "type": "registry:component",
      "target": "@components/xzero/theme-switch/theme-switch.module.css",
      "content": "/* Each transition uses the same library Button, which owns the press scale; this file only animates the icon in place. */\n.themeSwitch {\n  position: relative;\n  width: auto;\n  padding-inline: var(--space-3);\n}\n.iconOnly { width: 38px; min-width: 38px; height: 38px; min-height: 38px; border-radius: 12px; padding: 0; }\n@media (hover: hover) and (pointer: fine) {\n  .themeSwitch:hover:not(:disabled) { border-color: var(--border-strong); }\n  .iconOnly:hover:not(:disabled) { background: var(--surface-muted); box-shadow: none; }\n}\n.iconWrap {\n  position: relative;\n  display: inline-grid;\n  width: 20px;\n  height: 20px;\n  flex: 0 0 20px;\n  place-items: center;\n}\n/* The nudge only eases once the switch has settled, so a theme that arrives during hydration lands without motion. */\n.settled .iconWrap { transition: transform var(--duration-spring) var(--ease-spring); }\n.icon { position: absolute; display: grid; width: 18px; height: 18px; place-items: center; }\n.label { white-space: nowrap; }\n/* Each variant nudges the icon the way its page transition travels, and eases back when the theme returns. */\n.themeSwitch.reveal[data-theme=\"dark\"] .iconWrap { transform: scale(1.08); }\n.themeSwitch.eclipse[data-theme=\"dark\"] .iconWrap { transform: translateX(-2px); }\n.themeSwitch.split[data-theme=\"dark\"] .iconWrap { transform: translateX(1px); }\n.themeSwitch.rise[data-theme=\"dark\"] .iconWrap { transform: translateY(-2px); }\n@media (prefers-reduced-motion: reduce) {\n  .settled .iconWrap { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/theme-switch-eclipse",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "theme",
      "appearance",
      "motion"
    ],
    "docs": "https://ui.xzero.co.uk/components/theme-switch-eclipse",
    "markdown": "https://ui.xzero.co.uk/components/theme-switch-eclipse/markdown"
  }
}