{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "theme-picker",
  "type": "registry:ui",
  "title": "Theme Picker",
  "description": "Light, dark and system cards drawn in the real palettes, with a sliding ring.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/theme-picker.tsx",
      "content": "\"use client\";\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\nexport type ResolvedTheme = \"light\" | \"dark\";\n\nconst query = \"(prefers-color-scheme: dark)\";\nconst subscribe = (fn: () => void) => {\n  const mq = window.matchMedia(query);\n  mq.addEventListener(\"change\", fn);\n  return () => mq.removeEventListener(\"change\", fn);\n};\n\n/** What the device prefers right now, following changes live. Null on the server and during hydration. */\nexport function useSystemTheme(): ResolvedTheme | null {\n  return useSyncExternalStore(\n    subscribe,\n    () => (window.matchMedia(query).matches ? \"dark\" : \"light\"),\n    () => null,\n  );\n}\n\n/** Resolves a choice to the theme to paint: System follows the device. */\nexport function useResolvedTheme(theme: Theme): ResolvedTheme | null {\n  const system = useSystemTheme();\n  return theme === \"system\" ? system : theme;\n}\n\nconst defaults: Record<Theme, string> = {\n  light: \"Light\",\n  dark: \"Dark\",\n  system: \"System\",\n};\n\nexport type ThemePickerProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  value?: Theme;\n  defaultValue?: Theme;\n  /** Apply the theme here: set a data attribute, write a cookie, save to the account. */\n  onValueChange?: (theme: Theme) => void;\n  /** Which cards to show and in what order. */\n  themes?: Theme[];\n  /** Rename the cards, for another language or house style. */\n  labels?: Partial<Record<Theme, React.ReactNode>>;\n  disabled?: boolean;\n  /** Submitted with a form. */\n  name?: string;\n};\n\n/**\n * Three cards, each a small drawing of the app in that theme. One ring slides\n * to the chosen card; System shows what it resolves to right now.\n */\nexport function ThemePicker({\n  value,\n  defaultValue = \"system\",\n  onValueChange,\n  themes = [\"light\", \"dark\", \"system\"],\n  labels,\n  disabled = false,\n  name,\n  className,\n  ...rest\n}: ThemePickerProps) {\n  const id = useId();\n  const reduce = useReducedMotion();\n  const system = useSystemTheme();\n  const [theme, setTheme] = useControllableState<Theme>({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  });\n  // Arrow keys move the choice instantly; a click earns the slide.\n  const [keyboard, setKeyboard] = useState(false);\n\n  return (\n    <RadioGroup\n      value={theme}\n      onValueChange={(v) => setTheme(v as Theme)}\n      disabled={disabled}\n      name={name}\n      onKeyDownCapture={() => setKeyboard(true)}\n      onPointerDownCapture={() => setKeyboard(false)}\n      className={cn(\"grid w-full gap-3\", disabled && \"opacity-50\", className)}\n      style={{\n        gridTemplateColumns: `repeat(${themes.length}, minmax(0, 1fr))`,\n      }}\n      {...rest}\n    >\n      {themes.map((t) => {\n        const checked = theme === t;\n        return (\n          <Radio.Root\n            key={t}\n            value={t}\n            className={cn(\n              \"group/card relative flex min-w-0 cursor-default select-none flex-col gap-2 rounded-xl outline-none\",\n              // Focus sits outside the selection ring so the two never merge.\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-fg-3\",\n              \"data-disabled:cursor-not-allowed\",\n            )}\n          >\n            <span className=\"relative block\">\n              {checked && (\n                <motion.span\n                  layoutId={`${id}-ring`}\n                  aria-hidden\n                  className=\"pointer-events-none absolute -inset-[3px] z-[1] rounded-[13px] border-[1.5px] border-fg\"\n                  transition={\n                    reduce || keyboard ? { duration: 0 } : spring.snappy\n                  }\n                />\n              )}\n              <span\n                className={cn(\n                  \"relative block aspect-[8/5] overflow-hidden rounded-[10px] border border-line-2\",\n                  \"transition-[border-color,scale] duration-150 ease-out group-active/card:scale-[0.98] group-active/card:duration-75\",\n                  \"group-hover/card:border-fg-4 group-data-disabled/card:group-hover/card:border-line-2 group-data-disabled/card:group-active/card:scale-100\",\n                )}\n              >\n                {t === \"system\" ? (\n                  <>\n                    <Preview theme=\"light\" />\n                    {/* The same drawing twice, the dark one clipped to the right half. */}\n                    <span className=\"absolute inset-0 block [clip-path:inset(0_0_0_50%)]\">\n                      <Preview theme=\"dark\" />\n                    </span>\n                  </>\n                ) : (\n                  <Preview theme={t} />\n                )}\n              </span>\n            </span>\n            <span className=\"flex min-w-0 items-center gap-2 px-0.5\">\n              <Indicator checked={checked} reduce={!!reduce} />\n              <span className=\"flex min-w-0 flex-1 items-baseline gap-1.5\">\n                <span\n                  className={cn(\n                    \"truncate text-[13px] leading-5 transition-colors duration-150\",\n                    checked ? \"text-fg\" : \"text-fg-2 group-hover/card:text-fg\",\n                  )}\n                >\n                  {labels?.[t] ?? defaults[t]}\n                </span>\n                {t === \"system\" && system && (\n                  <span\n                    className=\"hidden truncate text-[12px] text-fg-4 min-[420px]:inline\"\n                    suppressHydrationWarning\n                  >\n                    {system === \"dark\" ? \"Dark now\" : \"Light now\"}\n                  </span>\n                )}\n              </span>\n            </span>\n          </Radio.Root>\n        );\n      })}\n    </RadioGroup>\n  );\n}\n\nfunction Indicator({ checked, reduce }: { checked: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors duration-150\",\n        checked\n          ? \"border-fg bg-fg\"\n          : \"border-line-2 group-hover/card:border-fg-4\",\n      )}\n    >\n      <AnimatePresence initial={false}>\n        {checked && (\n          <motion.svg\n            key=\"check\"\n            viewBox=\"0 0 16 16\"\n            className=\"size-2.5 text-frame\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2.2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={\n              reduce\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.5, transition: { duration: 0.1 } }\n            }\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n          >\n            <motion.path\n              d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n              initial={reduce ? false : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: 0.26, ease: ease.out, delay: 0.05 }}\n            />\n          </motion.svg>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n// A small drawing of an app window. Setting data-theme on it swaps every token\n// underneath, so the drawing is always in the real palette of that theme.\nfunction Preview({ theme }: { theme: ResolvedTheme }) {\n  // Sized in container units so the drawing keeps its proportions from a phone to a wide card.\n  return (\n    <span\n      data-theme={theme}\n      aria-hidden\n      className=\"absolute inset-0 block bg-page [container-type:size]\"\n    >\n      <span className=\"absolute inset-0 flex pt-[13cqh] pl-[10cqw]\">\n        <span className=\"flex flex-1 overflow-hidden rounded-tl-[6px] border-t border-l border-line-2 bg-frame shadow-[var(--shadow)]\">\n          <span className=\"flex w-[30%] flex-col gap-[8cqh] border-r border-line px-[3cqw] pt-[11cqh]\">\n            <span className=\"flex items-center gap-[2cqw]\">\n              <span className=\"size-[7cqh] shrink-0 rounded-full bg-fg-3\" />\n              <span className=\"h-[4.5cqh] flex-1 rounded-full bg-fg-4\" />\n            </span>\n            <span className=\"h-[4.5cqh] w-[80%] rounded-full bg-line-2\" />\n            <span className=\"h-[4.5cqh] w-[60%] rounded-full bg-line-2\" />\n            <span className=\"h-[4.5cqh] w-[70%] rounded-full bg-line-2\" />\n          </span>\n          <span className=\"flex flex-1 flex-col items-start gap-[8cqh] px-[5cqw] pt-[11cqh]\">\n            <span className=\"h-[5cqh] w-[45%] rounded-full bg-fg-2\" />\n            <span className=\"flex w-full flex-col gap-[5cqh] rounded-[4px] border border-line bg-raised p-[6cqh]\">\n              <span className=\"h-[4.5cqh] w-[85%] rounded-full bg-line-2\" />\n              <span className=\"h-[4.5cqh] w-[60%] rounded-full bg-line-2\" />\n            </span>\n            <span className=\"h-[10cqh] w-[34%] rounded-full bg-fg\" />\n          </span>\n        </span>\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/theme-picker"
}
