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…

"use client"

import * as React from "react"
import {
  Braces,
  Brain,
  Check,
  ChevronsUpDown,
  Eye,
  Lock,
  MapPinOff,
  Search,
  TriangleAlert,
  Wrench,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/model-selector.json

Prompt

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 replacementId is required (null is a valid, explicit answer) and a real button under the trigger commits the successor in one click.
  • Significant digits over fixed decimalstoFixed(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.

On This Page