{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-switcher",
  "title": "Theme Switcher",
  "author": "AlgorizeTech",
  "description": "Ask the user to pick one of 3 theme switcher variants: https://avaraui.com/view/theme-switcher. This item holds 3 of them as looks of one component (icon, pill, dropdown). Skip the question if the user already named one. A light/dark toggle in icon, pill or dropdown form, with a circular reveal that grows out of the control and a GSAP icon transition. Use it in a navbar or settings panel.",
  "dependencies": [
    "lucide-react",
    "next-themes"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/ThemeSwitcherBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Monitor, Moon, Sun } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport React, {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { flushSync } from \"react-dom\";\n\n/**\n * False while server-rendering and during hydration, true afterwards — without\n * the extra render an effect-flipped useState costs.\n */\nconst subscribeToNothing = () => () => {};\nconst useIsMounted = () =>\n  useSyncExternalStore(\n    subscribeToNothing,\n    () => true,\n    () => false,\n  );\n\n// ==========================================\n// CIRCULAR REVEAL\n// The new theme grows out of the control as a circle, using the View\n// Transitions API. Everything it needs lives here — the rules below go into\n// <head> the first time a switcher mounts — so an installed copy works\n// without touching globals.css.\n// ==========================================\nconst REVEAL_STYLE_ID = \"theme-switcher-reveal\";\nconst REVEAL_CSS = `\n::view-transition-old(root),\n::view-transition-new(root) {\n  animation: none;\n  mix-blend-mode: normal;\n}\n::view-transition-old(root) { z-index: 1; }\n::view-transition-new(root) { z-index: 9999; }\n/* No CSS transitions while the reveal runs, or colours fade twice. */\nhtml.theme-transitioning *,\nhtml.theme-transitioning *::before,\nhtml.theme-transitioning *::after {\n  transition: none !important;\n}\n`;\n\nfunction ensureRevealStyles() {\n  if (typeof document === \"undefined\" || document.getElementById(REVEAL_STYLE_ID)) return;\n  const style = document.createElement(\"style\");\n  style.id = REVEAL_STYLE_ID;\n  style.textContent = REVEAL_CSS;\n  document.head.appendChild(style);\n}\n\ntype ViewTransitionDocument = Document & {\n  startViewTransition?: (update: () => void) => {\n    ready: Promise<void>;\n    finished: Promise<void>;\n  };\n};\n\n/**\n * Applies `apply` behind a circle that grows from the centre of `origin` to\n * the far corner of the viewport. Falls back to a plain switch when the\n * browser has no View Transitions API or the user prefers reduced motion.\n */\nfunction revealFrom(origin: Element, apply: () => void, duration: number) {\n  const doc = document as ViewTransitionDocument;\n  if (\n    !doc.startViewTransition ||\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  ) {\n    apply();\n    return;\n  }\n\n  const rect = origin.getBoundingClientRect();\n  const x = rect.left + rect.width / 2;\n  const y = rect.top + rect.height / 2;\n  const endRadius = Math.hypot(\n    Math.max(x, window.innerWidth - x),\n    Math.max(y, window.innerHeight - y),\n  );\n\n  const root = document.documentElement;\n  const done = () => root.classList.remove(\"theme-transitioning\");\n  // Belt and braces: never leave transitions disabled if `finished` is slow.\n  window.setTimeout(done, duration + 250);\n\n  const transition = doc.startViewTransition(() => {\n    root.classList.add(\"theme-transitioning\");\n    // Flush so the new theme class is on <html> before the snapshot is taken.\n    flushSync(apply);\n  });\n\n  transition.ready.then(() => {\n    root.animate(\n      {\n        clipPath: [\n          `circle(0px at ${x}px ${y}px)`,\n          `circle(${endRadius}px at ${x}px ${y}px)`,\n        ],\n      },\n      { duration, easing: \"ease-in-out\", pseudoElement: \"::view-transition-new(root)\" },\n    );\n  });\n  transition.finished.then(done, done);\n}\n\n\n// ==========================================\n// MOTION\n// Everything below runs on the Web Animations API rather than CSS\n// transitions: the reveal above switches every transition off while it runs\n// (`html.theme-transitioning`), and a theme change is exactly when these move.\n// ==========================================\nconst POWER3_OUT =\n  \"linear(0, .1268, .2412, .3439, .4358, .5177, .5904, .6545, .7108, .7599, .8025, .8391, .8704, .8969, .9191, .9375, .9526, .9647, .9744, .9819, .9877, .9919, .9949, .997, .9984, .9992, .9997, .9999, 1, 1, 1)\";\nconst BACK_OUT_2 =\n  \"linear(0, .159, .3031, .433, .5493, .6528, .744, .8237, .8924, .951, 1, 1.0401, 1.072, 1.0963, 1.1138, 1.125, 1.1307, 1.1314, 1.128, 1.121, 1.1111, 1.099, 1.0853, 1.0708, 1.056, 1.0417, 1.0284, 1.017, 1.008, 1.0021, 1)\";\n// power2.in / power2.out; back to back they make power2.inOut exactly.\nconst POWER2_IN = \"cubic-bezier(0.3333, 0, 0.6667, 0)\";\nconst POWER2_OUT = \"cubic-bezier(0.3333, 1, 0.6667, 1)\";\n\nexport type ThemeVariant = \"icon\" | \"pill\" | \"dropdown\";\n\nexport interface ThemeSwitcherProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  variant?: ThemeVariant;\n  /** Grow the new theme out of the control as a circle. Off = instant switch. */\n  reveal?: boolean;\n  /** Length of the reveal, in ms. */\n  revealDuration?: number;\n  /**\n   * Replace how a picked theme is applied. By default the switcher reveals\n   * and calls next-themes' setTheme; pass this to do it yourself — persist it\n   * first, or hand the switch to a parent document. Gets the theme and the\n   * element that was clicked, for the reveal origin.\n   */\n  applyTheme?: (theme: string, origin: Element | null) => void;\n}\n\nexport const ThemeSwitcher = React.forwardRef<\n  HTMLDivElement,\n  ThemeSwitcherProps\n>(({ variant = \"icon\", reveal = true, revealDuration = 500, applyTheme, className, ...props }, ref) => {\n  const mounted = useIsMounted();\n  const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();\n\n  useEffect(() => {\n    if (reveal) ensureRevealStyles();\n  }, [reveal]);\n\n  // What the variants call. Reveals from the element that was clicked, unless\n  // nothing visible would change (e.g. \"system\" resolving to the current theme).\n  const select = useCallback(\n    (next: string, origin?: Element | null) => {\n      if (applyTheme) {\n        applyTheme(next, origin ?? null);\n        return;\n      }\n      const nextResolved = next === \"system\" ? systemTheme : next;\n      if (!reveal || !origin || nextResolved === resolvedTheme) {\n        setTheme(next);\n        return;\n      }\n      revealFrom(origin, () => setTheme(next), revealDuration);\n    },\n    [applyTheme, reveal, revealDuration, resolvedTheme, setTheme, systemTheme],\n  );\n\n  // Avoid hydration mismatch by waiting for mount\n\n  if (!mounted) {\n    // Render a placeholder with the same dimensions to prevent layout shift\n    if (variant === \"pill\")\n      return (\n        <div\n          className={cn(\n            \"h-10 w-[120px] bg-muted rounded-full\",\n            className,\n          )}\n        />\n      );\n    if (variant === \"dropdown\")\n      return (\n        <div\n          className={cn(\n            \"h-9 w-[110px] bg-muted rounded-lg\",\n            className,\n          )}\n        />\n      );\n    return (\n      <div\n        className={cn(\n          \"h-9 w-9 bg-muted rounded-lg\",\n          className,\n        )}\n      />\n    );\n  }\n\n  if (variant === \"pill\") {\n    return (\n      <ThemeSwitcherPill\n        theme={theme}\n        setTheme={select}\n        className={className}\n        {...props}\n        ref={ref}\n      />\n    );\n  }\n\n  if (variant === \"dropdown\") {\n    return (\n      <ThemeSwitcherDropdown\n        theme={theme}\n        resolvedTheme={resolvedTheme}\n        setTheme={select}\n        className={className}\n        {...props}\n        ref={ref}\n      />\n    );\n  }\n\n  return (\n    <ThemeSwitcherIcon\n      resolvedTheme={resolvedTheme}\n      setTheme={select}\n      className={className}\n      {...props}\n      ref={ref}\n    />\n  );\n});\nThemeSwitcher.displayName = \"ThemeSwitcher\";\n\n/** What every switcher variant is handed by ThemeSwitcher. */\ninterface ThemeSwitcherVariantProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  /** The selected theme: light | dark | system. */\n  theme?: string;\n  /** What \"system\" actually resolved to: light | dark. */\n  resolvedTheme?: string;\n  /** Pass the clicked element as `origin` so the reveal grows from it. */\n  setTheme: (theme: string, origin?: Element | null) => void;\n}\n\n// ==========================================\n// VARIANT: PILL (Segmented Control)\n// ==========================================\nconst ThemeSwitcherPill = React.forwardRef<HTMLDivElement, ThemeSwitcherVariantProps>(\n  ({ theme, setTheme, className, ...props }, ref) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const indicatorRef = useRef<HTMLDivElement>(null);\n    const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);\n    const slideRef = useRef<Animation | null>(null);\n\n    React.useImperativeHandle(\n      ref,\n      () => containerRef.current as HTMLDivElement,\n    );\n\n    const tabs = [\n      { id: \"light\", icon: Sun },\n      { id: \"system\", icon: Monitor },\n      { id: \"dark\", icon: Moon },\n    ];\n\n    useEffect(() => {\n      const activeIndex = tabs.findIndex((t) => t.id === theme);\n      const activeBtn = buttonsRef.current[activeIndex];\n      const indicator = indicatorRef.current;\n\n      if (activeBtn && indicator) {\n        // Slide from wherever the indicator is right now: mid-slide, or on\n        // mount from its unsized spot at the left edge.\n        const now = getComputedStyle(indicator);\n        const from = { transform: now.transform, width: now.width };\n        const to = {\n          transform: `translateX(${activeBtn.offsetLeft}px)`,\n          width: `${activeBtn.offsetWidth}px`,\n        };\n        slideRef.current?.cancel();\n        indicator.style.transform = to.transform;\n        indicator.style.width = to.width;\n        slideRef.current = indicator.animate([from, to], {\n          duration: 400,\n          easing: POWER3_OUT,\n        });\n      }\n    }, [theme]);\n\n    return (\n      <div\n        ref={containerRef}\n        className={cn(\n          \"relative flex items-center p-1 bg-muted/80 border border-border/50 backdrop-blur-md rounded-full w-max shadow-sm\",\n          className,\n        )}\n        {...props}\n      >\n        <div\n          ref={indicatorRef}\n          className=\"absolute top-1 bottom-1 left-0 bg-card rounded-full shadow-sm border border-border/50 pointer-events-none\"\n        />\n        {tabs.map((tab, i) => {\n          const Icon = tab.icon;\n          const isActive = theme === tab.id;\n          return (\n            <button\n              key={tab.id}\n              ref={(el) => {\n                buttonsRef.current[i] = el;\n              }}\n              onClick={(e) => setTheme(tab.id, e.currentTarget)}\n              className={cn(\n                \"relative z-10 flex items-center justify-center w-8 h-8 rounded-full transition-colors duration-200\",\n                isActive\n                  ? \"text-foreground\"\n                  : \"text-muted-foreground hover:text-foreground\",\n              )}\n              aria-label={`Switch to ${tab.id} theme`}\n            >\n              <Icon className=\"w-4 h-4\" />\n            </button>\n          );\n        })}\n      </div>\n    );\n  },\n);\nThemeSwitcherPill.displayName = \"ThemeSwitcherPill\";\n\n// ==========================================\n// VARIANT: ICON (Simple Toggle)\n// ==========================================\nconst ThemeSwitcherIcon = React.forwardRef<HTMLDivElement, ThemeSwitcherVariantProps>(\n  ({ resolvedTheme, setTheme, className, ...props }, ref) => {\n    const iconRef = useRef<HTMLDivElement>(null);\n    const spinRef = useRef<Animation | null>(null);\n\n    const toggleTheme = (e: React.MouseEvent<HTMLButtonElement>) => {\n      const isDark = resolvedTheme === \"dark\";\n\n      // Spin: a half turn while shrinking to 0.8, then back (0.15s each way,\n      // power2.inOut). A click mid-spin picks up from the current angle and\n      // still comes to rest upright.\n      const icon = iconRef.current;\n      if (icon) {\n        const now = getComputedStyle(icon);\n        const angle = parseFloat(now.rotate) || 0;\n        const size = parseFloat(now.scale);\n        const scale = Number.isNaN(size) ? 1 : size;\n        spinRef.current?.cancel();\n        spinRef.current = icon.animate(\n          [\n            { rotate: `${angle}deg`, scale, easing: POWER2_IN },\n            {\n              rotate: `${(angle + 180) / 2}deg`,\n              scale: (scale + 0.8) / 2,\n              easing: POWER2_OUT,\n            },\n            { rotate: \"180deg\", scale: 0.8, easing: POWER2_IN },\n            { rotate: \"90deg\", scale: 0.9, easing: POWER2_OUT },\n            { rotate: \"0deg\", scale: 1 },\n          ],\n          { duration: 300 },\n        );\n      }\n\n      setTheme(isDark ? \"light\" : \"dark\", e.currentTarget);\n    };\n\n    return (\n      <div ref={ref} className={className} {...props}>\n        <button\n          onClick={toggleTheme}\n          className=\"flex items-center justify-center w-9 h-9 rounded-lg bg-muted/80 border border-border/50 hover:bg-muted/80 transition-colors shadow-sm\"\n          aria-label=\"Toggle theme\"\n        >\n          <div\n            ref={iconRef}\n            className=\"relative flex items-center justify-center w-full h-full\"\n          >\n            {resolvedTheme === \"dark\" ? (\n              <Moon className=\"w-[18px] h-[18px] text-foreground\" />\n            ) : (\n              <Sun className=\"w-[18px] h-[18px] text-foreground\" />\n            )}\n          </div>\n        </button>\n      </div>\n    );\n  },\n);\nThemeSwitcherIcon.displayName = \"ThemeSwitcherIcon\";\n\n// ==========================================\n// VARIANT: DROPDOWN (Click to open menu)\n// ==========================================\nconst ThemeSwitcherDropdown = React.forwardRef<HTMLDivElement, ThemeSwitcherVariantProps>(\n  ({ theme, resolvedTheme, setTheme, className, ...props }, ref) => {\n    const [isOpen, setIsOpen] = useState(false);\n    // Whether the menu is displayed at all: true while open and while the\n    // close animation plays, then false (display: none).\n    const [menuShown, setMenuShown] = useState(false);\n    const containerRef = useRef<HTMLDivElement>(null);\n    const menuRef = useRef<HTMLDivElement>(null);\n    const menuMotionRef = useRef<Animation | null>(null);\n\n    // Opening displays the menu in the same render, so the entrance can start\n    // from its first frame.\n    const [prevOpen, setPrevOpen] = useState(isOpen);\n    if (prevOpen !== isOpen) {\n      setPrevOpen(isOpen);\n      if (isOpen) setMenuShown(true);\n    }\n\n    React.useImperativeHandle(\n      ref,\n      () => containerRef.current as HTMLDivElement,\n    );\n\n    useEffect(() => {\n      const handleClickOutside = (event: MouseEvent) => {\n        if (\n          containerRef.current &&\n          !containerRef.current.contains(event.target as Node)\n        ) {\n          setIsOpen(false);\n        }\n      };\n      document.addEventListener(\"mousedown\", handleClickOutside);\n      return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n    }, []);\n\n    // Open: 0.2s back.out(2) from 10px up at 95%. Close: 0.15s power2.in to\n    // 5px up at 95% from wherever it is, then display: none. The animated\n    // `transform` composes with the menu's own centring `translate` class\n    // instead of replacing it.\n    useLayoutEffect(() => {\n      const menu = menuRef.current;\n      if (!menu || !menuShown) return;\n      if (isOpen) {\n        menuMotionRef.current?.cancel();\n        menuMotionRef.current = menu.animate(\n          [\n            { opacity: 0, transform: \"translateY(-10px) scale(0.95)\" },\n            { opacity: 1, transform: \"translateY(0px) scale(1)\" },\n          ],\n          { duration: 200, easing: BACK_OUT_2 },\n        );\n        return;\n      }\n      const now = getComputedStyle(menu);\n      const from = { opacity: now.opacity, transform: now.transform };\n      menuMotionRef.current?.cancel();\n      const close = menu.animate(\n        [from, { opacity: 0, transform: \"translateY(-5px) scale(0.95)\" }],\n        { duration: 150, easing: POWER2_IN, fill: \"forwards\" },\n      );\n      menuMotionRef.current = close;\n      // Reopening cancels this animation, and a cancelled one never finishes.\n      close.onfinish = () => setMenuShown(false);\n    }, [isOpen, menuShown]);\n\n    return (\n      <div\n        ref={containerRef}\n        className={cn(\"relative inline-block\", className)}\n        {...props}\n      >\n        <button\n          onClick={() => setIsOpen((prev) => !prev)}\n          className=\"flex items-center gap-2 px-3.5 h-9 rounded-lg bg-muted border border-border hover:bg-muted transition-colors shadow-sm text-sm font-medium text-foreground cursor-pointer\"\n        >\n          {resolvedTheme === \"dark\" ? (\n            <Moon className=\"w-4 h-4\" />\n          ) : (\n            <Sun className=\"w-4 h-4\" />\n          )}\n          <span className=\"capitalize\">{theme}</span>\n        </button>\n\n        <div\n          ref={menuRef}\n          className={cn(\n            \"absolute left-1/2 -translate-x-1/2 sm:left-auto sm:right-0 sm:translate-x-0 top-full mt-2 w-36 p-1 bg-popover text-popover-foreground border border-border rounded-xl shadow-xl z-50\",\n            !menuShown && \"hidden\",\n          )}\n        >\n          {[\n            { id: \"light\", icon: Sun, label: \"Light\" },\n            { id: \"dark\", icon: Moon, label: \"Dark\" },\n            { id: \"system\", icon: Monitor, label: \"System\" },\n          ].map((item) => {\n            const Icon = item.icon;\n            const isActive = theme === item.id;\n            return (\n              <button\n                key={item.id}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  setTheme(item.id, e.currentTarget);\n                  setIsOpen(false);\n                }}\n                className={cn(\n                  \"flex items-center w-full gap-2.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer\",\n                  isActive\n                    ? \"bg-muted text-foreground\"\n                    : \"text-muted-foreground hover:bg-muted/60 hover:text-foreground\",\n                )}\n              >\n                <Icon className=\"w-4 h-4\" />\n                {item.label}\n              </button>\n            );\n          })}\n        </div>\n      </div>\n    );\n  },\n);\nThemeSwitcherDropdown.displayName = \"ThemeSwitcherDropdown\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/ThemeSwitcherBlock.tsx"
    }
  ],
  "docs": "Named export: ThemeSwitcher. Variants: icon | pill | dropdown — dropdown includes a system option. Switching runs a circular View-Transition reveal from the clicked control; the CSS it needs is injected by the component, so nothing goes in globals.css. `reveal={false}` for an instant switch. Requires `next-themes` with a ThemeProvider mounted above it; without one it renders but will not change the theme.",
  "categories": [
    "utilities",
    "actions"
  ],
  "type": "registry:component"
}
