Model picker
A compact menu for choosing the model behind a prompt.
- Status
- Experimental
- Level
- Molecule
- Category
- AI
- Adoption
- Not used yet
import { ModelPicker } from "@oration/canon/components/ai/model-picker";packages/canon/src/components/ai/model-picker.tsximport { ModelPicker } from "@oration/canon/components/ai/model-picker";import { Button } from "@oration/canon/components/button";import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArrowUpIcon, PaperclipIcon } from "lucide-react";import * as React from "react";export function Hero() { const models = [ { id: "auto", label: "Auto", description: "Picks a model per step" }, { id: "gpt-4-1", label: "GPT-4.1", description: "OpenAI, best for drafting", }, { id: "gemini-2-5-pro", label: "Gemini 2.5 Pro", description: "Google, long context", }, { id: "mistral-large", label: "Mistral Large", description: "Mistral, lowest cost", }, ]; const id = React.useId(); const [model, setModel] = React.useState("gpt-4-1"); const [text, setText] = React.useState( "Why are remittance calls transferring more this week?", ); const current = models.find((m) => m.id === model)?.label ?? "Auto"; return ( <form className="flex w-full max-w-lg flex-col rounded-xl bg-card shadow-border" onSubmit={(event) => { event.preventDefault(); toast.add({ title: `Sent with ${current}`, description: text }); }} > <label htmlFor={id} className="sr-only"> Message Copilot </label> <textarea id={id} value={text} onChange={(event) => setText(event.target.value)} rows={2} className="w-full resize-none bg-transparent px-3 pt-3 text-sm text-foreground outline-none placeholder:text-muted-foreground" placeholder="Ask anything" /> <div className="flex items-center gap-1 px-2 pb-2"> <IconAction label="Attach a file" size="icon-xs" onClick={() => toast.add({ title: "Attached payment-run-sep-25.csv" }) } > <PaperclipIcon aria-hidden="true" /> </IconAction> <ModelPicker models={models} value={model} onValueChange={setModel} /> <Tooltip> <TooltipTrigger render={ <Button type="submit" size="icon-sm" aria-label="Send" className="ml-auto rounded-full" /> } > <ArrowUpIcon aria-hidden="true" /> </TooltipTrigger> <TooltipContent>Send</TooltipContent> </Tooltip> </div> </form> );}Usage#
Model picker is the small ghost button in a prompt toolbar that shows the current model and opens a menu of models, each with a one-line description. It lets someone switch the model behind a single prompt, such as a Copilot question, without leaving the composer. It is deliberately quiet: 24px, Slate Meta, no border. The common mistake is using it to configure an agent. An agent's LLM, speech-to-text and text-to-speech choices belong to Provider model picker, with speed and cost beside each option.
When to use
- In a prompt bar's toolbar, beside attach and dictation, before the send button.
- In the header of a test panel where someone compares answers from different models.
- When there are two to six models and each can be summed up in a few words.
When not to use
- To choose the models an agent runs on, with providers, speed and cost. Use Provider model picker
- For a model setting in a settings row that saves with the page. Use Select field
- To pick a voice and preview it. Use Voice picker
- For a long list of models that needs search. Use Combobox
The One Filled Button Rule
The Quiet Indigo Rule
Anatomy#
- Trigger. An extra-small (24px) ghost button with 8px corners in Slate Meta, up to 10rem wide. It turns ink on hover.
- Current model. The selected model's label, truncated to fit.
- Chevron. A 12px chevron at the end.
- Group label. Model, in 12px medium Slate Meta.
- Model. A radio item: the label in 14px, highlighted in Menu Hover.
- Description. Optional 12px Slate Meta line under the label.
- Check. Marks the selected model at the right.
Examples#
With descriptions
Each model gets one line saying who makes it and what it is good for. The value is the model's id.
import { ModelPicker } from "@oration/canon/components/ai/model-picker";import * as React from "react";export function Basic() { const models = [ { id: "auto", label: "Auto", description: "Picks a model per step" }, { id: "gpt-4-1", label: "GPT-4.1", description: "OpenAI, best for drafting", }, { id: "gemini-2-5-pro", label: "Gemini 2.5 Pro", description: "Google, long context", }, { id: "mistral-large", label: "Mistral Large", description: "Mistral, lowest cost", }, ]; const [model, setModel] = React.useState("auto"); return ( <div className="flex items-center gap-3"> <ModelPicker models={models} value={model} onValueChange={setModel} side="bottom" /> <span className="text-xs text-muted-foreground"> value is <span className="font-mono">{model}</span> </span> </div> );}Labels only
Leave out description when the labels already explain themselves.
import { ModelPicker } from "@oration/canon/components/ai/model-picker";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function LabelsOnly() { const [model, setModel] = React.useState("fast"); return ( <ModelPicker models={[ { id: "fast", label: "Fast" }, { id: "balanced", label: "Balanced" }, { id: "thorough", label: "Thorough" }, ]} value={model} onValueChange={(next) => { setModel(next); toast.add({ title: "Model changed", description: `Next answers use ${next}.`, }); }} side="bottom" /> );}In a panel header
With side="bottom" the menu opens downward, for a picker at the top of a panel.
Test a question
Ask what a supplier might ask to see the passages the agent would use.
import { ModelPicker } from "@oration/canon/components/ai/model-picker";import * as React from "react";export function InAHeader() { const models = [ { id: "gpt-4-1", label: "GPT-4.1", description: "OpenAI, best for drafting", }, { id: "gemini-2-5-pro", label: "Gemini 2.5 Pro", description: "Google, long context", }, ]; const [model, setModel] = React.useState("gpt-4-1"); return ( <div className="w-full max-w-md rounded-xl bg-card shadow-border"> <div className="flex items-center gap-2 border-b border-border px-4 py-3"> <h3 className="text-sm font-semibold text-foreground"> Test a question </h3> <ModelPicker models={models} value={model} onValueChange={setModel} side="bottom" className="ml-auto" /> </div> <p className="px-4 py-6 text-center text-13 text-muted-foreground"> Ask what a supplier might ask to see the passages the agent would use. </p> </div> );}Inside Prompt bar
Where the product uses it. Pass models, model and onModelChange to Prompt bar and the picker appears in its toolbar; the chosen id arrives with each submit.
import { PromptBar } from "@oration/canon/components/ai/prompt-bar";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function InPromptBar() { const models = [ { id: "auto", label: "Auto", description: "Picks a model per step" }, { id: "gpt-4-1", label: "GPT-4.1", description: "OpenAI, best for drafting", }, { id: "gemini-2-5-pro", label: "Gemini 2.5 Pro", description: "Google, long context", }, { id: "mistral-large", label: "Mistral Large", description: "Mistral, lowest cost", }, ]; const [model, setModel] = React.useState("auto"); return ( <div className="w-full max-w-lg"> <PromptBar placeholder="Ask Copilot about suppliers, payments or calls" label="Message Copilot" models={models} model={model} onModelChange={setModel} dictation={false} onSubmit={(text, details) => toast.add({ title: `Sent with ${details.model ?? model}`, description: text, }) } /> </div> );}States#
import { ModelPicker } from "@oration/canon/components/ai/model-picker";import { cn } from "@oration/canon/lib/utils";export function StatesMatrix() { const models = [{ id: "gpt-4-1", label: "GPT-4.1" }]; const cells = [ { label: "Rest", className: "" }, { label: "Hover", className: "bg-muted text-foreground" }, { label: "Focus visible", className: "border-ring ring-3 ring-ring/40", }, { label: "Open", className: "bg-muted text-foreground" }, ]; return ( <div className="grid w-full grid-cols-2 gap-6 sm:grid-cols-4"> {cells.map((cell) => ( <div key={cell.label} className="flex flex-col items-start gap-2" > <span className="text-xs text-muted-foreground"> {cell.label} </span> <ModelPicker models={models} value="gpt-4-1" onValueChange={() => {}} className={cn("pointer-events-none", cell.className)} /> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Slate Meta text on no fill. |
| Hover | Ink text on Well Gray, over 150ms. |
| Focus visible | An indigo border and a 3px Focus Indigo ring at 40%. |
| Open | The trigger keeps the hover fill while the 16rem menu is open. The menu opens over 160ms from a 0.97 scale and closes in 110ms. |
| Highlighted | The item under the pointer or keyboard fills Menu Hover. |
| Selected | A check at the right of the current model. |
| Unknown value | If value matches no model, the first model shows and is checked. |
Behavior#
- Built on the Base UI menu through Dropdown menu: click, Enter, Space or the arrow keys open it, and focus moves into the list.
- The items are a radio group. Choosing one calls
onValueChangewith the model'sidand closes the menu; focus returns to the trigger. - It is controlled: pass
valueand update it inonValueChange. Prompt bar keeps its own copy when you don't passmodel. sidedefaults totop, because the prompt bar sits at the bottom of a panel. Usebottomin a header.- The menu portals to the body, so it isn't clipped by the composer.
Do and don't#
Content#
- Labels are the model's public name in its own casing: GPT-4.1, Gemini 2.5 Pro, Mistral Large.
- Descriptions name the provider and one strength, separated by a comma: Google, long context, Mistral, lowest cost.
- Offer Auto first when the product can choose per step, described as Picks a model per step.
- Keep the list to about six. Past that, the choice belongs in settings.
Accessibility#
- The trigger's name is Model: GPT-4.1, so it says what it controls, not only the value.
- Menu items are
menuitemradiowitharia-checked, so the current model is announced. - Focus moves into the menu on open and returns to the trigger on close.
- The trigger is 24px tall, the minimum hit size. On touch-first surfaces use the prompt bar at
md.
| Keys | Action |
|---|---|
| Enter | Opens the menu. Space and ↓ also open it. |
| ↓ | Moves to the next model. ↑ moves to the previous one. |
| Home | Moves to the first model. End moves to the last. |
| Enter | Selects the highlighted model and closes the menu. |
| Esc | Closes the menu without changing the model. |
| A–Z | Jumps to the next model starting with that letter. |
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Trigger text at rest; group label and descriptions |
--foreground | Trigger text on hover; model labels and the check |
--muted | Trigger hover and open fill |
--accent | Highlighted item (Menu Hover) |
--popover | Menu surface |
--ring | Focus border and ring |
--radius-md | 8px trigger corners at xs; 8px item corners |
API reference#
ModelPicker
The trigger and menu. Also exported: the PromptModel type.
Other props spread onto Nothing. Only the props below are read..
| Prop | Type | Default | Description |
|---|---|---|---|
modelsRequired | PromptModel[] | No default | { id: string; label: string; description?: string }[]. |
valueRequired | string | undefined | No default | The selected model's id. Falls back to the first model. |
onValueChangeRequired | (id: string) => void | No default | Called with the chosen model's id. |
side | "top" | "bottom" | "top" | Which side of the trigger the menu opens on. |
className | string | No default | Merged onto the trigger button. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Only Prompt bar uses it. No other screen imports it.
The trigger's name always starts with Model:, and there is no prop to change it. With no models it reads Model: None and opens an empty menu; Prompt bar hides it in that case.
Descriptions pack the provider and a strength into one string. There are no fields for provider, speed or cost as Provider model picker has.
Long labels truncate at 10rem with no tooltip for the full name.