Inputs
Model Selector
An LLM model picker where every option carries its context window, per-million input/output rate, capability set and whether your key can actually call it — and those fields drive the filtering.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/model-selector.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ModelSelector" component (lucide-react
Braces, Brain, Check, ChevronsUpDown, Eye, Lock, MapPinOff, Search,
TriangleAlert, Wrench). No Radix, no popover library — the panel is drawn and
driven by the component itself, the same self-drawn pattern this registry's
Combobox uses.
Contract
- Export a forwardRef component whose ref points at the root <div>; props
extend HTMLAttributes<HTMLDivElement> minus onChange/defaultValue.
- models: ModelOption[] — required, no built-in catalogue. A ModelOption is
{ id, name, provider, contextWindow (tokens, not thousands),
inputPricePerMTok, outputPricePerMTok (USD per 1,000,000 tokens),
capabilities: ("vision" | "tools" | "structured" | "reasoning")[],
availability? }.
- availability is a discriminated union, not a boolean plus a note:
{ status: "available" }
| { status: "deprecated"; reason; replacementId: string | null; sunsetOn? }
| { status: "no-access"; reason }
| { status: "region-locked"; reason }
Every non-available branch REQUIRES `reason`, so a model cannot be marked
unusable without saying why. `replacementId` is `string | null` rather than
optional, so retiring a model forces an explicit answer to "use what
instead?" — `null` is that answer spelled out.
- Controlled `value` (model id) or uncontrolled `defaultValue`;
onValueChange(modelId) fires on every commit.
- defaultCapabilityFilters?: capability[] and defaultSort?: "provider" |
"price" | "context" seed the panel's own state.
- placeholder?, searchPlaceholder?, disabled?, className.
- Export the two formatters (formatPricePerMTok, formatContextWindow) — they
are the part callers will want to reuse in invoices and usage tables.
Behavior
- Trigger is a real <button role="combobox"> with aria-expanded,
aria-haspopup="listbox" and aria-controls while a non-empty list exists. It
shows the selected model's name, provider, context window and BOTH rates.
The name column is min-w-0 + truncate and the rate column is shrink-0, so a
55-character model name eats its own ellipsis instead of pushing the price
out of the control.
- A value that is not in `models` still renders as its own row ("Not in the
model list") instead of silently falling back to the placeholder — a stale
id must not read as "nothing selected".
- Panel: search field, a row of capability facet chips, a sort row with a
live result count, then the option list.
- Facets are AND-combined toggle buttons (aria-pressed). The number on each
chip is the result count you get by pressing it — computed against the
current query and the OTHER active chips — so a chip can never promise a
count the list then contradicts.
- Sort: "provider" groups rows under role="presentation" headings in
first-appearance order; "price" sorts by input rate then output rate;
"context" sorts by window descending. Non-finite or negative rates sort
last instead of pretending to be free.
- Unavailable models are never filtered out. They stay listed with a status
badge and their reason in full contrast (not dimmed — the reason is the
most useful text on an unusable row), are reachable with the arrow keys so
screen readers can read that reason, carry aria-disabled, and simply do not
commit on Enter/click. Deprecated models stay selectable (they still
answer) and name their successor inline.
- Whenever the selected model is not plainly available, a notice sits under
the trigger with the panel closed, carrying the reason, the sunset date and
a real button that commits the replacement.
- Keyboard on the search field: ArrowUp/ArrowDown move the highlight,
Home/End jump to the ends, typing filters, Enter commits the highlighted
row if it is selectable, Escape closes and refocuses the trigger.
- Escape restores the previous selection because browsing never writes to
`value` in the first place: the highlight is panel-local state, so closing
discards the query and the highlight and nothing else. Never clear the
value on Escape.
- The highlighted index is derived, not synchronized: when the stored index
stops pointing at a real row (the list shrank), render falls back to the
selected row, or to the first row once a query is present.
- Capability filters survive a close/reopen (and say so in a line under the
trigger); the search query does not.
- Two different empty states: "No models configured." for an empty catalogue,
and a filtered-empty state that names the active facets and the query and
offers a Clear filters button.
- Panel flips above the trigger when it will not fit below: a
requestAnimationFrame scheduled from an effect keyed on `open` measures the
already-mounted panel against the nearest clipping ancestor, then caps
max-height to the room that actually exists so the list scrolls instead of
being clipped.
- Click-outside closes via a document pointerdown listener that only exists
while open.
Numbers — the part a buyer does arithmetic with
- Rates span three orders of magnitude ($0.075/M to $75/M), so fixed decimals
are wrong at one end or the other: toFixed(2) turns $0.075 into "$0.07"
(6.7% understated) and writes "$75.00" for a whole-dollar rate. Format
instead as: 0 -> "$0"; under $1 -> three significant digits, then padded to
the two decimals money is written with ("$0.075", "$0.60", "$0.02" — note
minimumSignificantDigits: 2 would print "$0.020" and claim a digit nobody
supplied); whole dollars -> no decimals ("$75"); other -> two to three
decimals ("$4.50", "$2.375"). Non-finite or negative -> an em dash, never
"$0".
- Context windows: divide by 1000 (never 1024 — mixing bases is how 131,072
and 128,000 both end up printed as "128K") and keep three significant
digits: 8,192 -> "8.19K", 128,000 -> "128K", 131,072 -> "131K", 200,000 ->
"200K", 256,000 -> "256K", 1,000,000 -> "1M", 1,048,576 -> "1.05M". Two
significant digits would collapse 128,000/131,072 and 256,000/262,144 into
one label each; three keeps every real window distinct, with error under
0.5%. The exact count goes in the row's accessible name and in a title.
Rendering & styling
- Semantic tokens only: border-input + shadow-xs for the field,
bg-popover/text-popover-foreground for the panel,
bg-accent/text-accent-foreground for the highlighted row,
bg-primary/text-primary-foreground for a pressed facet chip,
bg-secondary for the active sort button, bg-muted for capability chips,
text-muted-foreground for meta text, border-destructive/40 +
text-destructive for the deprecation badge and notice. Hover uses
bg-primary/10, because in the light theme accent, secondary and muted are
the same value and hover:bg-accent over bg-muted is a no-op.
- role="option" has presentational children, so each row gets one aria-label
built from a describeModel() sentence (name, provider, exact token count,
both rates spelled out, capabilities, status + reason) and no interactive
control ever lives inside a row — the "use the replacement" button lives in
the notice under the trigger instead.
- The status badge is worded in the list and icon-only in the trigger: at
375px the worded badge left "Halcyon Base 1" 73 of the 98px it needs, and
the icon costs 26px instead of 97px. In the list the badge sits on the
capability line rather than the name line for the same reason.
- Rates use tabular-nums so columns line up down the list.
- Only transition-colors, each paired with motion-reduce:transition-none;
nothing loops, so there is no further reduced-motion branching.
- cn() merges the consumer className onto the root.
Customization levers
- Capability set: CAPABILITY_ORDER + CAPABILITY_META are one table — add
"audio" or "caching" there and it appears as a facet chip, a row badge and
a filter, with counts, for free.
- Selectability policy: STATUS_META decides which statuses commit. Flip
deprecated.selectable to false to hard-block retired models, or add a
status ("preview", "over-quota") with its own icon and rule.
- Price comparator: sort "price" reads input rate first. Swap in a blended
rate (e.g. input * 0.75 + output * 0.25) if your traffic is output-heavy —
it is a single comparator.
- Formatters: formatPricePerMTok / formatContextWindow are exported pure
functions. Change the significant-digit budget, add a per-1K unit, or
switch currency by editing the Intl options in one place.
- Density: rows are two lines plus an optional reason line. Drop the
capability line for a compact variant, or add a fourth line (rate limits,
latency) without touching filtering.
- Panel behaviour: MIN_PANEL_HEIGHT and the clipping walk control how the
panel survives short containers; portal it to document.body instead if your
triggers live inside overflow:hidden cards.Concepts
- Metadata as the filter, not as decoration — the context window, the two rates and the capability set are not badges printed next to a text list; they are what the facet chips, the sort modes and the accessible name are all computed from. That is the whole difference from a Combobox holding the same twelve labels.
- A facet count is a promise — each chip shows the number of rows you will have after pressing it, evaluated against the live query and the other active chips, so the count and the list can never disagree.
- Listed, blocked, and explained — a model your key cannot call stays in the list at full contrast with its reason, because "where did that model go?" costs more support time than a greyed row costs screen space. Dimming the row would be the one place where fading the text also fades the explanation.
- Successor handoff — a deprecation is only actionable if it names what to use instead, so
replacementIdis required (nullis a valid, explicit answer) and a real button under the trigger commits the successor in one click. - Significant digits over fixed decimals —
toFixed(2)understates $0.075 by 6.7% and writes "$75.00"; rounding a context window to two significant digits prints "130K" for both 128,000 and 131,072. Three significant digits keeps every real value distinct while staying readable. - Browsing is not committing — the highlight lives in the panel, never in
value, so Escape has nothing to undo: it drops the query and the highlight and leaves the previous model selected instead of clearing it.
Variant Selector
Multi-dimension product options resolved against a sparse SKU matrix — impossible combinations stay in place struck through, availability converges from either direction, and every dead end names the way out.
Date Picker
A typeable single-date field with a calendar panel — Intl-driven formatting and parsing, a complete keyboard grid, and refusals that keep what you typed.