Elements

Elements · AUI connected · AUI

Model selector

A searchable runtime model picker with grouped providers and reasoning effort controls.

Outline (default)
Ghost
Muted
With search, provider filters + groups
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/model-selector"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Model selector is a popover that lists AI models and, for models that support it, a reasoning effort level: Low, Medium, High, or a custom set. With a runtime the selection registers with assistant-ui's ModelContext system automatically, so it reaches your backend on every request; standalone you hold the value and effort yourself. It comes in two designs: the runtime variant renders a popover of models with reasoning-effort controls, and the static variant, ModelPicker, renders a full-page list grouped by family with pricing and capability chips (see The model-picker design).

Getting started

Add the picker

Each model needs an id and a display name; everything else is optional.

components/assistant-ui/elements/thread.aui.tsx
import { ModelSelector } from "@/components/assistant-ui/elements/model-selector.aui";

function ComposerAction() {
  return (
    <ModelSelector
      models={[
        { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", description: "Fast and efficient" },
        { id: "gpt-5.6-terra", name: "GPT-5.6 Terra", description: "Balanced performance" },
        { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", description: "Most capable", efforts: true },
      ]}
      defaultValue="gpt-5.6-luna"
      defaultEffort="medium"
      size="sm"
    />
  );
}

Read the selection in your API route

The selected model's id arrives as config.modelName, and its effort level, when the model supports one, as config.reasoningEffort.

app/api/chat/route.ts
export async function POST(req: Request) {
  const { messages, config } = await req.json();

  const result = streamText({
    model: openai(config?.modelName ?? "gpt-5.6-luna"),
    providerOptions: {
      openai:
        config?.reasoningEffort !== undefined
          ? { reasoningEffort: config.reasoningEffort }
          : {},
    },
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

Anatomy

<button role="combobox" aria-haspopup="listbox" data-slot="model-selector-trigger" />

<div data-slot="model-selector-content"> {/* a Command (cmdk) list */}
  <input data-slot="model-selector-search" /> {/* only when searchable */}
  <div data-slot="model-selector-list">
    <div data-slot="model-selector-empty" /> {/* only when search has no matches */}
    <div data-slot="model-selector-group">
      <div data-slot="model-selector-item" /> {/* one per model */}
    </div>
  </div>
  <div data-slot="model-selector-effort" /> {/* only when the selected model declares efforts */}
</div>

The Thinking row (Effort) mounts only while the selected model has efforts set; switching to a model without it hides the row without discarding your effort choice; switching back to a model that supports it restores whatever level was selected before, as long as that model also offers it. Content needs a focused input to drive the keyboard: with no custom children (or searchable={false}), it renders a visually hidden FocusAnchor automatically; a custom layout that omits both Search and searchable={false} has no anchor and needs one added by hand to stay keyboard-operable.

Examples

Reasoning efforts

A model that declares efforts shows the Thinking row. efforts: true enables the default Low / Medium / High levels; a list of { id, name } objects defines a custom set instead, and omitting efforts hides the row entirely for that model.

{
  id: "gpt-5.6-sol",
  name: "GPT-5.6 Sol",
  efforts: [
    { id: "minimal", name: "Minimal" },
    { id: "high", name: "High" },
  ],
}

Custom effort UI

Effort's default layout is horizontal segments, which overflows once a model offers more than a few levels. useModelSelectorEfforts exposes the selected model's levels and the active selection for any other layout, from inside Content:

import { useModelSelectorEfforts } from "@/components/assistant-ui/elements/model-selector";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

function EffortDropdown() {
  const { efforts, effort, setEffort } = useModelSelectorEfforts();
  if (!efforts?.length) return null;

  return (
    <DropdownMenu>
      <DropdownMenuTrigger>{efforts.find((e) => e.id === effort)?.name ?? "Select"}</DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuRadioGroup value={effort} onValueChange={setEffort}>
          {efforts.map((option) => (
            <DropdownMenuRadioItem key={option.id} value={option.id}>
              {option.name}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Render it inside Content in place of Effort.

Provider logos

Each model's icon accepts any ReactNode and renders in both the trigger and the dropdown item. Model logos ships ready-made OpenAILogo, ClaudeLogo, and GeminiLogo marks for this.

{ id: "claude-opus-4.5", name: "Claude Opus 4.5", icon: <ClaudeLogo /> }

Search is opt-in: pass searchable to the connected ModelSelector, or to ModelSelectorContent directly when it renders its default children. Matching runs against each model's id, name, and keywords; add a provider name to keywords so typing it finds that provider's models.

<ModelSelector models={models} searchable />
{/* or, standalone: */}
<ModelSelectorContent searchable />

Variants and sizes

variant and size live on the trigger: pass them to the connected ModelSelector, or to ModelSelectorTrigger directly in a standalone layout.

<ModelSelector variant="ghost" size="lg" />
{/* or, standalone: */}
<ModelSelectorTrigger variant="ghost" size="lg" />
VariantDescription
outlineBorder, transparent background (default)
ghostNo background
mutedSolid secondary background
SizeDescription
smCompact, h-8
defaultStandard, h-9
lgLarger, h-10

API reference

ModelSelector

PropTypeDefaultDescription
modelsModelOption[]requiredModels to display.
value / defaultValuestringfirst modelControlled or initial selected model id.
onValueChange(value: string) => voidCalled when the selection changes.
effort / defaultEffortstringControlled or initial effort level id.
onEffortChange(effort: string) => voidCalled when the effort level changes.
searchablebooleanfalseRenders a search input above the model list.
variant"outline" | "ghost" | "muted""outline"Trigger style.
size"sm" | "default" | "lg""default"Trigger size.
align"start" | "center" | "end""start"Popover alignment relative to the trigger.
className / contentClassNamestringMerged onto the trigger / popover content.

How registration works

  1. ModelSelector calls aui.modelContext.register() with config.modelName set to the selected id, plus config.reasoningEffort whenever the selected model supports the chosen level.
  2. AssistantChatTransport (from @assistant-ui/ai-sdk) includes config in the body of every chat request.
  3. Your API route reads config.modelName and config.reasoningEffort.

ModelSelector.Root (the same ModelSelectorRoot documented below) performs no registration on its own; it is purely presentational.

The model-picker design

The Static variant in the rail is a second design for the same picker: ModelPicker renders every model in a full-page list grouped by family, each row carrying its context window, price, and capability chips, with the active model checked, instead of a searchable popover. It is a single props-driven component with no runtime dependency:

npx shadcn@latest add "@assistant-ui/elements-model-picker"

With a runtime there is no catalog of available models to read: assistant-ui tracks the model configuration for the next call, not a directory of every model you could switch to. Unlike the popover, ModelPicker does no registration on its own, so register a provider that reports the selected id and seed the initial selection from whatever the model context already reports, for example a default set on the server.

components/assistant-ui/elements/model-picker.tsx
"use client";

import { useAui, useAuiState } from "@assistant-ui/react";
import { useEffect, useState } from "react";
import {
  ModelPicker,
  type PickableModel,
} from "@/components/assistant-ui/elements/model-picker";

export function ModelPickerPanel({
  models,
}: {
  models: readonly PickableModel[];
}) {
  const aui = useAui();
  const activeModelName = useAuiState((s) => s.modelContext.modelName);
  const [selectedId, setSelectedId] = useState(
    () => models.find((model) => model.id === activeModelName)?.id ?? models[0]!.id,
  );

  useEffect(() => {
    return aui.modelContext.register({
      getModelContext: () => ({ config: { modelName: selectedId } }),
    });
  }, [aui, selectedId]);

  return (
    <ModelPicker models={models} selectedId={selectedId} onSelect={setSelectedId} />
  );
}

Families are derived from models with [...new Set(models.map((m) => m.family))], so a family's position is set by the first model that names it, not alphabetically. Each row is a plain toggle button (aria-pressed, not a listbox option), so there is no arrow-key navigation built in; Tab moves between rows the same as any button list. The check mark's column keeps its width whether or not the row is selected, so rows never shift when the selection changes.

ModelPicker

PropTypeDefaultDescription
modelsPickableModel[]required{ id, name, family, context, price, capabilities }[]. Families keep first-seen order; context and price are pre-formatted strings (the element does no currency math), capabilities renders as chip labels.
selectedIdstringrequiredWhich model is active.
onSelect(id: string) => voidCalled when a model is picked.
classNamestringMerged onto the root.