{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "theme-switch",
  "title": "Theme Switch",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/theme-switch/ThemeSwitch.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Component } from 'svelte'\n\n  export type ThemeSwitchTheme = 'light' | 'dark' | 'system' | 'black'\n  export type ThemeSwitchVariant = 'cards' | 'icons' | 'icon-only' | 'dropdown' | 'pill' | 'pill-4' | 'switch'\n\n  export interface ThemeSwitchProps {\n    value?: ThemeSwitchTheme\n    variant?: ThemeSwitchVariant\n    title?: string\n    description?: string\n    /** Wipe the new theme in from the clicked control. Ignored without View Transition support or with reduced motion. */\n    viewTransition?: boolean\n    class?: string\n    /** Called with the new theme after every change. */\n    onValueChange?: (value: ThemeSwitchTheme) => void\n  }\n</script>\n\n<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import { cn } from '$lib/utils'\n  import { ChevronDown, Monitor, Moon, Palette, Sparkles, Sun } from '@lucide/svelte'\n\n  const ICONS: Record<ThemeSwitchTheme, Component> = {\n    light: Sun,\n    dark: Moon,\n    system: Monitor,\n    black: Sparkles,\n  }\n\n  const LABELS: Record<ThemeSwitchTheme, string> = {\n    light: 'Light',\n    dark: 'Dark',\n    system: 'System',\n    black: 'Black',\n  }\n\n  const VARIANT_OPTIONS: Record<ThemeSwitchVariant, ThemeSwitchTheme[]> = {\n    cards: ['light', 'dark', 'system'],\n    icons: ['light', 'dark', 'system'],\n    'icon-only': ['light', 'dark'],\n    dropdown: ['light', 'dark', 'system'],\n    pill: ['light', 'dark', 'system'],\n    'pill-4': ['system', 'light', 'dark', 'black'],\n    switch: ['light', 'dark'],\n  }\n\n  let {\n    value = $bindable('system'),\n    variant = 'cards',\n    title,\n    description,\n    viewTransition = true,\n    class: className,\n    onValueChange,\n  }: ThemeSwitchProps = $props()\n\n  const options = $derived(VARIANT_OPTIONS[variant])\n  const activeIndex = $derived.by(() => {\n    const i = options.indexOf(value)\n    return i === -1 ? 0 : i\n  })\n\n  const indicatorStyle = $derived(\n    `width: calc((100% - 4px) / ${options.length}); transform: translateX(calc(${activeIndex} * 100%));`,\n  )\n\n  const switchThumbStyle = $derived(`transform: translateX(${value === 'dark' ? '36px' : '4px'});`)\n\n  /**\n   * Swap the theme inside a View Transition so the new theme wipes in as a\n   * circle growing from the control that was clicked. The keyframes live in\n   * tailwind.css behind `html[data-uipkge-theme-reveal]`, so this never\n   * hijacks a consumer's own view transitions.\n   *\n   * Falls back to a plain emit when `view-transition` is off, the browser has\n   * no startViewTransition, or the user prefers reduced motion. The theme is\n   * applied by whoever owns the model (useTheme / next-themes), so the swap\n   * awaits tick() to let that watcher run inside the transition.\n   */\n  type ViewTransitionDocument = Document & {\n    startViewTransition?: (callback: () => unknown) => { finished: Promise<void> }\n  }\n\n  // The dropdown variant fires both select and click paths; without this a second\n  // startViewTransition would abort the first mid-wipe.\n  let revealing = false\n\n  async function withThemeReveal(event: MouseEvent | undefined, swap: () => void) {\n    const startViewTransition = (document as ViewTransitionDocument).startViewTransition?.bind(document)\n    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    if (!viewTransition || !startViewTransition || reduceMotion) {\n      swap()\n      return\n    }\n    if (revealing) return\n    revealing = true\n\n    const root = document.documentElement\n    const x = event?.clientX ?? window.innerWidth / 2\n    const y = event?.clientY ?? window.innerHeight / 2\n    // Radius that still covers the farthest corner from the click.\n    const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))\n    root.style.setProperty('--uipkge-theme-x', `${x}px`)\n    root.style.setProperty('--uipkge-theme-y', `${y}px`)\n    root.style.setProperty('--uipkge-theme-r', `${radius}px`)\n    root.setAttribute('data-uipkge-theme-reveal', '')\n\n    try {\n      await startViewTransition(async () => {\n        swap()\n        await tick()\n      }).finished\n    } finally {\n      root.removeAttribute('data-uipkge-theme-reveal')\n      revealing = false\n    }\n  }\n\n  function set(t: ThemeSwitchTheme, event?: MouseEvent) {\n    withThemeReveal(event, () => {\n      value = t\n      onValueChange?.(t)\n    })\n  }\n\n  function cycle(event?: MouseEvent) {\n    const next = options[(activeIndex + 1) % options.length]\n    if (next)\n      withThemeReveal(event, () => {\n        value = next\n        onValueChange?.(next)\n      })\n  }\n\n  let dropdownOpen = $state(false)\n\n  /** Close the hand-rolled dropdown on outside pointer-down or Escape (no bits-ui). */\n  function dismissable(node: HTMLElement) {\n    function onPointerDown(e: PointerEvent) {\n      if (!node.contains(e.target as Node)) dropdownOpen = false\n    }\n    function onKeyDown(e: KeyboardEvent) {\n      if (e.key === 'Escape') dropdownOpen = false\n    }\n    document.addEventListener('pointerdown', onPointerDown)\n    document.addEventListener('keydown', onKeyDown)\n    return {\n      destroy() {\n        document.removeEventListener('pointerdown', onPointerDown)\n        document.removeEventListener('keydown', onKeyDown)\n      },\n    }\n  }\n\n  function selectDropdown(t: ThemeSwitchTheme, event: MouseEvent) {\n    dropdownOpen = false\n    set(t, event)\n  }\n</script>\n\n<!-- Cards: full section card with 3-button grid (default). Hand-rolled card\n     (no section-card dependency): header row + bordered body. -->\n{#if variant === 'cards'}\n  <section\n    data-uipkge=\"\"\n    data-slot=\"theme-switch\"\n    data-variant=\"cards\"\n    class={cn('bg-card rounded-lg border p-4 shadow-xs', className)}\n  >\n    <div class=\"mb-3 flex items-start justify-between gap-2\">\n      <div>\n        <h3 class=\"text-sm font-semibold\">{title ?? 'Appearance'}</h3>\n        <p class=\"text-muted-foreground text-xs\">{description ?? 'Choose your interface theme.'}</p>\n      </div>\n      <Palette class=\"text-muted-foreground size-5\" aria-hidden=\"true\" />\n    </div>\n    <div class=\"grid grid-cols-3 gap-2\" role=\"radiogroup\" aria-label={title ?? 'Theme'}>\n      {#each options as t (t)}\n        {@const Icon = ICONS[t]}\n        <button\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={value === t}\n          class={cn(\n            'focus-visible:ring-ring rounded-md border p-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:outline-none',\n            value === t ? 'border-primary ring-primary bg-primary/5 ring-1' : 'border-border hover:bg-muted/50',\n          )}\n          onclick={(e) => set(t, e)}\n        >\n          <Icon class=\"text-muted-foreground mb-2 size-4\" aria-hidden=\"true\" />\n          <p class=\"text-xs font-medium\">{LABELS[t]}</p>\n        </button>\n      {/each}\n    </div>\n  </section>\n\n  <!-- Icons: compact 3-icon segmented row, no labels -->\n{:else if variant === 'icons'}\n  <div\n    role=\"radiogroup\"\n    aria-label={title ?? 'Theme'}\n    data-uipkge=\"\"\n    data-slot=\"theme-switch\"\n    data-variant=\"icons\"\n    class={cn('border-border bg-card inline-flex items-center gap-0.5 rounded-md border p-0.5', className)}\n  >\n    {#each options as t (t)}\n      {@const Icon = ICONS[t]}\n      <button\n        type=\"button\"\n        role=\"radio\"\n        aria-checked={value === t}\n        aria-label={LABELS[t]}\n        class={cn(\n          'focus-visible:ring-ring grid size-7 place-items-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none',\n          value === t\n            ? 'bg-primary text-primary-foreground'\n            : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n        )}\n        onclick={(e) => set(t, e)}\n      >\n        <Icon class=\"size-4\" aria-hidden=\"true\" />\n      </button>\n    {/each}\n  </div>\n\n  <!-- Icon-only: header-grade icon button. No border, ghost background,\n       rounded-lg to match adjacent header affordances (notification\n       bell, profile avatar). A direct icon swap (no rotate+fade\n       transition) reads cleaner and never flickers empty. -->\n{:else if variant === 'icon-only'}\n  {@const CycleIcon = ICONS[options.includes(value) ? value : 'light']}\n  <button\n    type=\"button\"\n    aria-label={LABELS[value] ?? 'Theme'}\n    data-uipkge=\"\"\n    data-slot=\"theme-switch\"\n    data-variant=\"icon-only\"\n    class={cn(\n      'text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring inline-flex size-8 items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none',\n      className,\n    )}\n    onclick={(e) => cycle(e)}\n  >\n    <CycleIcon class=\"size-4\" aria-hidden=\"true\" />\n  </button>\n\n  <!-- Dropdown: trigger button → menu of states (hand-rolled, no dropdown-menu dep) -->\n{:else if variant === 'dropdown'}\n  {@const TriggerIcon = ICONS[value] ?? Monitor}\n  <div use:dismissable data-uipkge=\"\" data-slot=\"theme-switch\" data-variant=\"dropdown\" class=\"relative inline-block\">\n    <button\n      type=\"button\"\n      aria-haspopup=\"menu\"\n      aria-expanded={dropdownOpen}\n      class={cn(\n        'border-border bg-card hover:bg-muted focus-visible:ring-ring inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition focus-visible:ring-2 focus-visible:outline-none',\n        className,\n      )}\n      onclick={() => (dropdownOpen = !dropdownOpen)}\n    >\n      <TriggerIcon class=\"size-4\" aria-hidden=\"true\" />\n      <span>{LABELS[value] ?? value}</span>\n      <ChevronDown class=\"size-3 opacity-60\" aria-hidden=\"true\" />\n    </button>\n    {#if dropdownOpen}\n      <div\n        role=\"menu\"\n        aria-label={title ?? 'Theme'}\n        class=\"bg-popover text-popover-foreground absolute right-0 z-50 mt-1 min-w-[140px] rounded-md border p-1 shadow-md\"\n      >\n        {#each options as t (t)}\n          {@const ItemIcon = ICONS[t]}\n          <button\n            type=\"button\"\n            role=\"menuitemradio\"\n            aria-checked={value === t}\n            class={cn(\n              'hover:bg-accent focus-visible:ring-ring flex w-full items-center rounded-sm px-2 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none',\n              value === t && 'bg-accent/50',\n            )}\n            onclick={(e) => selectDropdown(t, e)}\n          >\n            <ItemIcon class=\"mr-2 size-4\" aria-hidden=\"true\" />\n            <span>{LABELS[t]}</span>\n          </button>\n        {/each}\n      </div>\n    {/if}\n  </div>\n\n  <!-- Pill / Pill-4: equal segments with sliding indicator -->\n{:else if variant === 'pill' || variant === 'pill-4'}\n  <div\n    role=\"radiogroup\"\n    aria-label={title ?? 'Theme'}\n    data-uipkge=\"\"\n    data-slot=\"theme-switch\"\n    data-variant={variant}\n    class={cn('border-border bg-card relative inline-flex w-full max-w-md rounded-full border p-0.5', className)}\n  >\n    <span\n      aria-hidden=\"true\"\n      class=\"bg-primary pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 rounded-full transition-transform duration-300 ease-out\"\n      style={indicatorStyle}\n    ></span>\n    {#each options as t (t)}\n      {@const Icon = ICONS[t]}\n      <button\n        type=\"button\"\n        role=\"radio\"\n        aria-checked={value === t}\n        aria-label={LABELS[t]}\n        class={cn(\n          'focus-visible:ring-ring relative z-[1] inline-flex h-7 flex-1 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',\n          value === t ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground',\n        )}\n        onclick={(e) => set(t, e)}\n      >\n        <Icon class=\"size-3.5\" aria-hidden=\"true\" />\n        <span>{LABELS[t]}</span>\n      </button>\n    {/each}\n  </div>\n\n  <!-- Switch: iOS-style 2-state toggle with thumb that slides -->\n{:else if variant === 'switch'}\n  <button\n    type=\"button\"\n    role=\"switch\"\n    aria-checked={value === 'dark'}\n    aria-label={LABELS[value] ?? 'Theme'}\n    data-uipkge=\"\"\n    data-slot=\"theme-switch\"\n    data-variant=\"switch\"\n    class={cn(\n      'border-border focus-visible:ring-ring relative inline-flex h-8 w-16 items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none',\n      value === 'dark' ? 'bg-primary' : 'bg-muted',\n      className,\n    )}\n    onclick={(e) => set(value === 'dark' ? 'light' : 'dark', e)}\n  >\n    <Sun\n      class={cn('text-warning absolute left-1.5 size-4 transition-opacity', value === 'dark' ? 'opacity-30' : 'opacity-100')}\n      aria-hidden=\"true\"\n    />\n    <Moon\n      class={cn(\n        'text-muted-foreground absolute right-1.5 size-4 transition-opacity',\n        value === 'light' ? 'opacity-30' : 'opacity-100',\n      )}\n      aria-hidden=\"true\"\n    />\n    <span\n      aria-hidden=\"true\"\n      class=\"bg-card border-border absolute size-6 rounded-full border shadow transition-transform duration-300 ease-out\"\n      style={switchThumbStyle}\n    ></span>\n  </button>\n{/if}\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/theme-switch/ThemeSwitch.svelte"
    },
    {
      "path": "packages/registry-svelte/components/theme-switch/index.ts",
      "content": "export {\n  default as ThemeSwitch,\n  type ThemeSwitchProps,\n  type ThemeSwitchTheme,\n  type ThemeSwitchVariant,\n} from './ThemeSwitch.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/theme-switch/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Light / dark / system theme toggle — drop in the header. Seven visual variants: `cards`, `icons`, `icon-only`, `dropdown`, `pill`, `pill-4`, and `switch`. Persists choice to `localStorage` and respects `prefers-color-scheme` for `system`.",
  "categories": [
    "action"
  ]
}