Pro

Model picker

Compact trigger that resizes to fit the current model’s name. It opens a list of models with one-line descriptions and a drawn check on the selection.

In the composer

Replies use Balanced.

On its own

Summaries are written by

Installation

With UI Polish connected, ask your agent:

Add the UI Polish model picker to this project.

Or with the shadcn CLI:

npx shadcn@latest add https://uipolish.com/r/model-picker.json
Package@base-ui/react
Brings inIcons, Text morph

A compact trigger that shows the current model and opens a list of models, each with a name and a one-line description. It follows the WAI-ARIA select-only combobox pattern: a trigger with role="combobox" and aria-haspopup="listbox", option roles, aria-selected, arrow keys and typeahead. The trigger resizes to fit the name it shows, and the name morphs into the new one when the model changes.

The behavior is built on Base UI's <Select>: it handles focus, keyboard, typeahead, positioning and dismissal.

TSX
const models = [  { id: "fast", name: "Fast", description: "Quick answers to short questions" },  { id: "balanced", name: "Balanced", description: "Everyday writing, code and analysis" },  { id: "deep", name: "Deep", description: "Thinks longer on hard problems" },];<PromptInput  onSubmit={send}  footer={<ModelPicker models={models} value={model} onValueChange={setModel} side="top" />}/>

When to use it

  • Choosing which model answers, in a composer toolbar or a settings row.
  • Any short list of named options where each needs one line of explanation and the current choice should always be visible.

When not to

  • Options that need more than a line to compare (pricing, limits, a table of capabilities): link to a page that compares them.
  • More than about eight options: use a command palette or a searchable combobox.
  • Actions rather than a value: use a dropdown menu.
  • A form field that submits with the form: use a native select.

States

StateWhat you seeHow
RestName and a chevron in muted text, no fill
HoverText darkens, a 6% fill appears behind it@media (hover: hover)
PressFill darkens to 10% at once. The trigger never shrinks: it opens a list, and the list opens from where the trigger really is:active on ::before
OpenFill stays, the chevron's tip moves to point uparia-expanded
Highlighted optionOne rounded fill under the option; it slides from the last onepointer and keyboard share it, data-highlighted
Selected optionA check at the end of the name's linearia-selected
Disabled optionName in the subtle color; can be reached, can't be picked. Say why in the descriptiondisabled, data-disabled
Changed modelThe name morphs (shared letters slide), and the trigger's width follows on the same springmeasured width, TextMorph
Flipped / shiftedOpens the other way near a viewport edge and stays 8px inside it; the list scrolls if it's taller than the roomBase UI Positioner, --available-height

Rows. The list is a menu: inset --ui-inset-menu (12px) inside its 24px corner, rows with the concentric 12px radius and --ui-pad-row (12px) inside them, so the text clears each row's curve. The same as the dropdown menu and the command palette.

Why the width is measured. A pill sized by its content jumps when the label changes. The trigger renders an invisible copy of its content, measures it, and sets its own width in px, so the width can transition from the old label's width to the new one. Nothing else in the row moves suddenly.

Keyboard

KeyOn the triggerIn the list
Enter, Space, ArrowDown, ArrowUpOpen, highlight the selected modelEnter and Space pick the highlighted model and close
LettersSelect the first model starting with them, like a native selectTypeahead; repeating one letter cycles
Home, End, PageUp, PageDownFirst / last model
ArrowDown, ArrowUpNext / previous; stops at the ends like a native select
EscapeClose, focus back on the trigger
TabClose and move on to whatever follows the trigger

Pointer

  • Opens on press, not on release. Pressing, dragging to an option and releasing picks it, the way native selects do.
  • On touch it opens on tap, since a press might start a scroll.
  • A press outside closes the list. The page doesn't lock its scroll while the list is open (modal={false}).

The list's edge is the one in --ui-shadow-floating: the same outline color as fields (--ui-border-strong), drawn once. Don't add a ring of your own on top.

Motion

WhatDurationEasing
List opens: fade and scale 0.97 → 1 from the trigger side--ui-dur-fast--ui-ease-enter
List closes--ui-dur-instant--ui-ease-exit
Highlight moves between options--ui-dur-snap--ui-ease-snap
Check draws in on the new selection--ui-dur-base--ui-ease-draw
Name morph and trigger width--ui-dur-move--ui-ease-move
Fill and text color--ui-dur-instant--ui-ease-color

Under prefers-reduced-motion: reduce the list appears at once, the highlight jumps, the check appears drawn, and the name and width change in one step.

Accessibility

  • The trigger's name includes the value: "Model: Balanced". Pass label for another context ("Summary model").
  • Focus moves into the list when it opens and follows the highlighted option; each description is linked with aria-describedby.
  • On close focus returns to the trigger. After a mouse or touch pick the browser doesn't paint the keyboard focus line on it (:focus-visible).
  • Options are 44px tall and the trigger has a 40px hit area on coarse pointers.

Before / after

Before: a native select can't show a description per option, and a custom div list has no roles, no keyboard and a trigger that jumps to the new label's width.

TSX
<select value={model} onChange={(e) => setModel(e.target.value)}>  <option value="fast">Fast</option>  <option value="deep">Deep</option></select>

After:

TSX
<ModelPicker models={models} value={model} onValueChange={setModel} />