{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-switcher",
  "title": "Theme Switcher",
  "description": "Persistent segmented and menu-based light, dark, and system theme controls with live operating-system synchronization.",
  "dependencies": [
    "@base-ui/react",
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/rivetui/components/theme-switcher/theme-switcher.tsx",
      "content": "\"use client\";\n\nimport { Menu as MenuPrimitive } from \"@base-ui/react/menu\";\nimport { Radio as RadioPrimitive } from \"@base-ui/react/radio\";\nimport { RadioGroup as RadioGroupPrimitive } from \"@base-ui/react/radio-group\";\nimport { Check, ChevronDown, Monitor, Moon, Sun } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype Theme = \"light\" | \"dark\" | \"system\";\ntype ResolvedTheme = Exclude<Theme, \"system\">;\ntype ThemeSnapshot = `${Theme}:${ResolvedTheme}`;\n\ntype ThemeStoreOptions = {\n  defaultTheme?: Theme;\n  storageKey?: string;\n};\n\ntype ThemeSwitcherProps = Omit<\n  RadioGroupPrimitive.Props<Theme>,\n  \"children\" | \"defaultValue\" | \"onValueChange\" | \"value\"\n> &\n  ThemeStoreOptions & {\n    onValueChange?: (theme: Theme) => void;\n  };\n\ntype ThemeMenuVariant = \"label\" | \"icon\";\n\ntype ThemeMenuProps = ThemeStoreOptions & {\n  align?: MenuPrimitive.Positioner.Props[\"align\"];\n  \"aria-label\"?: string;\n  className?: string;\n  disabled?: boolean;\n  onValueChange?: (theme: Theme) => void;\n  side?: MenuPrimitive.Positioner.Props[\"side\"];\n  variant?: ThemeMenuVariant;\n};\n\nconst defaultStorageKey = \"theme\";\nconst themeChangeEvent = \"rivet-ui:theme-change\";\nconst themeMemory = new Map<string, Theme>();\n\nconst themeOptions = [\n  { value: \"light\", label: \"Light\", icon: Sun },\n  { value: \"system\", label: \"System\", icon: Monitor },\n  { value: \"dark\", label: \"Dark\", icon: Moon },\n] as const;\n\nfunction ThemeSwitcher({\n  className,\n  defaultTheme = \"system\",\n  storageKey = defaultStorageKey,\n  onValueChange,\n  \"aria-label\": ariaLabel = \"Color theme\",\n  ...props\n}: ThemeSwitcherProps) {\n  const { theme, setTheme } = useTheme({ defaultTheme, storageKey });\n\n  function selectTheme(nextTheme: Theme) {\n    setTheme(nextTheme);\n    onValueChange?.(nextTheme);\n  }\n\n  return (\n    <RadioGroupPrimitive\n      data-slot=\"theme-switcher\"\n      value={theme}\n      onValueChange={selectTheme}\n      aria-label={ariaLabel}\n      className={cn(\n        \"inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-none border border-border bg-muted/40 p-1\",\n        className,\n      )}\n      {...props}\n    >\n      {themeOptions.map((option) => {\n        const Icon = option.icon;\n\n        return (\n          <RadioPrimitive.Root\n            key={option.value}\n            value={option.value}\n            aria-label={`Use ${option.label.toLowerCase()} theme`}\n            className=\"inline-flex h-9 flex-none cursor-pointer items-center justify-center gap-2 rounded-none border border-transparent px-3 text-sm font-medium whitespace-nowrap text-muted-foreground outline-none transition-[background-color,border-color,box-shadow,color,opacity] duration-100 hover:text-foreground focus-visible:z-10 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 data-checked:border-border data-checked:bg-background data-checked:text-foreground data-disabled:pointer-events-none data-disabled:opacity-40 motion-reduce:transition-none\"\n          >\n            <Icon className=\"size-4\" aria-hidden=\"true\" />\n            {option.label}\n          </RadioPrimitive.Root>\n        );\n      })}\n    </RadioGroupPrimitive>\n  );\n}\n\nfunction ThemeMenu({\n  align = \"end\",\n  className,\n  defaultTheme = \"system\",\n  disabled = false,\n  onValueChange,\n  side = \"bottom\",\n  storageKey = defaultStorageKey,\n  variant = \"label\",\n  \"aria-label\": ariaLabel,\n}: ThemeMenuProps) {\n  const { theme, resolvedTheme, setTheme } = useTheme({ defaultTheme, storageKey });\n  const selectedOption = themeOptions.find((option) => option.value === theme) ?? themeOptions[1];\n  const TriggerIcon =\n    variant === \"icon\" ? (resolvedTheme === \"dark\" ? Moon : Sun) : selectedOption.icon;\n\n  function selectTheme(nextTheme: Theme) {\n    setTheme(nextTheme);\n    onValueChange?.(nextTheme);\n  }\n\n  return (\n    <MenuPrimitive.Root>\n      <MenuPrimitive.Trigger\n        data-slot=\"theme-menu-trigger\"\n        disabled={disabled}\n        aria-label={\n          ariaLabel ??\n          (variant === \"icon\"\n            ? `Select color theme. ${selectedOption.label} selected.`\n            : \"Select color theme\")\n        }\n        className={cn(\n          \"group/theme-menu inline-flex h-10 cursor-pointer items-center rounded-none border border-border bg-background text-sm font-medium outline-none transition-[background-color,border-color,box-shadow,color,opacity] duration-100 hover:border-foreground/40 hover:bg-muted focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40 data-disabled:pointer-events-none data-disabled:opacity-40 data-popup-open:border-foreground/50 data-popup-open:bg-muted motion-reduce:transition-none\",\n          variant === \"icon\" ? \"w-10 justify-center\" : \"min-w-44 justify-start gap-2 px-3\",\n          className,\n        )}\n      >\n        <TriggerIcon className=\"size-4 shrink-0\" aria-hidden=\"true\" />\n        {variant === \"label\" ? (\n          <>\n            <span className=\"min-w-0 flex-1 truncate text-left\">{selectedOption.label}</span>\n            <ChevronDown\n              className=\"size-4 shrink-0 text-muted-foreground transition-transform duration-100 group-data-popup-open/theme-menu:rotate-180 motion-reduce:transition-none\"\n              aria-hidden=\"true\"\n            />\n          </>\n        ) : null}\n      </MenuPrimitive.Trigger>\n\n      <MenuPrimitive.Portal>\n        <MenuPrimitive.Positioner\n          data-slot=\"theme-menu-positioner\"\n          align={align}\n          side={side}\n          sideOffset={6}\n          className=\"isolate z-50 outline-none\"\n        >\n          <MenuPrimitive.Popup\n            data-slot=\"theme-menu-content\"\n            className=\"min-w-44 origin-(--transform-origin) overflow-hidden border border-border bg-popover p-1 text-popover-foreground outline-none transition-[opacity,transform] duration-100 data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-instant:transition-none data-starting-style:scale-[0.98] data-starting-style:opacity-0 motion-reduce:transition-none\"\n          >\n            <MenuPrimitive.Group>\n              <MenuPrimitive.GroupLabel className=\"px-3 py-2 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em] text-muted-foreground\">\n                Theme\n              </MenuPrimitive.GroupLabel>\n              <MenuPrimitive.RadioGroup value={theme} onValueChange={selectTheme}>\n                {themeOptions.map((option) => {\n                  const Icon = option.icon;\n\n                  return (\n                    <MenuPrimitive.RadioItem\n                      key={option.value}\n                      value={option.value}\n                      closeOnClick\n                      className=\"relative flex min-h-9 cursor-default select-none items-center gap-2 py-2 pr-9 pl-3 text-sm outline-none data-disabled:pointer-events-none data-disabled:opacity-40 data-highlighted:bg-accent data-highlighted:text-accent-foreground\"\n                    >\n                      <Icon className=\"size-4 shrink-0\" aria-hidden=\"true\" />\n                      <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n                      <MenuPrimitive.RadioItemIndicator className=\"absolute right-3 grid size-4 place-items-center\">\n                        <Check className=\"size-3.5\" strokeWidth={2.5} aria-hidden=\"true\" />\n                      </MenuPrimitive.RadioItemIndicator>\n                    </MenuPrimitive.RadioItem>\n                  );\n                })}\n              </MenuPrimitive.RadioGroup>\n            </MenuPrimitive.Group>\n          </MenuPrimitive.Popup>\n        </MenuPrimitive.Positioner>\n      </MenuPrimitive.Portal>\n    </MenuPrimitive.Root>\n  );\n}\n\nfunction useTheme({\n  defaultTheme = \"system\",\n  storageKey = defaultStorageKey,\n}: ThemeStoreOptions = {}) {\n  const subscribe = React.useCallback(\n    (onStoreChange: () => void) => subscribeToTheme(storageKey, onStoreChange),\n    [storageKey],\n  );\n  const getSnapshot = React.useCallback(\n    () => getThemeSnapshot(storageKey, defaultTheme),\n    [defaultTheme, storageKey],\n  );\n  const getServerSnapshot = React.useCallback(\n    () => getServerThemeSnapshot(defaultTheme),\n    [defaultTheme],\n  );\n  const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n  const { theme, resolvedTheme } = parseThemeSnapshot(snapshot);\n\n  React.useEffect(() => {\n    applyResolvedTheme(resolvedTheme);\n  }, [resolvedTheme]);\n\n  const setTheme = React.useCallback(\n    (nextTheme: Theme) => setThemePreference(storageKey, nextTheme),\n    [storageKey],\n  );\n\n  return { theme, resolvedTheme, setTheme };\n}\n\nfunction subscribeToTheme(storageKey: string, onStoreChange: () => void) {\n  const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n  function handleStorage(event: StorageEvent) {\n    if (event.key === storageKey || event.key === null) {\n      if (isTheme(event.newValue)) {\n        themeMemory.set(storageKey, event.newValue);\n      } else {\n        themeMemory.delete(storageKey);\n      }\n\n      onStoreChange();\n    }\n  }\n\n  function handleThemeChange(event: Event) {\n    if (event instanceof CustomEvent && event.detail?.storageKey === storageKey) {\n      onStoreChange();\n    }\n  }\n\n  window.addEventListener(\"storage\", handleStorage);\n  window.addEventListener(themeChangeEvent, handleThemeChange);\n  mediaQuery.addEventListener(\"change\", onStoreChange);\n\n  return () => {\n    window.removeEventListener(\"storage\", handleStorage);\n    window.removeEventListener(themeChangeEvent, handleThemeChange);\n    mediaQuery.removeEventListener(\"change\", onStoreChange);\n  };\n}\n\nfunction getThemeSnapshot(storageKey: string, defaultTheme: Theme): ThemeSnapshot {\n  const theme = getThemePreference(storageKey, defaultTheme);\n  return `${theme}:${resolveTheme(theme)}`;\n}\n\nfunction getServerThemeSnapshot(defaultTheme: Theme): ThemeSnapshot {\n  return `${defaultTheme}:${defaultTheme === \"dark\" ? \"dark\" : \"light\"}`;\n}\n\nfunction getThemePreference(storageKey: string, defaultTheme: Theme): Theme {\n  try {\n    const storedTheme = window.localStorage.getItem(storageKey);\n\n    if (isTheme(storedTheme)) {\n      themeMemory.set(storageKey, storedTheme);\n      return storedTheme;\n    }\n  } catch {\n    // Fall through to the in-memory preference when storage is unavailable.\n  }\n\n  return themeMemory.get(storageKey) ?? defaultTheme;\n}\n\nfunction setThemePreference(storageKey: string, theme: Theme) {\n  themeMemory.set(storageKey, theme);\n\n  try {\n    window.localStorage.setItem(storageKey, theme);\n  } catch {\n    // The current page still updates when persistent storage is unavailable.\n  }\n\n  applyResolvedTheme(resolveTheme(theme));\n  window.dispatchEvent(new CustomEvent(themeChangeEvent, { detail: { storageKey } }));\n}\n\nfunction resolveTheme(theme: Theme): ResolvedTheme {\n  if (theme === \"system\") {\n    return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n  }\n\n  return theme;\n}\n\nfunction applyResolvedTheme(theme: ResolvedTheme) {\n  document.documentElement.classList.toggle(\"dark\", theme === \"dark\");\n  document.documentElement.style.colorScheme = theme;\n}\n\nfunction parseThemeSnapshot(snapshot: ThemeSnapshot) {\n  const separatorIndex = snapshot.indexOf(\":\");\n  const theme = snapshot.slice(0, separatorIndex);\n  const resolvedTheme = snapshot.slice(separatorIndex + 1);\n\n  return {\n    theme: isTheme(theme) ? theme : \"system\",\n    resolvedTheme: resolvedTheme === \"dark\" ? \"dark\" : \"light\",\n  } satisfies { theme: Theme; resolvedTheme: ResolvedTheme };\n}\n\nfunction isTheme(value: string | null): value is Theme {\n  return value === \"light\" || value === \"dark\" || value === \"system\";\n}\n\nexport type { ResolvedTheme, Theme, ThemeMenuProps, ThemeMenuVariant, ThemeSwitcherProps };\nexport { ThemeMenu, ThemeSwitcher, useTheme };\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/rivetui/components/theme-switcher/theme-script.tsx",
      "content": "type Theme = \"light\" | \"dark\" | \"system\";\n\ntype ThemeScriptProps = {\n  defaultTheme?: Theme;\n  nonce?: string;\n  storageKey?: string;\n};\n\nfunction ThemeScript({ defaultTheme = \"system\", storageKey = \"theme\", nonce }: ThemeScriptProps) {\n  const serializedStorageKey = serializeScriptValue(storageKey);\n  const serializedDefaultTheme = serializeScriptValue(defaultTheme);\n  const script = `try{var t=localStorage.getItem(${serializedStorageKey})||${serializedDefaultTheme};var d=t===\"dark\"||(t===\"system\"&&matchMedia(\"(prefers-color-scheme: dark)\").matches);document.documentElement.classList.toggle(\"dark\",d);document.documentElement.style.colorScheme=d?\"dark\":\"light\"}catch(e){}`;\n\n  return (\n    <script\n      id=\"rivet-ui-theme\"\n      nonce={nonce}\n      // biome-ignore lint/security/noDangerouslySetInnerHtml: Values are JSON-encoded and escaped before insertion.\n      dangerouslySetInnerHTML={{ __html: script }}\n    />\n  );\n}\n\nfunction serializeScriptValue(value: string) {\n  return JSON.stringify(value).replaceAll(\"<\", \"\\\\u003c\");\n}\n\nexport type { ThemeScriptProps };\nexport { ThemeScript };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}