{"slug":"model-picker","category":"composer","job":"steer","added":"2026-11-07T22:47Z","title":"Model Picker","description":"A composer chip that opens a list of models, each with a one-line description, plan-locked options that lead to an upgrade, and an extended-thinking switch.","url":"https://ai-patterns.app/patterns/composer/model-picker","markdown":"https://ai-patterns.app/patterns/composer/model-picker.md","json":"https://ai-patterns.app/api/patterns/composer/model-picker","seenIn":[{"team":"Cursor","source":"https://docs.cursor.com/models"},{"team":"chatgpt","source":"https://help.openai.com/en/articles/20001354-gpt-6-and-other-models-in-chatgpt"},{"team":"Claude","source":"https://support.claude.com/en/articles/8664678-change-the-model-effort-and-thinking-settings"},{"team":"Google Gemini","source":"https://support.google.com/gemini/answer/18648722?hl=en"},{"team":"v0","source":"https://v0.app/changelog"}],"spec":"# Model Picker\n\n## Summary\nA compact chip in the composer that opens a list of models, each with a one-line description of what it is good at, plus an optional extended-thinking switch. It lets users trade speed for capability per message without leaving the prompt.\n\n## When to use\n- Products that offer more than one model or reasoning mode, and where the choice changes the result in a way users can feel (speed, depth, cost).\n- Power-user surfaces — chat, coding assistants, research tools — where people switch models between tasks.\n- Plans that unlock better models, so locked options double as an upgrade path.\n\n## When not to use\n- A single model, or models that differ only in ways users can't perceive. Route automatically instead of asking.\n- Consumer surfaces where the choice would be noise. Offer an \"auto\" default and hide the picker behind settings.\n- Choosing a provider or API key — that is configuration, not a per-message choice.\n\n## Anatomy\n- Trigger chip: the selected model's name, a \"· Thinking\" suffix when extended thinking is on, and a chevron.\n- Menu: one row per model with name, optional badge (\"New\", \"Beta\") and a one-line description.\n- Selected indicator: a check on the current model.\n- Locked rows: muted name and an \"Upgrade\" tag (or a lock icon when there is no upgrade path).\n- Optional footer: an extended-thinking switch with a short explanation.\n\n## Behavior\n- The menu opens above the trigger by default, because composers sit at the bottom of the screen.\n- Choosing a model closes the menu and returns focus to the trigger; toggling thinking keeps it open.\n- Locked models stay visible and can't be selected. Choosing one closes the menu and starts the upgrade flow instead, so the row is an upgrade path, not a dead end.\n- The choice persists for the conversation (and ideally as the user's default). A model change mid-conversation applies to the next message, not to past ones.\n- Clicking outside, pressing Escape or tabbing away closes the menu.\n\n## Content guidelines\n- Describe what a model is for, not its parameters: \"Quick answers for everyday tasks\", not \"8B params, 128k context\".\n- Keep names short and stable; put version numbers in the description or badge, not the trigger.\n- Order models from fastest to most capable, so the list reads as a scale.\n- Use badges sparingly — one \"New\" at a time.\n\n## Accessibility\n- The trigger is a real button with `aria-haspopup=\"listbox\"` and `aria-expanded`.\n- The list uses `role=\"listbox\"` with `role=\"option\"` rows, `aria-selected` on the current model and `aria-disabled` on locked ones; focus stays on the list and `aria-activedescendant` tracks the highlighted row.\n- Arrow keys move, Home/End jump, Enter or Space selects, Escape closes and returns focus to the trigger.\n- The thinking control is a `role=\"switch\"` with a visible label.\n\n## Related patterns\n- **Prompt Bar** — Use it instead when a minimal dropdown is enough and models don't need descriptions, plan gating or a thinking switch.\n- **Cost Estimate** — Use it as well when models are priced differently, so people see what the chosen one will cost before running.\n- **Rate Limit** — Use it as well when people hit limits on an expensive model, and point them to the picker to step down.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Check, ChevronDown, Lock } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface ModelOption {\n  id: string;\n  label: string;\n  /** One line on what the model is good at — not a spec sheet. */\n  description?: string;\n  /** A short tag such as \"New\" or \"Beta\". */\n  badge?: string;\n  /** Not available on the user's plan: shown, but not selectable. */\n  locked?: boolean;\n}\n\nexport interface ModelPickerProps {\n  models: ModelOption[];\n  value: string;\n  onChange: (id: string) => void;\n  /** Show an extended-thinking switch under the list when this is defined. */\n  thinking?: boolean;\n  onThinkingChange?: (thinking: boolean) => void;\n  thinkingLabel?: string;\n  upgradeLabel?: string;\n  onUpgrade?: () => void;\n  /** Which way the menu opens. Composers sit at the bottom, so \"up\" is the default. */\n  side?: \"up\" | \"down\";\n  className?: string;\n}\n\nexport function ModelPicker({\n  models,\n  value,\n  onChange,\n  thinking,\n  onThinkingChange,\n  thinkingLabel = \"Extended thinking\",\n  upgradeLabel = \"Upgrade\",\n  onUpgrade,\n  side = \"up\",\n  className,\n}: ModelPickerProps) {\n  const [open, setOpen] = React.useState(false);\n  const [active, setActive] = React.useState(0);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const id = React.useId();\n\n  const selected = models.find((m) => m.id === value) ?? models[0];\n\n  React.useEffect(() => {\n    if (!open) return;\n    function handle(e: MouseEvent) {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);\n    }\n    document.addEventListener(\"mousedown\", handle);\n    return () => document.removeEventListener(\"mousedown\", handle);\n  }, [open]);\n\n  function openMenu() {\n    setActive(Math.max(0, models.findIndex((m) => m.id === selected?.id)));\n    setOpen(true);\n    requestAnimationFrame(() => listRef.current?.focus());\n  }\n\n  function close() {\n    setOpen(false);\n    triggerRef.current?.focus();\n  }\n\n  function choose(index: number) {\n    const model = models[index];\n    if (!model) return;\n    if (model.locked) {\n      // A locked row is an upgrade path, not a dead end.\n      if (onUpgrade) {\n        setOpen(false);\n        onUpgrade();\n      }\n      return;\n    }\n    onChange(model.id);\n    close();\n  }\n\n  function handleListKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setActive((i) => (i + 1) % models.length);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setActive((i) => (i - 1 + models.length) % models.length);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      setActive(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      setActive(models.length - 1);\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      choose(active);\n    } else if (e.key === \"Escape\" || e.key === \"Tab\") {\n      if (e.key === \"Escape\") e.preventDefault();\n      close();\n    }\n  }\n\n  return (\n    <div ref={rootRef} className={cn(\"relative inline-flex\", className)}>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        aria-controls={open ? `${id}-list` : undefined}\n        onClick={() => (open ? setOpen(false) : openMenu())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            openMenu();\n          }\n        }}\n        className=\"flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground\"\n      >\n        <span>{selected?.label}</span>\n        {thinking && <span className=\"text-muted-foreground/70\">· Thinking</span>}\n        <ChevronDown className={cn(\"size-3.5 transition-transform\", open && \"rotate-180\")} aria-hidden />\n      </button>\n\n      <AnimatePresence>\n        {open && (\n          <motion.div\n            initial={{ opacity: 0, y: side === \"up\" ? 4 : -4, scale: 0.98 }}\n            animate={{ opacity: 1, y: 0, scale: 1 }}\n            exit={{ opacity: 0, y: side === \"up\" ? 4 : -4, scale: 0.98 }}\n            transition={{ duration: 0.14, ease: \"easeOut\" }}\n            className={cn(\n              \"absolute left-0 z-20 w-72 overflow-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg\",\n              side === \"up\" ? \"bottom-full mb-2 origin-bottom-left\" : \"top-full mt-2 origin-top-left\"\n            )}\n          >\n            <ul\n              ref={listRef}\n              id={`${id}-list`}\n              role=\"listbox\"\n              tabIndex={-1}\n              aria-label=\"Model\"\n              aria-activedescendant={`${id}-opt-${active}`}\n              onKeyDown={handleListKeyDown}\n              className=\"outline-none\"\n            >\n              {models.map((model, i) => {\n                const isSelected = model.id === selected?.id;\n                return (\n                  <li\n                    key={model.id}\n                    id={`${id}-opt-${i}`}\n                    role=\"option\"\n                    aria-selected={isSelected}\n                    aria-disabled={model.locked || undefined}\n                    onMouseEnter={() => setActive(i)}\n                    onClick={() => choose(i)}\n                    className={cn(\n                      \"flex cursor-pointer items-start gap-2 rounded-lg px-2.5 py-2\",\n                      i === active && \"bg-accent\",\n                      model.locked && !onUpgrade && \"cursor-default\"\n                    )}\n                  >\n                    <div className=\"min-w-0 flex-1\">\n                      <div className=\"flex items-center gap-1.5\">\n                        <span className={cn(\"text-sm font-medium\", model.locked && \"text-muted-foreground\")}>\n                          {model.label}\n                        </span>\n                        {model.badge && (\n                          <span className=\"rounded-full border px-1.5 text-[10px] leading-4 text-muted-foreground\">\n                            {model.badge}\n                          </span>\n                        )}\n                      </div>\n                      {model.description && (\n                        <p className=\"mt-0.5 text-xs text-pretty text-muted-foreground\">{model.description}</p>\n                      )}\n                    </div>\n                    {model.locked ? (\n                      onUpgrade ? (\n                        // Not a nested button: the whole row is the upgrade action.\n                        <span className=\"mt-0.5 flex shrink-0 items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 text-[11px] font-medium text-foreground\">\n                          <Lock className=\"size-3\" aria-hidden />\n                          {upgradeLabel}\n                        </span>\n                      ) : (\n                        <Lock className=\"mt-1 size-3.5 shrink-0 text-muted-foreground\" aria-label=\"Locked\" />\n                      )\n                    ) : (\n                      isSelected && <Check className=\"mt-1 size-4 shrink-0\" aria-hidden />\n                    )}\n                  </li>\n                );\n              })}\n            </ul>\n\n            {thinking !== undefined && (\n              <div className=\"mt-1 border-t px-2.5 pt-2 pb-1.5\">\n                <label className=\"flex cursor-pointer items-center justify-between gap-3\">\n                  <span className=\"text-sm\">{thinkingLabel}</span>\n                  <Switch checked={thinking} onCheckedChange={(v) => onThinkingChange?.(v)} label={thinkingLabel} />\n                </label>\n                <p className=\"mt-0.5 text-xs text-muted-foreground\">Slower, more careful answers for hard problems.</p>\n              </div>\n            )}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Switch({\n  checked,\n  onCheckedChange,\n  label,\n}: {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  label: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-label={label}\n      onClick={() => onCheckedChange(!checked)}\n      className={cn(\n        \"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors\",\n        checked ? \"bg-foreground\" : \"bg-muted-foreground/30\"\n      )}\n    >\n      <motion.span\n        layout\n        transition={{ type: \"spring\", stiffness: 500, damping: 32 }}\n        className={cn(\"size-4 rounded-full bg-background shadow-sm\", checked ? \"ml-[18px]\" : \"ml-0.5\")}\n      />\n    </button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}