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