ModelSelector
An AI model picker grouped by provider — per-model context window, cost tier and capability badges, disabled models that say why, searchable once the list is long, and a compact ghost trigger for the composer bar.
pnpm add @tessinaui/uiWhen to use
- Choosing which AI model answers, in a chat composer, a settings form or a playground.
- Options that need more than a name: context window, cost tier, capabilities, or why a model is not available right now.
- A list grouped by provider. Set
provideron each model and the sections follow, in the order each provider first appears. - A model that is unavailable but worth showing. Give it
disabledand adisabledReason; it stays in the list, reachable by keyboard, with the reason where the description would be. - A long list. Turn on
searchableand the trigger becomes a text field that filters as you type.
When not to use
- A flat mode or scope pill in the composer ("Search", "Deep research",
"Computer"). Use
PromptInputModelSelect, the generic single-select pill; it takes a model list too. Reach for ModelSelector when the list needs provider groups, metadata or search. - Plain options with no metadata. A Select is enough.
- A searchable list of ordinary values. Use a
Combobox; ModelSelector's
searchableis for model rows. - Choosing several items. It picks one model at a time.
- A real price list.
costTieris a three-step hint, not a price; put real pricing indescriptionif it matters.
Installation
pnpm add @tessinaui/uiUsage
import { ModelSelector } from "@tessinaui/ui/model-selector";
<ModelSelector
defaultValue="kestrel-swift"
models={[
{ value: "auto", label: "Auto", description: "Picks a model for each message" },
{
value: "kestrel-pro",
label: "Kestrel Pro",
provider: "Kestrel",
description: "Most capable for complex work",
contextWindow: 200_000,
costTier: "high",
capabilities: ["vision", "tools"],
},
{
value: "kestrel-max",
label: "Kestrel Max",
provider: "Kestrel",
disabled: true,
disabledReason: "Not available on your plan",
},
]}
/>Models are one flat array. provider becomes the section heading, in the
order each provider first appears; models with no provider sit at the top.
Examples
Default
Grouped by provider, with each model's context window, cost tier and capabilities in a trailing column.
Disabled with a reason
A model that cannot be picked stays in the list, and its reason takes the description line. The keyboard still reaches it, so the reason is read, not just seen.
Searchable
Past about fifteen models, turn on searchable: the trigger becomes a text
field that filters as you type (a Combobox), with an empty state when nothing
matches.
Validation
errorMessage replaces the supportingText line, turns the boundary to the
error intent and marks the control invalid, in both the button and the search
field. Clear it as soon as a model is picked.
In the composer
variant="ghost" is the compact trigger for a composer toolbar: no well, a
visually hidden label for its name, and w-auto on both the root and the
trigger so it sizes to the model it shows. w-auto shrinks it only inside a
flex row, as the toolbar is; in a block parent it still fills the width.
Model menu
ModelMenu is the menu form: a button you supply opens a short list of models,
one click picks, and the chosen row ends in a check. Each row can carry an
icon at its start and a quiet note before the check, such as the maker or a
price. Use it beside other menu triggers in a header or toolbar. Pass children
for content after the models, a separator and an effort submenu, say, or a
footer. The trigger names itself, so say the chosen model in it.
Right to left
dir="rtl" mirrors the layout and defaults every string to Arabic, including
the spoken description of each model.
On color
The trigger reads the plate's tone. The popup is a page surface, so its rows keep the page's inks.
API Reference
ModelSelector
| Prop | Type | Default | Description |
|---|---|---|---|
models | ModelSelectorModel[] | — | The models, flat. Grouped by provider |
value | string | null | — | Controlled selection |
defaultValue | string | null | null | Uncontrolled initial selection |
onValueChange | (value: string | null) => void | — | Called with the picked model's value |
searchable | boolean | false | A text field that filters as you type (Combobox) instead of a button (Select) |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | The input family's size ladder |
variant | "outline" | "ghost" | "underline" | "outline" | ghost is the composer trigger |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Boundary tint |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Trigger radius |
tone | "default" | "on-color" | inherited | Inherited from a wrapping <Surface> |
disabled | boolean | false | Disables the whole control |
label | string | "Model" | The control's accessible name |
visuallyHiddenLabel | boolean | false | Keeps the label for AT, hides it on screen |
placeholder | string | "Select a model" / "Search models…" | Shown with nothing selected |
required | boolean | false | Star on the label and required on the control |
supportingText | string | — | Helper line under the trigger, joined into its description |
errorMessage | string | — | Validation message. Replaces supportingText, implies intent="error" and marks the control invalid |
dir | "ltr" | "rtl" | — | Text direction; "rtl" also picks the Arabic strings |
strings | Partial<ModelSelectorStrings> | — | Per-entry string overrides |
className | string | — | Classes on the root wrapper |
triggerClassName | string | — | Classes on the trigger (Select) or the field (Combobox) |
aria-* props land on the control; everything else lands on the root.
ModelSelectorModel
| Field | Type | Description |
|---|---|---|
value | string | What onValueChange reports |
label | string | The option's accessible name, alone |
provider | string | Section heading |
description | string | One line under the label |
contextWindow | number | Tokens, rendered compact ("200K") |
costTier | "low" | "medium" | "high" | Shown as $ / $$ / $$$, spoken as a word |
capabilities | string[] | "vision", "tools", "reasoning" and "web" are localized; anything else renders as given |
disabled | boolean | In the list, reachable, not selectable |
disabledReason | string | Why. Replaces the description line; defaults to "Unavailable" |
icon | ReactNode | Decorative glyph at the row's start |
ModelMenu
| Prop | Type | Default | Description |
|---|---|---|---|
models | ModelMenuModel[] | — | The models, flat |
value | string | — | The chosen model's value. Controlled |
onValueChange | (value: string) => void | — | Called with the picked model's value |
trigger | ReactElement | — | The button that opens the menu. It names itself, so say the chosen model |
label | string | "Model" | Names the group of models |
size | "xs" | "sm" | "md" | "lg" | "xl" | "sm" | Menu density |
align | "start" | "center" | "end" | "end" | Which edge of the trigger the menu lines up with |
contentClassName | string | — | Classes for the menu panel, its width |
quietCheck | boolean | false | Draw the chosen row's check in the row's own icon ink rather than the strong foreground, for a menu whose notes carry their own quiet ink |
children | ReactNode | — | Content after the models: a separator and a submenu, a footer |
ModelMenuModel has value, label (the row's accessible name, alone), icon
(decorative), note (quiet text before the check; say what it means in label
or in screen-reader-only text inside it) and disabled.
composeModelDescription
composeModelDescription(model, strings, locale) returns the sentence an
option is described by — exported for tests and for anything that lists
models outside the picker.
DOM contract
| Attribute | Where | Values |
|---|---|---|
data-component | the root | model-selector |
data-slot | each option · its metadata column | model-selector-option · model-selector-meta |
aria-disabled | a disabled option | true |
aria-describedby | every option with metadata | the composed sentence |
Accessibility
- Two bases, on purpose. Without
searchablethe control is a Select: a button that opens a listbox. With it, a Combobox: a text field that filters a listbox. A control with no text field is a Select, so the role follows the choice rather than the list length. - The name is the label alone. An option announces as "Kestrel Pro", never as
its whole row. Everything else — the description, "200K context", "supports
vision and tools", the cost — is one sentence wired through
aria-describedby, read after the name. The badges and the metadata column arearia-hiddendecoration whose meaning is already in that sentence. - A disabled model says why. It stays in the list with
aria-disabled, the arrow keys reach it, and its description leads with the reason. It cannot be picked by keyboard or pointer. Typeahead skips it, as it does in a native<select>. - An error is text, not only colour.
errorMessagesetsaria-invalid, joins the message to the control's description and announces it through a polite live region; the red boundary only repeats it. - Keyboard: the house Select and Combobox contracts — arrows move, Enter picks, Escape closes, Home/End jump; in the searchable field typing filters.
- 375px: below
mdthe metadata column moves under the description as one compact line — "200K context · $$$ · vision, tools" — that wraps instead of truncating. Phone readers see what the sentence gives a screen reader, and rows keep the family's 44px touch floor and 16px touch type.
AT matrix: NOT yet screen-reader tested (hence beta). NVDA and JAWS:
untested, no Windows host (QA §5.6).
Notes
- Vocabulary: this component,
ModelMenuandPromptInputModelSelectall pick a model.ModelMenuis the short menu with a trigger of your own. The pill is the generic composer select — flat list, label, meta, description. ModelSelector is the model-specific one: groups, metadata, a disabled reason, search. - No skeleton export. Wrap the real thing while it loads —
<Skeleton loading={isPending} inline rounded="full"><ModelSelector … /></Skeleton>— so the placeholder is the same box (§2b). - Cost is relative.
costTieris a three-step hint, not a price; put real pricing indescriptionif it matters.