Composable model picker with reasoning effort levels, search, and runtime integration.
A picker that lets users switch between AI models and choose a reasoning effort (thinking) level. It is built on Popover + Command, so search, provider grouping, and filtering compose in without being built in. The default export integrates with assistant-ui's ModelContext system, so the selection reaches your backend on every request with no extra wiring.
Getting Started
Add model-selector
npx shadcn@latest add @assistant-ui/model-selectorThe @assistant-uinamespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/model-selector.jsonMain Component
npm install @assistant-ui/react @base-ui/react class-variance-authority"use client";import { memo, useCallback, useEffect, useMemo, useRef, useState, createContext, useContext, type ComponentPropsWithoutRef, type ReactNode,} from "react";import { cva, type VariantProps } from "class-variance-authority";import { CheckIcon, ChevronDownIcon } from "lucide-react";import { useAui } from "@assistant-ui/react";import { cn } from "@/lib/utils";import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover";import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator,} from "@/components/ui/command";import { RadioGroup } from "@base-ui/react/radio-group";import { Radio } from "@base-ui/react/radio";export type ModelSelectorEffortOption = { id: string; name: string;};export const DEFAULT_EFFORT_OPTIONS: readonly ModelSelectorEffortOption[] = [ { id: "low", name: "Low" }, { id: "medium", name: "Med" }, { id: "high", name: "High" },];export type ModelOption = { id: string; name: string; description?: string; icon?: ReactNode; disabled?: boolean; /** Extra terms matched by ModelSelector.Search, in addition to id and name. */ keywords?: readonly string[]; /** * Reasoning effort levels the model supports. Pass `true` for the default * low/medium/high levels, or a custom list. Omit for models without * configurable reasoning. */ efforts?: boolean | readonly ModelSelectorEffortOption[];};function getModelEfforts( model: ModelOption | undefined,): readonly ModelSelectorEffortOption[] | undefined { if (!model?.efforts) return undefined; return model.efforts === true ? DEFAULT_EFFORT_OPTIONS : model.efforts;}function resolveEffort( efforts: readonly ModelSelectorEffortOption[] | undefined, effort: string | undefined,): string | undefined { if (effort === undefined) return undefined; return efforts?.some((e) => e.id === effort) ? effort : undefined;}/** * Returns the effort id if the given model supports it, otherwise undefined. * Effort selection is kept sticky across model switches; this resolves what * actually applies to the current model. */export function resolveModelEffort( models: readonly ModelOption[], modelId: string | undefined, effort: string | undefined,): string | undefined { return resolveEffort( getModelEfforts(models.find((m) => m.id === modelId)), effort, );}function useControllableState<T>({ prop, defaultProp, onChange,}: { prop: T | undefined; defaultProp: T | undefined; onChange: ((next: T) => void) | undefined;}) { const [internal, setInternal] = useState(defaultProp); const isControlled = prop !== undefined; const value = isControlled ? prop : internal; // Read onChange through a ref so inline callbacks don't recreate the setter // (and with it the memoized context value) every render. const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }); const setValue = useCallback( (next: T) => { if (!isControlled) setInternal(next); onChangeRef.current?.(next); }, [isControlled], ); return [value, setValue] as const;}type ModelSelectorContextValue = { models: readonly ModelOption[]; value: string | undefined; setValue: (value: string) => void; /** The model matching `value`, derived once for all sub-components. */ selectedModel: ModelOption | undefined; /** The selected model's effort levels, undefined when not configurable. */ efforts: readonly ModelSelectorEffortOption[] | undefined; /** Effort resolved against the selected model's supported levels. */ effort: string | undefined; setEffort: (effort: string) => void; setOpen: (open: boolean) => void;};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;}/** * The selected model's effort levels and the active selection. Use it to build * a custom effort UI inside ModelSelector.Content (e.g. a slider or a shadcn * DropdownMenu) when the built-in ModelSelector.Effort layout doesn't fit. * `efforts` is undefined for models without configurable reasoning. */export function useModelSelectorEfforts(): { efforts: readonly ModelSelectorEffortOption[] | undefined; effort: string | undefined; setEffort: (effort: string) => void;} { const { efforts, effort, setEffort } = useModelSelectorContext(); return { efforts, effort, setEffort };}export type ModelSelectorRootProps = { models: readonly ModelOption[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; effort?: string; defaultEffort?: string; onEffortChange?: (effort: string) => void; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode;};function ModelSelectorRoot({ models, value: valueProp, defaultValue, onValueChange, effort: effortProp, defaultEffort, onEffortChange, open: openProp, defaultOpen, onOpenChange, children,}: ModelSelectorRootProps) { const [value, setValue] = useControllableState({ prop: valueProp, defaultProp: defaultValue ?? models[0]?.id, onChange: onValueChange, }); const [effort, setEffort] = useControllableState({ prop: effortProp, defaultProp: defaultEffort, onChange: onEffortChange, }); const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen ?? false, onChange: onOpenChange, }); const selectedModel = models.find((m) => m.id === value); const efforts = getModelEfforts(selectedModel); const activeEffort = resolveEffort(efforts, effort); const contextValue = useMemo( () => ({ models, value, setValue, selectedModel, efforts, effort: activeEffort, setEffort, setOpen, }), [ models, value, setValue, selectedModel, efforts, activeEffort, setEffort, setOpen, ], ); return ( <ModelSelectorContext.Provider value={contextValue}> <Popover open={open ?? false} onOpenChange={setOpen}> {children} </Popover> </ModelSelectorContext.Provider> );}export const modelSelectorTriggerVariants = cva( "focus-visible:ring-ring/50 flex w-fit items-center justify-between gap-2 overflow-hidden rounded-md text-sm whitespace-nowrap transition-colors outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5", { variants: { variant: { outline: "border-input hover:bg-accent hover:text-accent-foreground border bg-transparent", ghost: "hover:bg-accent hover:text-accent-foreground", muted: "bg-secondary text-secondary-foreground hover:bg-secondary/80", }, size: { default: "h-9 px-3 py-2", sm: "h-8 px-2.5 py-1.5 text-xs", lg: "h-10 px-4 py-2.5", }, }, defaultVariants: { variant: "outline", size: "default", }, },);export type ModelSelectorTriggerProps = ComponentPropsWithoutRef< typeof PopoverTrigger> & VariantProps<typeof modelSelectorTriggerVariants>;function ModelSelectorTrigger({ className, variant, size, children, onKeyDown, ...props}: ModelSelectorTriggerProps) { const { setOpen } = useModelSelectorContext(); return ( <PopoverTrigger data-slot="model-selector-trigger" data-variant={variant ?? "outline"} data-size={size ?? "default"} role="combobox" aria-haspopup="listbox" className={cn(modelSelectorTriggerVariants({ variant, size }), className)} onKeyDown={(e) => { onKeyDown?.(e); if (e.defaultPrevented) return; // ARIA combobox: arrows open the listbox from a focused trigger. // Popover leaves this to the consumer. if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setOpen(true); } }} {...props} > {children ?? <ModelSelectorValue />} <ChevronDownIcon className="size-4 opacity-50" /> </PopoverTrigger> );}export type ModelSelectorValueProps = { placeholder?: ReactNode; /** Show the active effort level next to the model name. */ showEffort?: boolean; className?: string;};function ModelIcon({ children, className,}: { children: ReactNode; className?: string;}) { return ( <span className={cn( "flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5", className, )} > {children} </span> );}function ModelSelectorValue({ placeholder = "Select model", showEffort = true, className,}: ModelSelectorValueProps) { const { selectedModel, efforts, effort } = useModelSelectorContext(); if (!selectedModel) { return ( <span data-slot="model-selector-value" className={cn("text-muted-foreground", className)} > {placeholder} </span> ); } const effortName = showEffort && effort !== undefined ? efforts?.find((e) => e.id === effort)?.name : undefined; return ( <span data-slot="model-selector-value" className={cn("flex min-w-0 items-center gap-2", className)} > {selectedModel.icon && <ModelIcon>{selectedModel.icon}</ModelIcon>} <span className="truncate font-medium">{selectedModel.name}</span> {effortName && ( <span className="text-muted-foreground min-w-7.5 truncate text-center"> {effortName} </span> )} </span> );}export type ModelSelectorContentProps = Omit< ComponentPropsWithoutRef<typeof PopoverContent>, "side"> & { /** * Preferred side for the initial placement. Once the popover is open, the * rendered side takes over until it closes, so the popup does not jump * between sides while filtering resizes the list. */ side?: ComponentPropsWithoutRef<typeof PopoverContent>["side"]; searchable?: boolean;};// Base UI's Popover re-evaluates collision flipping whenever the popup// resizes, so filtering the list down flips the popup back to the preferred// side mid-interaction. Base UI only exposes its lazy-flip behavior on the// Combobox positioner, so mirror it here: feed the rendered side back as the// preferred side, making the popup keep its side until it no longer fits.function useLazyFlipSide(): { side: ModelSelectorContentProps["side"]; popupRef: (node: HTMLDivElement | null) => void;} { const [side, setSide] = useState<ModelSelectorContentProps["side"]>(); const observerRef = useRef<MutationObserver | null>(null); const popupRef = useCallback((node: HTMLDivElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; if (!node) { setSide(undefined); return; } const sync = () => { const rendered = node.getAttribute("data-side"); if (rendered) setSide(rendered as ModelSelectorContentProps["side"]); }; sync(); const observer = new MutationObserver(sync); observer.observe(node, { attributes: true, attributeFilter: ["data-side"], }); observerRef.current = observer; }, []); return { side, popupRef };}/** * Hidden input that anchors cmdk's keyboard navigation, keeping the list * keyboard-operable without a visible search box. ModelSelectorContent renders * one automatically when unfiltered. */function ModelSelectorFocusAnchor() { return ( <div className="sr-only"> <CommandInput readOnly aria-label="Model" /> </div> );}function ModelSelectorContent({ className, align = "start", side, sideOffset = 6, searchable, children, ...props}: ModelSelectorContentProps) { const { value } = useModelSelectorContext(); const { side: renderedSide, popupRef } = useLazyFlipSide(); const unfiltered = searchable === false || (!searchable && children === undefined); return ( <PopoverContent ref={popupRef} data-slot="model-selector-content" align={align} side={renderedSide ?? side ?? "bottom"} sideOffset={sideOffset} className={cn( "bg-popover/95 w-72 min-w-(--anchor-width) overflow-hidden rounded-xl p-0 shadow-lg backdrop-blur-sm", className, )} {...props} > <Command className="bg-transparent" shouldFilter={!unfiltered} {...(value !== undefined ? { defaultValue: value } : {})} > {unfiltered && <ModelSelectorFocusAnchor />} {children ?? ( <> {searchable && <ModelSelectorSearch />} <ModelSelectorList /> <ModelSelectorEffort /> </> )} </Command> </PopoverContent> );}export type ModelSelectorSearchProps = ComponentPropsWithoutRef< typeof CommandInput>;function ModelSelectorSearch({ placeholder = "Search models...", ...props}: ModelSelectorSearchProps) { return ( <CommandInput data-slot="model-selector-search" placeholder={placeholder} {...props} /> );}export type ModelSelectorListProps = ComponentPropsWithoutRef< typeof CommandList>;function ModelSelectorList({ className, children, ...props}: ModelSelectorListProps) { const { models } = useModelSelectorContext(); return ( <CommandList data-slot="model-selector-list" className={cn( "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", className, )} {...props} > {children ?? ( <> <ModelSelectorEmpty /> <CommandGroup> {models.map((model) => ( <ModelSelectorItem key={model.id} model={model} /> ))} </CommandGroup> </> )} </CommandList> );}export type ModelSelectorEmptyProps = ComponentPropsWithoutRef< typeof CommandEmpty>;function ModelSelectorEmpty({ children, ...props }: ModelSelectorEmptyProps) { return ( <CommandEmpty data-slot="model-selector-empty" {...props}> {children ?? "No models found."} </CommandEmpty> );}export type ModelSelectorGroupProps = ComponentPropsWithoutRef< typeof CommandGroup>;function ModelSelectorGroup(props: ModelSelectorGroupProps) { return <CommandGroup data-slot="model-selector-group" {...props} />;}export type ModelSelectorSeparatorProps = ComponentPropsWithoutRef< typeof CommandSeparator>;function ModelSelectorSeparator(props: ModelSelectorSeparatorProps) { return <CommandSeparator data-slot="model-selector-separator" {...props} />;}export type ModelSelectorItemProps = Omit< ComponentPropsWithoutRef<typeof CommandItem>, "value"> & { model: ModelOption;};function ModelSelectorItem({ model, className, children, onSelect, ...props}: ModelSelectorItemProps) { const { value, setValue, setOpen } = useModelSelectorContext(); const isSelected = value === model.id; return ( <CommandItem data-slot="model-selector-item" value={model.id} keywords={[model.name, ...(model.keywords ?? [])]} {...(model.disabled ? { disabled: true } : undefined)} onSelect={(selectedValue) => { setValue(model.id); setOpen(false); onSelect?.(selectedValue); }} className={cn( "relative items-start gap-2 rounded-lg py-2 ps-3 pe-9 [&_svg:not([class*='size-'])]:size-3.5", className, )} {...props} > {children ?? ( <> {model.icon && ( <ModelIcon className="mt-[3px]">{model.icon}</ModelIcon> )} <span className="flex min-w-0 flex-col"> <span className="truncate font-medium">{model.name}</span> {model.description && ( <span className="text-muted-foreground truncate text-xs"> {model.description} </span> )} </span> </> )} {isSelected && ( <span className="absolute end-3 top-2.5 flex size-4 items-center justify-center"> <CheckIcon className="size-4" /> </span> )} </CommandItem> );}export type ModelSelectorEffortProps = ComponentPropsWithoutRef<"div"> & { label?: ReactNode;};function ModelSelectorEffort({ label = "Thinking", className, onKeyDown, onKeyDownCapture, ...props}: ModelSelectorEffortProps) { const { efforts, effort, setEffort } = useModelSelectorEfforts(); if (!efforts?.length) return null; return ( <div data-slot="model-selector-effort" className={cn( "flex cursor-default items-center justify-between gap-3 border-t px-3 py-2", className, )} onKeyDownCapture={(e) => { onKeyDownCapture?.(e); if (e.defaultPrevented) return; if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; // Base UI's RadioGroup composite claims vertical arrows for roving // focus (orientation "both", not configurable), so intercept them in // capture and hand the keypress to cmdk: the model list owns vertical // navigation, and cmdk's Enter is inert while a radio has focus. onKeyDown?.(e); if (e.defaultPrevented) return; const input = e.currentTarget .closest("[cmdk-root]") ?.querySelector<HTMLInputElement>("[cmdk-input]"); if (!input) return; e.preventDefault(); e.stopPropagation(); input.focus(); input.dispatchEvent(new KeyboardEvent("keydown", e.nativeEvent)); }} onKeyDown={(e) => { if (e.key === "ArrowUp" || e.key === "ArrowDown") return; onKeyDown?.(e); if (e.defaultPrevented) return; // Base UI's radio composite ignores Home/End and cmdk's Command // root would claim them to jump the model list; move radio focus // here so only the radiogroup reacts. if (e.key === "Home" || e.key === "End") { e.preventDefault(); e.stopPropagation(); const radios = Array.from( e.currentTarget.querySelectorAll<HTMLElement>( '[role="radio"]:not([data-disabled])', ), ); (e.key === "Home" ? radios[0] : radios[radios.length - 1])?.focus(); } }} {...props} > <span className="text-muted-foreground text-xs">{label}</span> <RadioGroup value={effort ?? ""} onValueChange={setEffort} aria-label={typeof label === "string" ? label : "Reasoning effort"} className="flex items-center gap-0.5" > {efforts.map((option) => ( <Radio.Root key={option.id} value={option.id} className={cn( "focus-visible:ring-ring/50 text-muted-foreground hover:text-foreground rounded-md px-2 py-1 text-xs transition-colors outline-none focus-visible:ring-2", "data-checked:bg-accent data-checked:text-accent-foreground data-checked:font-medium", )} > {option.name} </Radio.Root> ))} </RadioGroup> </div> );}export type ModelSelectorProps = Omit<ModelSelectorRootProps, "children"> & VariantProps<typeof modelSelectorTriggerVariants> & { /** Render a search input above the model list. */ searchable?: boolean; /** Alignment of the dropdown relative to the trigger. Use `"end"` when the * trigger sits at the right edge of its container. */ align?: ModelSelectorContentProps["align"]; className?: string; contentClassName?: string; };/** Registers the selection with assistant-ui's ModelContext system. The * context's effort is already resolved against the selected model. */function ModelSelectorModelContext() { const { value, effort } = useModelSelectorContext(); const api = useAui(); useEffect(() => { if (value === undefined) return; const config = { config: { modelName: value, ...(effort !== undefined ? { reasoningEffort: effort } : undefined), }, }; return api.modelContext.register({ getModelContext: () => config, }); }, [api, value, effort]); return null;}const ModelSelectorImpl = ({ searchable, variant, size, align, className, contentClassName, ...rootProps}: ModelSelectorProps) => { return ( <ModelSelectorRoot {...rootProps}> <ModelSelectorModelContext /> <ModelSelectorTrigger variant={variant} size={size} className={className} /> <ModelSelectorContent {...(align !== undefined ? { align } : {})} className={contentClassName} searchable={searchable ?? false} /> </ModelSelectorRoot> );};type ModelSelectorComponent = typeof ModelSelectorImpl & { displayName?: string; Root: typeof ModelSelectorRoot; Trigger: typeof ModelSelectorTrigger; Value: typeof ModelSelectorValue; Content: typeof ModelSelectorContent; Search: typeof ModelSelectorSearch; FocusAnchor: typeof ModelSelectorFocusAnchor; List: typeof ModelSelectorList; Empty: typeof ModelSelectorEmpty; Group: typeof ModelSelectorGroup; Separator: typeof ModelSelectorSeparator; Item: typeof ModelSelectorItem; Effort: typeof ModelSelectorEffort;};const ModelSelector = memo( ModelSelectorImpl,) as unknown as ModelSelectorComponent;ModelSelector.displayName = "ModelSelector";ModelSelector.Root = ModelSelectorRoot;ModelSelector.Trigger = ModelSelectorTrigger;ModelSelector.Value = ModelSelectorValue;ModelSelector.Content = ModelSelectorContent;ModelSelector.Search = ModelSelectorSearch;ModelSelector.FocusAnchor = ModelSelectorFocusAnchor;ModelSelector.List = ModelSelectorList;ModelSelector.Empty = ModelSelectorEmpty;ModelSelector.Group = ModelSelectorGroup;ModelSelector.Separator = ModelSelectorSeparator;ModelSelector.Item = ModelSelectorItem;ModelSelector.Effort = ModelSelectorEffort;export { ModelSelector, ModelSelectorRoot, ModelSelectorTrigger, ModelSelectorValue, ModelSelectorContent, ModelSelectorSearch, ModelSelectorFocusAnchor, ModelSelectorList, ModelSelectorEmpty, ModelSelectorGroup, ModelSelectorSeparator, ModelSelectorItem, ModelSelectorEffort,};shadcn/ui dependencies
npm install @base-ui/react"use client";import type * as React from "react";import { Command as CommandPrimitive } from "cmdk";import { SearchIcon } from "lucide-react";import { cn } from "@/lib/utils";import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@/components/ui/dialog";function Command({ className, ...props}: React.ComponentProps<typeof CommandPrimitive>) { return ( <CommandPrimitive data-slot="command" className={cn( "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", className, )} {...props} /> );}function CommandDialog({ title = "Command Palette", description = "Search for a command to run...", children, className, showCloseButton = true, ...props}: React.ComponentProps<typeof Dialog> & { title?: string; description?: string; className?: string; showCloseButton?: boolean; children?: React.ReactNode;}) { return ( <Dialog {...props}> <DialogHeader className="sr-only"> <DialogTitle>{title}</DialogTitle> <DialogDescription>{description}</DialogDescription> </DialogHeader> <DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton} > <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> {children} </Command> </DialogContent> </Dialog> );}function CommandInput({ className, ...props}: React.ComponentProps<typeof CommandPrimitive.Input>) { return ( <div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3" > <SearchIcon className="size-4 shrink-0 opacity-50" /> <CommandPrimitive.Input data-slot="command-input" className={cn( "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className, )} {...props} /> </div> );}function CommandList({ className, ...props}: React.ComponentProps<typeof CommandPrimitive.List>) { return ( <CommandPrimitive.List data-slot="command-list" className={cn( "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className, )} {...props} /> );}function CommandEmpty({ ...props}: React.ComponentProps<typeof CommandPrimitive.Empty>) { return ( <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} /> );}function CommandGroup({ className, ...props}: React.ComponentProps<typeof CommandPrimitive.Group>) { return ( <CommandPrimitive.Group data-slot="command-group" className={cn( "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", className, )} {...props} /> );}function CommandSeparator({ className, ...props}: React.ComponentProps<typeof CommandPrimitive.Separator>) { return ( <CommandPrimitive.Separator data-slot="command-separator" className={cn("bg-border -mx-1 h-px", className)} {...props} /> );}function CommandItem({ className, ...props}: React.ComponentProps<typeof CommandPrimitive.Item>) { return ( <CommandPrimitive.Item data-slot="command-item" className={cn( "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} /> );}function CommandShortcut({ className, ...props}: React.ComponentProps<"span">) { return ( <span data-slot="command-shortcut" className={cn( "text-muted-foreground ms-auto text-xs tracking-widest", className, )} {...props} /> );}export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator,};"use client";import * as React from "react";import { Popover as PopoverPrimitive } from "@base-ui/react/popover";import { cn } from "@/lib/utils";function Popover({ ...props }: PopoverPrimitive.Root.Props) { return <PopoverPrimitive.Root data-slot="popover" {...props} />;}function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;}const PopoverContent = React.forwardRef< HTMLDivElement, PopoverPrimitive.Popup.Props & Pick< PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >>(function PopoverContent( { className, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, ...props }, ref,) { return ( <PopoverPrimitive.Portal> <PopoverPrimitive.Positioner align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset} className="isolate z-50" > <PopoverPrimitive.Popup ref={ref} data-slot="popover-content" className={cn( "bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 outline-hidden duration-100", className, )} {...props} /> </PopoverPrimitive.Positioner> </PopoverPrimitive.Portal> );});function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="popover-header" className={cn("flex flex-col gap-0.5 text-sm", className)} {...props} /> );}function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { return ( <PopoverPrimitive.Title data-slot="popover-title" className={cn("font-medium", className)} {...props} /> );}function PopoverDescription({ className, ...props}: PopoverPrimitive.Description.Props) { return ( <PopoverPrimitive.Description data-slot="popover-description" className={cn("text-muted-foreground", className)} {...props} /> );}export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger,};Use in your application
Place the ModelSelector inside your thread component, typically in the composer area. Each model needs an id and a display name; everything else is optional:
import { ModelSelector } from "@/components/assistant-ui/model-selector";
const ComposerAction: FC = () => {
return (
<div className="flex items-center gap-1">
<ModelSelector
models={[
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", description: "Fast and efficient" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", description: "Balanced performance" },
{ id: "gpt-5.5", name: "GPT-5.5", description: "Most capable", efforts: true },
]}
defaultValue="gpt-5.4-nano"
defaultEffort="medium"
size="sm"
/>
</div>
);
};Read the selection in your API route
The selected model's id arrives as config.modelName, and the effort level as config.reasoningEffort:
export async function POST(req: Request) {
const { messages, config } = await req.json();
const result = streamText({
model: openai(config?.modelName ?? "gpt-5.4-nano"),
providerOptions: {
openai:
config?.reasoningEffort !== undefined
? { reasoningEffort: config.reasoningEffort }
: {},
},
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}config.reasoningEffort is only present when the selected model supports the chosen level, so the route only forwards it when it exists. See How It Works.
Reasoning Efforts
A model that declares efforts shows a "Thinking" row at the bottom of the popover. efforts: true enables the default Low / Medium / High levels; pass a list of { id, name } objects to define your own:
{
id: "gpt-5.5",
name: "GPT-5.5",
efforts: [
{ id: "minimal", name: "Minimal" },
{ id: "high", name: "High" },
],
}Omit efforts for models without configurable reasoning. The row is hidden while such a model is selected.
Sticky Selection
The effort selection survives model switches. Switching to a model that doesn't support the current level omits reasoningEffort from the request instead of resetting the user's choice, and the level applies again when the user switches back. The exported resolveModelEffort helper applies the same rule if you build your own runtime integration around ModelSelector.Root; see resolveModelEffort.
Custom Effort UI
ModelSelector.Effort lays the levels out as horizontal segments, which overflows the popover width once a model has more than a few. For those cases, or for a different layout such as a slider or a sub-dropdown, build your own control with the useModelSelectorEfforts hook. It exposes the selected model's levels and the active selection:
import { useModelSelectorEfforts } from "@/components/assistant-ui/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 (
<div className="flex items-center justify-between gap-3 border-t px-3 py-2">
<span className="text-muted-foreground text-xs">Thinking</span>
<DropdownMenu>
<DropdownMenuTrigger className="text-xs">
{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>
</div>
);
}Render it inside ModelSelector.Content in place of ModelSelector.Effort. The same hook supports any shape that reads the levels and writes the selection.
Provider Logos
Each model's icon accepts any ReactNode and renders in the trigger and the dropdown items. The optional logos registry item ships OpenAILogo, ClaudeLogo, and GeminiLogo marks to plug in:
npx shadcn@latest add @assistant-ui/logosThe @assistant-uinamespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/logos.jsonMain Component
"use client";import { useId, type ComponentProps } from "react";export type LogoProps = ComponentProps<"svg">;function ClaudeLogo(props: LogoProps) { return ( <svg viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" {...props} > <path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" /> </svg> );}function OpenAILogo(props: LogoProps) { return ( <svg viewBox="0 0 256 260" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" {...props} > <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" /> </svg> );}function GeminiLogo(props: LogoProps) { const id = useId(); return ( <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill={`url(#${id})`} aria-hidden="true" {...props} > <defs> <linearGradient id={id} x1="2" y1="12" x2="22" y2="12" gradientUnits="userSpaceOnUse" > <stop stopColor="#4285F4" /> <stop offset="0.5" stopColor="#9B72CB" /> <stop offset="1" stopColor="#D96570" /> </linearGradient> </defs> <path d="M12 1c.53 5.94 4.06 9.47 10 10-5.94.53-9.47 4.06-10 10-.53-5.94-4.06-9.47-10-10C7.94 10.47 11.47 6.94 12 1Z" /> </svg> );}export { ClaudeLogo, OpenAILogo, GeminiLogo };import { ClaudeLogo, GeminiLogo, OpenAILogo } from "@/components/assistant-ui/logos";
<ModelSelector
models={[
{ id: "gpt-5.5", name: "GPT-5.5", icon: <OpenAILogo /> },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5", icon: <ClaudeLogo /> },
{ id: "gemini-3-pro", name: "Gemini 3 Pro", icon: <GeminiLogo /> },
]}
/>;Icons are opt-in per model — omit icon and the entry renders text-only.
Search
Search is opt-in. Pass searchable to the default component:
<ModelSelector models={models} searchable />The same prop works on ModelSelector.Content when it renders its default children. Or compose ModelSelector.Search into a custom layout. Matching runs against each model's id, name, and keywords; add the provider name to keywords so typing "openai" finds its models.
Composition
All parts are exported individually. The default popover content is List + Effort; replace it to add search, provider groups, or anything else:
import {
ModelSelectorRoot,
ModelSelectorTrigger,
ModelSelectorContent,
ModelSelectorSearch,
ModelSelectorList,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorItem,
ModelSelectorEffort,
} from "@/components/assistant-ui/model-selector";
<ModelSelectorRoot
models={models}
value={modelId}
onValueChange={setModelId}
effort={effort}
onEffortChange={setEffort}
>
<ModelSelectorTrigger variant="outline" />
<ModelSelectorContent>
<ModelSelectorSearch placeholder="Search models..." />
<ModelSelectorList>
<ModelSelectorEmpty />
<ModelSelectorGroup heading="OpenAI">
{openaiModels.map((model) => (
<ModelSelectorItem key={model.id} model={model} />
))}
</ModelSelectorGroup>
<ModelSelectorGroup heading="Anthropic">
{anthropicModels.map((model) => (
<ModelSelectorItem key={model.id} model={model} />
))}
</ModelSelectorGroup>
</ModelSelectorList>
<ModelSelectorEffort label="Thinking" />
</ModelSelectorContent>
</ModelSelectorRoot>| Component | Description |
|---|---|
ModelSelector | Default export with runtime integration |
ModelSelector.Root | Presentational root (no runtime, controlled state) |
ModelSelector.Trigger | CVA-styled trigger showing the current selection |
ModelSelector.Value | Selected model name, icon, and active effort |
ModelSelector.Content | Popover content wrapping a Command |
ModelSelector.Search | Search input that filters the list |
ModelSelector.FocusAnchor | Visually hidden input that anchors keyboard navigation when there is no search box |
ModelSelector.List | List of model items (renders all models by default) |
ModelSelector.Empty | Empty state shown when search has no matches |
ModelSelector.Group | Labeled group of items (e.g. by provider) |
ModelSelector.Separator | Divider between groups or items |
ModelSelector.Item | Individual model option |
ModelSelector.Effort | Thinking level row for the selected model |
ModelSelector.List is a Command list, so filtering and keyboard navigation work across groups automatically. Custom sorting is plain code: order the models before rendering items.
Keyboard navigation needs a focused input to drive it. When content is unfiltered (searchable={false} on ModelSelector.Content, or the default children), ModelSelector.Content renders a visually hidden ModelSelector.FocusAnchor automatically, so custom layouts without a search box stay keyboard-operable. In a custom layout that renders neither ModelSelector.Search nor searchable={false}, place ModelSelector.FocusAnchor yourself to keep the list reachable from the keyboard.
ModelSelector.Content wraps a Command whose root keydown handler claims
Enter to select the highlighted model and the arrow keys to move
through the list. Interactive elements composed inside it (filter chips,
custom effort controls) should stop propagation for the keys they handle in
their own onKeyDown so the focused control responds instead.
ModelSelector.Effort does this for Home / End (which
cmdk would otherwise use to jump to the first / last model), lets its
radiogroup own ArrowLeft / ArrowRight, and hands
ArrowUp / ArrowDown back to the model list by
refocusing cmdk's input, so the highlight only moves while a following
Enter can act on it.
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)Keyboard Navigation
The picker is fully operable from the keyboard, including when search is disabled.
| Key | Action |
|---|---|
| ArrowDown / ArrowUp | Open the popover from the focused trigger; move between models once open, returning focus to the list from the Thinking row |
| Enter | Select the highlighted model and close |
| Escape | Close the popover and return focus to the trigger |
| Tab | Move from the model list to the Thinking row |
| ArrowLeft / ArrowRight | Move between reasoning effort levels (Thinking row) |
| Home / End | Jump to the first / last model (list) or effort level (Thinking row) |
When searchable is set, typing filters the list; otherwise the keys above drive selection directly.
Accessibility
The picker implements the WAI-ARIA combobox pattern over Popover + Command.
- The trigger is
role="combobox"witharia-haspopup="listbox"; the popover primitive managesaria-expandedandaria-controls. - The model list is a Command (cmdk) listbox: each item is
role="option"witharia-selected, and the active item is tracked witharia-activedescendant. - Keyboard navigation works without a visible search box:
ModelSelector.Contentrenders a visually hidden input (ModelSelector.FocusAnchor) that anchors cmdk's focus so the list stays reachable. Passsearchableto surface a real search input instead. - The Thinking row (
ModelSelector.Effort) is arole="radiogroup"ofrole="radio"toggles with roving tabindex, so it is a single tab stop and ArrowLeft / ArrowRight move focus and select in one step. ArrowUp / ArrowDown return focus to the model list.
How It Works
The default ModelSelector export registers the selection with assistant-ui's ModelContext system:
- The component calls
aui.modelContext.register()withconfig.modelName, plusconfig.reasoningEffortwhen the selected model supports the chosen level - The
AssistantChatTransportincludesconfigin the request body of every chat request - Your API route reads
config.modelNameandconfig.reasoningEffort
This works out of the box with @assistant-ui/react-ai-sdk. ModelSelector.Root performs no registration; it is purely presentational, with controlled and uncontrolled props for the value, effort, and open state.
API Reference
ModelSelector
ModelSelectorPropsmodels: ModelOption[]Array of available models to display.
defaultValue?: stringInitial model ID for uncontrolled usage. Defaults to the first model, captured on first render; if models loads asynchronously, control the value instead.
value?: stringControlled selected model ID.
onValueChange?: (value: string) => voidCallback when selected model changes.
defaultEffort?: stringInitial effort level ID for uncontrolled usage.
effort?: stringControlled effort level ID.
onEffortChange?: (effort: string) => voidCallback when effort level changes.
searchable: boolean= falseRender a search input above the model list.
variant: "outline" | "ghost" | "muted"= "outline"Visual style of the trigger button.
size: "sm" | "default" | "lg"= "default"Size of the trigger button.
align: "start" | "center" | "end"= "start"Alignment of the dropdown relative to the trigger.
contentClassName?: stringAdditional class name for the dropdown content.
ModelOption
ModelOptionid: stringUnique identifier sent to the backend as modelName.
name: stringDisplay name shown in trigger and dropdown.
description?: stringOptional subtitle shown below the model name.
icon?: React.ReactNodeOptional icon displayed before the model name.
disabled?: booleanDisable selection of this model.
keywords?: string[]Extra search terms matched by ModelSelector.Search (e.g. the provider name).
efforts?: boolean | ModelSelectorEffortOption[]Reasoning effort levels. true enables the default Low/Medium/High; pass a custom { id, name } list to override. Omit for models without configurable reasoning.
useModelSelectorEfforts
const { efforts, effort, setEffort } = useModelSelectorEfforts();The selected model's effort levels and the active selection, for building a custom effort UI inside ModelSelector.Content. efforts is undefined for models without configurable reasoning.
resolveModelEffort
resolveModelEffort(models, modelId, effort); // => string | undefinedReturns the effort ID when the given model supports it, otherwise undefined. This is the sticky selection rule the default component applies before registering the selection.
Related
- Model Context: How registered context (instructions, tools, config) reaches your backend