Model picker with unified overlay positioning and runtime integration.
A select component that lets users switch between AI models. Uses item-aligned positioning so the selected model overlays the trigger for a unified look. Integrates with assistant-ui's ModelContext system to automatically propagate the selected model to your backend.
Getting Started
Add model-selector
npx shadcn@latest add https://r.assistant-ui.com/model-selector.jsonMain Component
npm install @assistant-ui/react @radix-ui/react-select class-variance-authority"use client";import { memo, useState, useEffect, createContext, useContext, type ComponentPropsWithoutRef, type ReactNode,} from "react";import * as SelectPrimitive from "@radix-ui/react-select";import { type VariantProps } from "class-variance-authority";import { CheckIcon } from "lucide-react";import { useAssistantApi } from "@assistant-ui/react";import { cn } from "@/lib/utils";import { SelectRoot, SelectTrigger, SelectContent, SelectItem, selectTriggerVariants,} from "@/components/assistant-ui/select";export type ModelOption = { id: string; name: string; description?: string; icon?: ReactNode; disabled?: boolean;};type ModelSelectorContextValue = { models: ModelOption[];};const ModelSelectorContext = createContext<ModelSelectorContextValue | null>( null,);function useModelSelectorContext() { const ctx = useContext(ModelSelectorContext); if (!ctx) { throw new Error( "ModelSelector sub-components must be used within ModelSelector.Root", ); } return ctx;}export type ModelSelectorRootProps = { models: ModelOption[]; value?: string; onValueChange?: (value: string) => void; defaultValue?: string; children: ReactNode;};function ModelSelectorRoot({ models, value, onValueChange, defaultValue, children,}: ModelSelectorRootProps) { const selectProps: ComponentPropsWithoutRef<typeof SelectRoot> = {}; if (value !== undefined) selectProps.value = value; if (onValueChange) selectProps.onValueChange = onValueChange; const resolvedDefault = defaultValue ?? models[0]?.id; if (resolvedDefault) selectProps.defaultValue = resolvedDefault; return ( <ModelSelectorContext.Provider value={{ models }}> <SelectRoot {...selectProps}>{children}</SelectRoot> </ModelSelectorContext.Provider> );}export type ModelSelectorTriggerProps = ComponentPropsWithoutRef< typeof SelectTrigger>;function ModelSelectorTrigger({ className, variant, size, children, ...props}: ModelSelectorTriggerProps) { return ( <SelectTrigger data-slot="model-selector-trigger" variant={variant} size={size} className={cn("aui-model-selector-trigger", className)} {...props} > {children ?? <SelectPrimitive.Value />} </SelectTrigger> );}export type ModelSelectorContentProps = ComponentPropsWithoutRef< typeof SelectContent>;function ModelSelectorContent({ className, children, ...props}: ModelSelectorContentProps) { const { models } = useModelSelectorContext(); return ( <SelectContent data-slot="model-selector-content" className={cn("min-w-[180px]", className)} {...props} > {children ?? models.map((model) => ( <ModelSelectorItem key={model.id} model={model} {...(model.disabled ? { disabled: true } : undefined)} /> ))} </SelectContent> );}export type ModelSelectorItemProps = Omit< ComponentPropsWithoutRef<typeof SelectItem>, "value" | "children"> & { model: ModelOption;};function ModelSelectorItem({ model, className, ...props}: ModelSelectorItemProps) { return ( <SelectPrimitive.Item data-slot="model-selector-item" value={model.id} textValue={model.name} className={cn( "relative flex w-full cursor-default select-none items-center gap-2 rounded-lg py-2 pr-9 pl-3 text-sm outline-none", "focus:bg-accent focus:text-accent-foreground", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className, )} {...props} > <span className="absolute right-3 flex size-4 items-center justify-center"> <SelectPrimitive.ItemIndicator> <CheckIcon className="size-4" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText> <span className="flex items-center gap-2"> {model.icon && ( <span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4"> {model.icon} </span> )} <span className="truncate font-medium">{model.name}</span> </span> </SelectPrimitive.ItemText> {model.description && ( <span className="truncate text-muted-foreground text-xs"> {model.description} </span> )} </SelectPrimitive.Item> );}export type ModelSelectorProps = Omit<ModelSelectorRootProps, "children"> & VariantProps<typeof selectTriggerVariants> & { contentClassName?: string; };const ModelSelectorImpl = ({ models, value: controlledValue, onValueChange: controlledOnValueChange, defaultValue, variant, size, contentClassName,}: ModelSelectorProps) => { const [internalValue, setInternalValue] = useState( () => controlledValue ?? defaultValue ?? models[0]?.id ?? "", ); const value = controlledValue ?? internalValue; const onValueChange = controlledOnValueChange ?? setInternalValue; const api = useAssistantApi(); useEffect(() => { const config = { config: { modelName: value } }; return api.modelContext().register({ getModelContext: () => config, }); }, [api, value]); return ( <ModelSelectorRoot models={models} value={value} onValueChange={onValueChange} > <ModelSelectorTrigger variant={variant} size={size} /> <ModelSelectorContent className={contentClassName} /> </ModelSelectorRoot> );};type ModelSelectorComponent = typeof ModelSelectorImpl & { displayName?: string; Root: typeof ModelSelectorRoot; Trigger: typeof ModelSelectorTrigger; Content: typeof ModelSelectorContent; Item: typeof ModelSelectorItem;};const ModelSelector = memo( ModelSelectorImpl,) as unknown as ModelSelectorComponent;ModelSelector.displayName = "ModelSelector";ModelSelector.Root = ModelSelectorRoot;ModelSelector.Trigger = ModelSelectorTrigger;ModelSelector.Content = ModelSelectorContent;ModelSelector.Item = ModelSelectorItem;export { ModelSelector, ModelSelectorRoot, ModelSelectorTrigger, ModelSelectorContent, ModelSelectorItem,};Use in your application
Place the ModelSelector inside your thread component, typically in the composer area:
import { ModelSelector } from "@/components/assistant-ui/model-selector";
const ComposerAction: FC = () => {
return (
<div className="flex items-center gap-1">
<ModelSelector
models={[
{ id: "gpt-5-nano", name: "GPT-5 Nano", description: "Fast and efficient" },
{ id: "gpt-5-mini", name: "GPT-5 Mini", description: "Balanced performance" },
{ id: "gpt-5", name: "GPT-5", description: "Most capable" },
]}
defaultValue="gpt-5-mini"
size="sm"
/>
</div>
);
};Read the model in your API route
The selected model name is sent as config.modelName in the request body:
export async function POST(req: Request) {
const { messages, config } = await req.json();
const result = streamText({
model: openai(config?.modelName ?? "gpt-4o"),
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Variants
Use the variant prop to change the trigger's visual style.
<ModelSelector variant="outline" /> // Border (default)
<ModelSelector variant="ghost" /> // No background
<ModelSelector variant="muted" /> // Solid background| Variant | Description |
|---|---|
outline | Border with transparent background (default) |
ghost | No background, subtle hover |
muted | Solid secondary background |
Sizes
Use the size prop to control the trigger dimensions.
<ModelSelector size="sm" /> // Compact (h-8, text-xs)
<ModelSelector size="default" /> // Standard (h-9)
<ModelSelector size="lg" /> // Large (h-10)Model Options
Each model in the models array supports:
const models = [
{
id: "gpt-5", // Sent to backend as config.modelName
name: "GPT-5", // Display name in trigger and items
description: "Most capable", // Optional subtitle in items only
icon: <SparklesIcon />, // Optional icon (any ReactNode)
},
];Runtime Integration
The default ModelSelector export automatically registers the selected model with assistant-ui's ModelContext system. When a user selects a model:
- The component calls
api.modelContext().register()withconfig.modelName - The
AssistantChatTransportincludesconfigin the request body - Your API route reads
config.modelNameto determine which model to use
This works out of the box with @assistant-ui/react-ai-sdk.
API Reference
Composable API
For custom layouts, use the sub-components directly with ModelSelector.Root:
import {
ModelSelectorRoot,
ModelSelectorTrigger,
ModelSelectorContent,
ModelSelectorItem,
} from "@/components/assistant-ui/model-selector";
<ModelSelectorRoot models={models} value={modelId} onValueChange={setModelId}>
<ModelSelectorTrigger variant="outline" />
<ModelSelectorContent />
</ModelSelectorRoot>| Component | Description |
|---|---|
ModelSelector | Default export with runtime integration |
ModelSelector.Root | Presentational root (no runtime, controlled state) |
ModelSelector.Trigger | CVA-styled trigger showing current model |
ModelSelector.Content | Select content with model items |
ModelSelector.Item | Individual model option with icon, name, description |
ModelSelector
ModelSelectorPropsmodelsrequired: ModelOption[]Array of available models to display.
defaultValue?: stringInitial model ID for uncontrolled usage.
value?: stringControlled selected model ID.
onValueChange?: (value: string) => voidCallback when selected model changes.
variant: "outline" | "ghost" | "muted"= "outline"Visual style of the trigger button.
size: "sm" | "default" | "lg"= "default"Size of the trigger button.
contentClassName?: stringAdditional class name for the dropdown content.
ModelOption
ModelOptionidrequired: stringUnique identifier sent to the backend as modelName.
namerequired: stringDisplay name shown in trigger and dropdown.
description?: stringOptional subtitle shown below the model name.
icon?: React.ReactNodeOptional icon displayed before the model name.