Model Picker
Compact model switcher with 36px phone touch targets that return to its compact desktop size at sm: one row per thing that can change, each showing its current value, with the choices in a submenu.
Installation
npx shadcn@latest add miskibin/chat-components/model-pickerAlso installs dropdown-menu, command.
Usage
"use client"
import { useState } from "react"
import {
DEFAULT_MODEL_EFFORTS,
ModelPicker,
type ModelOption,
} from "@/components/ui/model-picker"
const MODELS: ModelOption[] = [
{ id: "composer-2.5", name: "Composer 2.5", badge: "Cursor" },
{ id: "gpt-5.4", name: "GPT-5.4", badge: "OpenAI" },
]
export function Picker() {
const [model, setModel] = useState(MODELS[0].id)
const [effort, setEffort] = useState("medium")
return (
<ModelPicker
value={model}
onChange={setModel}
options={MODELS}
efforts={DEFAULT_MODEL_EFFORTS}
effort={effort}
onEffortChange={setEffort}
/>
)
}Examples
Just the models
Without efforts there is only one thing to change, so the menu is the model list — no row to hover through first. Disabled options stay listed and carry their disabledReason as the row tooltip, which answers “where did it go?” before it gets asked.
Long lists get a search field
Under searchThreshold models the submenu is plain rows and the menu’s typeahead is enough. Over it, cmdk takes the list over — it owns focus and the arrow keys, so typing filters instead of jumping.
Grouped by provider
groups sections the list — each option names its group by id, and the array order is the section order. Both list shapes carry the headings: plain rows get a label between runs, cmdk gets a real group, and the search field matches the group label so typing a provider narrows to its models. Anything without a known group stays at the top, unlabelled.
Name your own effort steps
efforts is plain data, so the scale is yours — two steps or six, whatever your backend accepts. Ship DEFAULT_MODEL_EFFORTS for the usual low/medium/high/xhigh ladder, or write your own; effortLabel renames the row.
Restyle the trigger
The trigger is a plain button: className merges last, and data-state="open" covers the open state. The effort suffix has its own slot, so it can be dimmed, recoloured, or hidden without touching the name.
Notes
API reference
ModelPicker
options*ModelOption[]- The list contents. An empty array disables the trigger. Past
searchThresholdthe list grows a search field that matchesname,badge,description,metaand the group label. groupsModelPickerGroup[]- Section definitions; the array order is the section order. Options with an unknown or absent
grouprender first, ungrouped, and a group nothing lands in is dropped. Omit it and the list stays flat. valuestring- Controlled selection. Omit to let it manage its own. An id that matches no option is not a selection: the trigger shows the
placeholderrather than naming a model no row is checked beside. defaultValuestring = options[0]?.id- Initial selection when uncontrolled.
onChange(id: string) => void- Fired on pick, then the menu closes. Disabled options never fire it.
effortsModelEffortOption[] | false = false- Reasoning-effort options.
falsedrops the row entirely and the menu becomes the model list itself; passDEFAULT_MODEL_EFFORTSor your own list to show it. effortstring- Controlled effort id.
defaultEffortstring = efforts[0]?.id- Initial effort when uncontrolled.
onEffortChange(id: string) => void- Fired on effort pick, then the menu closes.
side"top" | "bottom" = "top"- Which way the menu opens. Composers open top, navbars open bottom. Submenus pick their own side.
searchThresholdnumber = 8- Model counts above this get a search field; at or below it, a plain list the menu’s own typeahead can walk.
placeholderstring = "Model"- Trigger label while nothing is selected — including when the selected id names no option, which is what a catalog that is still loading looks like.
searchPlaceholderstring = "Search models…"- Placeholder inside the search field.
emptyMessagestring = "No models found."- Shown when the search matches nothing.
labelstring = "Model"- Label on the row that opens the model submenu.
effortLabelstring = "Effort"- Label on the row that opens the effort submenu.
disabledboolean = false- Disables the trigger.
ModelEffortOption
id*string- Stable key, reported by
onEffortChange. label*string- Shown in the row, in the submenu, and as the trigger suffix.
descriptionstring- Tooltip on the submenu row.
ModelOption
id*string- Stable key.
name*string- Shown in the trigger and the row.
badgestring- Short tag after the name — the provider, usually.
descriptionstring- Row tooltip, and a search keyword.
metastring- Trailing detail on the row, e.g. a size or a context length.
disabledboolean- Row stays visible but cannot be picked.
disabledReasonstring- Why it is unavailable, joined into the row tooltip.
groupstring- Id of the
ModelPickerGroupthis model belongs to. Unknown or absent means ungrouped. contextLengthnumber- Usable context window in tokens. The picker never renders it — it rides along so a host that already has this list can size a
ContextMeterwithout a second lookup table beside it. Put a human-readable size inmetaif you want it on the row.
ModelPickerGroup
id*string- Stable key, referenced by
ModelOption.group. label*string- Section heading, and a search keyword for its models.
iconReactNode- Small leading mark in the heading, e.g. a provider logo. Sized to 3.5 by the heading.
Data slots
Every part below is addressable from a parent, so you can restyle internals without forking the component.
model-picker-triggermodel-picker-trigger-effortmodel-picker-contentmodel-picker-sectionmodel-picker-section-valuemodel-picker-listmodel-picker-searchmodel-picker-groupmodel-picker-group-headingmodel-picker-itemmodel-picker-badgemodel-picker-metamodel-picker-effortmodel-picker-effort-item