Skip to content

Model picker

A compact menu for choosing the model behind a prompt.

Category
AI
Adoption
Not used yet
import { ModelPicker } from "@oration/canon/components/ai/model-picker";
packages/canon/src/components/ai/model-picker.tsx
import { 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

In a composer, Send is the one filled button. The model picker is an extra-small ghost button that only fills Well Gray on hover or while its menu is open.

The Quiet Indigo Rule

The selected model is marked with an ink check, not an indigo fill. Menu highlight is Menu Hover.

Anatomy#

  1. Trigger. An extra-small (24px) ghost button with 8px corners in Slate Meta, up to 10rem wide. It turns ink on hover.
  2. Current model. The selected model's label, truncated to fit.
  3. Chevron. A 12px chevron at the end.
  4. Group label. Model, in 12px medium Slate Meta.
  5. Model. A radio item: the label in 14px, highlighted in Menu Hover.
  6. Description. Optional 12px Slate Meta line under the label.
  7. 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.

value is auto
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.

Enter sends, Shift+Enter adds a new line.

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#

Rest
Hover
Focus visible
Open
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>    );}
States
StateTreatment
RestSlate Meta text on no fill.
HoverInk text on Well Gray, over 150ms.
Focus visibleAn indigo border and a 3px Focus Indigo ring at 40%.
OpenThe 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.
HighlightedThe item under the pointer or keyboard fills Menu Hover.
SelectedA check at the right of the current model.
Unknown valueIf 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 onValueChange with the model's id and closes the menu; focus returns to the trigger.
  • It is controlled: pass value and update it in onValueChange. Prompt bar keeps its own copy when you don't pass model.
  • side defaults to top, because the prompt bar sits at the bottom of a panel. Use bottom in a header.
  • The menu portals to the body, so it isn't clipped by the composer.

Do and don't#

Do. Label models with the names people know and say what each is good for: GPT-4.1, OpenAI, best for drafting.
Don't. Use raw API identifiers such as gpt-4.1-2025-04-14 as labels. They truncate in the trigger and mean nothing to most people.
Do. Keep the picker as the quiet ghost trigger in the toolbar, so Send stays the one strong action.
Don't. Restyle it as an outlined select at full control height. It competes with Send and makes model choice look like a required field.

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 menuitemradio with aria-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.
Keyboard interactions
KeysAction
EnterOpens the menu. Space and ↓ also open it.
↓Moves to the next model. ↑ moves to the previous one.
HomeMoves to the first model. End moves to the last.
EnterSelects the highlighted model and closes the menu.
EscCloses the menu without changing the model.
A–ZJumps to the next model starting with that letter.

Design tokens#

Design tokens
TokenUsed for
--muted-foregroundTrigger text at rest; group label and descriptions
--foregroundTrigger text on hover; model labels and the check
--mutedTrigger hover and open fill
--accentHighlighted item (Menu Hover)
--popoverMenu surface
--ringFocus border and ring
--radius-md8px 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..

Props of ModelPicker
PropTypeDefaultDescription
modelsRequiredPromptModel[]No default{ id: string; label: string; description?: string }[].
valueRequiredstring | undefinedNo defaultThe selected model's id. Falls back to the first model.
onValueChangeRequired(id: string) => voidNo defaultCalled with the chosen model's id.
side"top" | "bottom""top"Which side of the trigger the menu opens on.
classNamestringNo defaultMerged 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.