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.

Open in

Installation

npx shadcn@latest add miskibin/chat-components/model-picker

Also 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 searchThreshold the list grows a search field that matches name, badge, description, meta and the group label.
groupsModelPickerGroup[]
Section definitions; the array order is the section order. Options with an unknown or absent group render 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 placeholder rather 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. false drops the row entirely and the menu becomes the model list itself; pass DEFAULT_MODEL_EFFORTS or 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 ModelPickerGroup this 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 ContextMeter without a second lookup table beside it. Put a human-readable size in meta if 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