{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-picker",
  "type": "registry:ui",
  "title": "Model Picker",
  "description": "Compact model select with capabilities, speed and cost; the name slides and resizes.",
  "categories": [
    "ai-chat"
  ],
  "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/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/model-picker.tsx",
      "content": "\"use client\";\nimport { Select } from \"@base-ui/react/select\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronDown, Lock } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Data\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ModelCapability = \"vision\" | \"tools\" | \"reasoning\" | \"long-context\" | \"web\" | \"code\";\n\nexport type Model = {\n  value: string;\n  name: string;\n  /** One line on what it's for. Truncates, so lead with the point. */\n  description?: string;\n  capabilities?: ModelCapability[];\n  /** 1 slow · 2 balanced · 3 fast. */\n  speed?: 1 | 2 | 3;\n  /** 1 cheap · 2 moderate · 3 expensive. */\n  cost?: 1 | 2 | 3;\n  /** A short tag after the name, like \"New\" or \"Beta\". */\n  tag?: string;\n  /** Rows with the same group sit together under that label. */\n  group?: string;\n  disabled?: boolean;\n  /** Why it can't be picked. Replaces the description on a disabled row. */\n  disabledReason?: string;\n  icon?: React.ReactNode;\n};\n\nconst capabilityLabel: Record<ModelCapability, string> = {\n  vision: \"Vision\",\n  tools: \"Tools\",\n  reasoning: \"Reasoning\",\n  \"long-context\": \"Long context\",\n  web: \"Web\",\n  code: \"Code\",\n};\nconst speedLabel = [\"\", \"Slower\", \"Balanced\", \"Fast\"];\nconst costLabel = [\"\", \"Low cost\", \"Moderate cost\", \"High cost\"];\n\n/* -------------------------------------------------------------------------------------------------\n * Picker\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ModelPickerProps = {\n  models: Model[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Accessible name for the trigger. The visible label is the model's name. */\n  label?: string;\n  size?: \"sm\" | \"md\";\n  variant?: \"ghost\" | \"secondary\";\n  side?: \"top\" | \"bottom\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** A key that opens the picker together with ⌘ (Ctrl elsewhere), e.g. \"/\". It needs the modifier, so it works while typing. */\n  shortcut?: string;\n  disabled?: boolean;\n  /** Portal target. Defaults to document.body. */\n  container?: Select.Portal.Props[\"container\"];\n  className?: string;\n};\n\nexport function ModelPicker({\n  models,\n  value,\n  defaultValue,\n  onValueChange,\n  label = \"Model\",\n  size = \"sm\",\n  variant = \"ghost\",\n  side = \"bottom\",\n  align = \"start\",\n  shortcut,\n  disabled,\n  container,\n  className,\n}: ModelPickerProps) {\n  const [inner, setInner] = useState<string | null>(defaultValue ?? models.find((m) => !m.disabled)?.value ?? null);\n  const controlled = value !== undefined;\n  const current = controlled ? value : inner;\n  const [open, setOpen] = useState(false);\n  const reduce = !!useReducedMotion();\n  const byValue = useMemo(() => new Map(models.map((m) => [m.value, m])), [models]);\n  const selected = current ? byValue.get(current) : undefined;\n  const groups = useMemo(() => groupBy(models), [models]);\n  const apple = useApple();\n\n  // The new name arrives from where it sat in the list: picked below slides up, above slides down.\n  const order = models.map((m) => m.value);\n  const [shown, setShown] = useState({ value: current, dir: 1 });\n  if (shown.value !== current) setShown({ value: current, dir: order.indexOf(current ?? \"\") < order.indexOf(shown.value ?? \"\") ? -1 : 1 });\n\n  // ⌘ + key opens it from anywhere, instantly.\n  useEffect(() => {\n    if (!shortcut || disabled) return;\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === shortcut.toLowerCase()) {\n        e.preventDefault();\n        setOpen((o) => !o);\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, disabled]);\n\n  return (\n    <Select.Root<string>\n      value={current}\n      onValueChange={(next) => {\n        if (next == null) return;\n        if (!controlled) setInner(next);\n        onValueChange?.(next);\n      }}\n      open={open}\n      onOpenChange={setOpen}\n      disabled={disabled}\n      items={models.map((m) => ({ value: m.value, label: m.name }))}\n    >\n      <Select.Trigger\n        aria-label={label}\n        data-size={size}\n        data-variant={variant}\n        aria-keyshortcuts={shortcut ? `Meta+${shortcut.toUpperCase()}` : undefined}\n        className={cn(\n          \"group/trigger relative inline-flex max-w-full shrink-0 select-none items-center font-medium tracking-[-0.005em] text-fg-2 outline-none\",\n          \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n          \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"hover:text-fg data-popup-open:text-fg data-disabled:pointer-events-none data-disabled:opacity-50\",\n          size === \"sm\" ? \"h-7 gap-1.5 rounded-md pl-2 pr-1.5 text-[12.5px]\" : \"h-8 gap-1.5 rounded-lg pl-2.5 pr-2 text-[13px]\",\n          variant === \"ghost\"\n            ? \"hover:bg-hover data-popup-open:bg-hover\"\n            : \"border border-line-2 bg-raised shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover data-popup-open:border-fg-4\",\n          \"pointer-coarse:after:absolute pointer-coarse:after:-inset-y-2 pointer-coarse:after:inset-x-0 pointer-coarse:after:content-['']\",\n          className,\n        )}\n      >\n        {selected?.icon && <span className=\"grid size-4 shrink-0 place-items-center text-fg-3 [&_svg]:size-3.5\">{selected.icon}</span>}\n        <TriggerLabel id={current ?? \"\"} dir={shown.dir} reduce={reduce}>\n          {selected?.name ?? \"Choose a model\"}\n        </TriggerLabel>\n        <Select.Icon className=\"grid shrink-0 place-items-center text-fg-3 transition-transform duration-200 ease-out-expo group-data-popup-open/trigger:rotate-180 motion-reduce:transition-none\">\n          <ChevronDown size={14} />\n        </Select.Icon>\n      </Select.Trigger>\n\n      <Select.Portal container={container}>\n        <Select.Positioner\n          alignItemWithTrigger={false}\n          side={side}\n          align={align}\n          sideOffset={6}\n          collisionPadding={8}\n          className=\"z-(--z-popover) outline-none select-none\"\n        >\n          <Select.Popup\n            className={cn(\n              \"flex w-[min(22rem,var(--available-width))] origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"max-h-[min(30rem,var(--available-height))]\",\n              \"transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n            )}\n          >\n            <List reduce={reduce}>\n              {groups.map(([group, items], gi) => (\n                <Fragment key={group ?? gi}>\n                  {group ? (\n                    <Select.Group className=\"flex flex-col\">\n                      <Select.GroupLabel className=\"px-2 pb-1 pt-2 font-mono text-[10.5px] font-medium uppercase tracking-[0.08em] text-fg-3\">{group}</Select.GroupLabel>\n                      {items.map((m) => (\n                        <ModelRow key={m.value} model={m} reduce={reduce} />\n                      ))}\n                    </Select.Group>\n                  ) : (\n                    items.map((m) => <ModelRow key={m.value} model={m} reduce={reduce} />)\n                  )}\n                </Fragment>\n              ))}\n            </List>\n            <div className=\"flex shrink-0 items-center gap-3 border-t border-line px-3 py-2 text-[11px] text-fg-3\">\n              <span className=\"flex items-center gap-1.5\">\n                <SpeedBars level={3} className=\"text-fg-3\" /> Speed\n              </span>\n              <span className=\"flex items-center gap-1.5\">\n                <span className=\"font-mono text-[10.5px] text-fg-3\">$</span> Cost\n              </span>\n              {shortcut && (\n                <span className=\"ms-auto flex items-center gap-1 text-fg-4 pointer-coarse:hidden\">\n                  <kbd className=\"rounded-[4px] border border-line-2 px-1 font-sans text-[10.5px] leading-4 text-fg-3\">\n                    {apple ? \"⌘\" : \"Ctrl\"} {shortcut.toUpperCase()}\n                  </kbd>\n                  to open\n                </span>\n              )}\n            </div>\n          </Select.Popup>\n        </Select.Positioner>\n      </Select.Portal>\n    </Select.Root>\n  );\n}\n\nfunction groupBy(models: Model[]) {\n  const out: [string | undefined, Model[]][] = [];\n  for (const m of models) {\n    const last = out.find(([g]) => g === m.group);\n    if (last) last[1].push(m);\n    else out.push([m.group, [m]]);\n  }\n  return out;\n}\n\nconst subscribe = () => () => {};\nfunction useApple() {\n  return useSyncExternalStore(subscribe, () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent), () => true);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Trigger label: swaps in place and the width follows on a spring\n * -----------------------------------------------------------------------------------------------*/\n\nfunction TriggerLabel({ id, dir, reduce, children }: { id: string; dir: number; reduce: boolean; children: React.ReactNode }) {\n  const measure = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const first = useRef(true);\n\n  useLayoutEffect(() => {\n    const el = measure.current;\n    if (!el) return;\n    const w = el.offsetWidth;\n    if (first.current || reduce) {\n      first.current = false;\n      width.jump(w);\n      return;\n    }\n    const c = animate(width, w, spring.snappy);\n    return () => c.stop();\n  }, [id, reduce, width]);\n\n  return (\n    <motion.span style={{ width }} className=\"relative grid min-w-0 max-w-[16rem] overflow-hidden py-1\">\n      {/* The next name, laid out invisibly, tells the box how wide to become. */}\n      <span ref={measure} aria-hidden className=\"invisible col-start-1 row-start-1 w-max whitespace-nowrap\">\n        {children}\n      </span>\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={dir}>\n        <motion.span\n          key={id}\n          custom={dir}\n          className=\"col-start-1 row-start-1 truncate whitespace-nowrap\"\n          variants={{\n            enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: 8 * d, filter: \"blur(2px)\" }),\n            center: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n            exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, y: -8 * d, filter: \"blur(2px)\" }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The list and its rows\n * -----------------------------------------------------------------------------------------------*/\n\n// One highlight for the list. It glides after the pointer and jumps for arrow keys.\nfunction List({ reduce, children }: { reduce: boolean; children: React.ReactNode }) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(56);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      running.forEach((c) => c.stop());\n      running = [];\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.1 }));\n        return;\n      }\n      let top = 0;\n      for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;\n      const target = el.hasAttribute(\"data-disabled\") ? 0.5 : 1;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n        opacity.jump(target);\n      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(target);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      running.forEach((c) => c.stop());\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return (\n    <Select.List ref={setList} className=\"relative min-h-0 flex-1 overflow-y-auto overscroll-contain p-1 outline-none\">\n      <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-fg/[0.06]\" />\n      {children}\n    </Select.List>\n  );\n}\n\nfunction ModelRow({ model: m, reduce }: { model: Model; reduce: boolean }) {\n  const unavailable = m.disabled;\n  const caps = m.capabilities ?? [];\n  // What a screen reader hears after the name: the row's details, in words.\n  const details = [\n    unavailable ? m.disabledReason : m.description,\n    caps.map((c) => capabilityLabel[c]).join(\", \"),\n    m.speed ? speedLabel[m.speed] : \"\",\n    m.cost ? costLabel[m.cost] : \"\",\n  ]\n    .filter(Boolean)\n    .join(\". \");\n\n  return (\n    <Select.Item\n      value={m.value}\n      label={m.name}\n      disabled={unavailable}\n      className={cn(\n        \"relative grid cursor-default grid-cols-[16px_minmax(0,1fr)_auto] gap-x-2.5 rounded-lg px-2 py-2 outline-none\",\n        \"data-disabled:cursor-not-allowed\",\n      )}\n    >\n      <span className=\"grid h-[18px] place-items-center text-fg\">\n        <Select.ItemIndicator className=\"grid place-items-center\">\n          <SelectedCheck reduce={reduce} />\n        </Select.ItemIndicator>\n      </span>\n\n      <span className=\"flex min-w-0 flex-col gap-0.5\">\n        <span className=\"flex min-w-0 items-center gap-1.5\">\n          {m.icon && <span className=\"grid size-4 shrink-0 place-items-center text-fg-3 [&_svg]:size-3.5\">{m.icon}</span>}\n          <Select.ItemText className={cn(\"truncate text-[13px] font-medium leading-[18px] tracking-[-0.005em]\", unavailable ? \"text-fg-3\" : \"text-fg\")}>{m.name}</Select.ItemText>\n          {m.tag && <span className=\"shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] font-medium leading-4 text-fg-2\">{m.tag}</span>}\n        </span>\n        {(unavailable ? m.disabledReason : m.description) && (\n          <span className={\"flex min-w-0 items-center gap-1 text-[12px] leading-4 text-fg-3\"}>\n            {unavailable && <Lock size={12} className=\"shrink-0\" />}\n            <span className=\"truncate\">{unavailable ? m.disabledReason : m.description}</span>\n          </span>\n        )}\n        {caps.length > 0 && !unavailable && (\n          <span aria-hidden className=\"mt-1 flex flex-wrap gap-1\">\n            {caps.map((c) => (\n              <span key={c} className=\"rounded-[4px] bg-fg/[0.06] px-1.5 text-[10.5px] font-medium leading-4 text-fg-2\">\n                {capabilityLabel[c]}\n              </span>\n            ))}\n          </span>\n        )}\n        <span className=\"sr-only\">{details}</span>\n      </span>\n\n      <span aria-hidden className={cn(\"flex flex-col items-end gap-1.5 pt-1 text-fg-2\", unavailable && \"opacity-50\")}>\n        {m.speed && <SpeedBars level={m.speed} />}\n        {m.cost && <Cost level={m.cost} />}\n      </span>\n    </Select.Item>\n  );\n}\n\nfunction SelectedCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.28, ease: ease.out, delay: 0.04 }} />\n    </svg>\n  );\n}\n\nfunction SpeedBars({ level, className }: { level: 1 | 2 | 3; className?: string }) {\n  return (\n    <span className={cn(\"flex h-2.5 items-end gap-[2px]\", className)}>\n      {[1, 2, 3].map((i) => (\n        <span key={i} className={cn(\"w-[3px] rounded-[1px]\", i <= level ? \"bg-current\" : \"bg-fg/15\")} style={{ height: 4 + i * 2 }} />\n      ))}\n    </span>\n  );\n}\n\nfunction Cost({ level }: { level: 1 | 2 | 3 }) {\n  return (\n    <span className=\"flex font-mono text-[10.5px] leading-3 tracking-[-0.02em]\">\n      {[1, 2, 3].map((i) => (\n        <span key={i} className={i <= level ? \"text-fg-2\" : \"text-fg-4 opacity-60\"}>\n          $\n        </span>\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/model-picker"
}
