A composable dropdown for selecting AI models. Built on Radix UI Dropdown Menu,
it supports radio groups for single selection, sub-menus for nested options, plain items for toggles or actions, and separators for grouping.
The trigger displays the selected model's icon and title when you pass an items array.
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import GeminiIcon from "@/components/svgs/gemini";
const models = [
{
value: "gpt-4",
icon: ChatgptIcon,
title: "GPT-4",
description: "Most capable, best for complex tasks",
},
{
value: "gpt-4o-mini",
icon: ChatgptIcon,
title: "GPT-4o Mini",
description: "Fast and affordable",
},
{
value: "claude-3.5",
icon: ClaudeIcon2,
title: "Claude 3.5",
description: "Strong reasoning and analysis",
},
{
value: "gemini-1.5-flash",
icon: GeminiIcon,
title: "Gemini 1.5 Flash",
description: "Fast and versatile",
},
];
export default function ModelSelectorDefault() {
const [model, setModel] = React.useState("gpt-4");
return (
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger />
<ModelSelectorContent className="w-[264px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Select model</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
description={m.description}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
);
}
Installation
npx shadcn@latest add @nexus-ui/model-selectorpnpm dlx shadcn@latest add @nexus-ui/model-selectoryarn dlx shadcn@latest add @nexus-ui/model-selectorbunx shadcn@latest add @nexus-ui/model-selectorCopy and paste the following code into your project.
"use client";
import * as React from "react";
import {
Tick02Icon,
ArrowDown01Icon,
ArrowRight01Icon,
SquareLock01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cva, type VariantProps } from "class-variance-authority";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const triggerVariants = cva(
"inline-flex h-8 cursor-pointer items-center gap-1 rounded-full px-3 font-normal text-primary text-sm outline-none transition-all duration-200 ease-out [&>span:last-child]:transition-transform [&>span:last-child]:duration-200 data-[state=open]:[&>span:last-child]:rotate-180",
{
variants: {
variant: {
filled:
"bg-muted hover:bg-border",
outline:
"border border-input bg-transparent hover:bg-muted data-[state=open]:bg-transparent",
ghost:
"bg-transparent hover:bg-muted data-[state=open]:bg-transparent",
},
},
defaultVariants: {
variant: "filled",
},
},
);
export type ModelItemData = {
icon?: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
};
function matchesModelItemFilter(
filterQuery: string,
fields: {
value?: string;
title?: string | null;
description?: string | null;
},
) {
const q = filterQuery.trim().toLowerCase();
if (!q) return true;
if (fields.value != null && fields.value.toLowerCase().includes(q)) {
return true;
}
if (fields.title?.toLowerCase().includes(q)) return true;
if (fields.description?.toLowerCase().includes(q)) return true;
return false;
}
const ModelSelectorContext = React.createContext<{
value: string;
onValueChange: (value: string) => void;
items: Map<string, ModelItemData>;
filterQuery: string;
setFilterQuery: (query: string) => void;
} | null>(null);
function useModelSelectorContext() {
const ctx = React.useContext(ModelSelectorContext);
if (!ctx) {
throw new Error(
"ModelSelector components must be used within ModelSelector",
);
}
return ctx;
}
function ModelSelector({
value,
onValueChange,
items: itemsProp,
children,
onOpenChange,
...props
}: Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Root>,
"value" | "onValueChange"
> & {
value: string;
onValueChange: (value: string) => void;
items?: Array<{ value: string } & ModelItemData>;
}) {
const items = React.useMemo(() => {
if (!itemsProp) return new Map<string, ModelItemData>();
const m = new Map<string, ModelItemData>();
for (const { value: v, ...rest } of itemsProp) {
m.set(v, rest);
}
return m;
}, [itemsProp]);
const [filterQuery, setFilterQuery] = React.useState("");
const handleOpenChange = React.useCallback(
(open: boolean) => {
onOpenChange?.(open);
if (!open) setFilterQuery("");
},
[onOpenChange],
);
const ctx = React.useMemo(
() => ({
value,
onValueChange,
items,
filterQuery,
setFilterQuery,
}),
[value, onValueChange, items, filterQuery],
);
return (
<ModelSelectorContext.Provider value={ctx}>
<DropdownMenuPrimitive.Root
data-slot="model-selector"
onOpenChange={handleOpenChange}
{...props}
>
{children}
</DropdownMenuPrimitive.Root>
</ModelSelectorContext.Provider>
);
}
ModelSelector.displayName = "ModelSelector";
function ModelSelectorPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal
data-slot="model-selector-portal"
{...props}
/>
);
}
ModelSelectorPortal.displayName = "ModelSelectorPortal";
function ModelSelectorTrigger({
className,
children,
asChild,
variant = "filled",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger> &
VariantProps<typeof triggerVariants>) {
const { value, items } = useModelSelectorContext();
const selected = items.get(value);
const defaultContent = (
<>
<span
data-slot="model-selector-trigger-content"
className="flex items-center gap-1"
>
{selected?.icon && <selected.icon className="size-4 shrink-0" />}
<span
data-slot="model-selector-trigger-title"
className="truncate text-sm"
>
{selected?.title ?? value}
</span>
</span>
<span data-slot="model-selector-trigger-chevron">
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2.0}
className="size-4 shrink-0"
/>
</span>
</>
);
const content = children ?? defaultContent;
let triggerContent = content;
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{
children?: React.ReactNode;
}>;
triggerContent = React.cloneElement(child, {
children: child.props.children ?? defaultContent,
});
}
return (
<DropdownMenuPrimitive.Trigger
data-slot="model-selector-trigger"
data-variant={variant}
asChild={asChild}
className={cn(triggerVariants({ variant }), className)}
{...props}
>
{triggerContent}
</DropdownMenuPrimitive.Trigger>
);
}
ModelSelectorTrigger.displayName = "ModelSelectorTrigger";
function ModelSelectorContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="model-selector-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[min(var(--model-selector-content-max-height,500px),var(--radix-dropdown-menu-content-available-height))] min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto overscroll-none rounded-lg border border-accent bg-popover p-1 text-popover-foreground shadow-modal duration-200 ease-out data-[side=bottom]:slide-in-from-top-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-[state=closed]:overflow-hidden data-[state=closed]:duration-0 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",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
ModelSelectorContent.displayName = "ModelSelectorContent";
const ModelSelectorSearch = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(function ModelSelectorSearch(
{
className,
type = "text",
onKeyDown,
onPointerDown,
onChange,
autoComplete = "off",
...props
},
ref,
) {
const { filterQuery, setFilterQuery } = useModelSelectorContext();
return (
<div
data-slot="model-selector-search-wrapper"
className="sticky top-0 z-10 bg-popover py-1"
onPointerDown={(e) => {
e.preventDefault();
}}
>
<HugeiconsIcon
icon={Search01Icon}
className="absolute top-1/2 left-2.75 -mb-0.25 size-4 -translate-y-1/2 text-muted-foreground"
strokeWidth={2}
/>
<input
ref={ref}
type={type}
data-slot="model-selector-search"
autoComplete={autoComplete}
className={cn(
"inline-flex h-8 w-full items-center gap-2 rounded-[6px] bg-muted p-1.5 ps-8.5 pe-2 text-[13px] leading-6 font-[350] text-muted-foreground transition-all placeholder:text-muted-foreground hover:bg-border/50 focus-visible:ring-2 focus-visible:outline-0 focus-visible:ring-border dark:focus-visible:ring-ring",
className,
)}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key === "Escape") return;
e.stopPropagation();
}}
onPointerDown={(e) => {
onPointerDown?.(e);
e.stopPropagation();
}}
{...props}
value={filterQuery}
onChange={(e) => {
setFilterQuery(e.target.value);
onChange?.(e);
}}
/>
</div>
);
});
ModelSelectorSearch.displayName = "ModelSelectorSearch";
function ModelSelectorEmpty({
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const { filterQuery, items } = useModelSelectorContext();
const q = filterQuery.trim();
const hasCatalogMatch = React.useMemo(() => {
if (!q || items.size === 0) return true;
for (const [value, data] of items) {
if (
matchesModelItemFilter(q, {
value,
title: data.title,
description: data.description,
})
) {
return true;
}
}
return false;
}, [q, items]);
if (!q || hasCatalogMatch) return null;
return (
<div data-slot="model-selector-empty" className={cn("flex h-27 w-full items-center justify-center px-3 py-2 text-center text-[13px] font-[350] text-muted-foreground", className)} {...props}>
{children ?? "No models found"}
</div>
);
}
ModelSelectorEmpty.displayName = "ModelSelectorEmpty";
function ModelSelectorGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="model-selector-group" {...props} />
);
}
ModelSelectorGroup.displayName = "ModelSelectorGroup";
function ModelSelectorItemTitle({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
data-slot="model-selector-item-title"
className={cn("truncate", className)}
{...props}
/>
);
}
ModelSelectorItemTitle.displayName = "ModelSelectorItemTitle";
function ModelSelectorItemDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
data-slot="model-selector-item-description"
className={cn("truncate text-xs", className)}
{...props}
/>
);
}
ModelSelectorItemDescription.displayName = "ModelSelectorItemDescription";
function ModelSelectorItemIcon({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
data-slot="model-selector-item-icon"
className={cn("flex shrink-0 items-center justify-center", className)}
{...props}
/>
);
}
ModelSelectorItemIcon.displayName = "ModelSelectorItemIcon";
function ModelSelectorItemIndicator({
className,
children,
wrapWithItemIndicator = true,
...props
}: React.HTMLAttributes<HTMLSpanElement> & {
/** When false, children are rendered directly without ItemIndicator. Use for always-visible content (e.g. LockIcon when disabled). */
wrapWithItemIndicator?: boolean;
}) {
return (
<span
data-slot="model-selector-item-indicator"
className={cn(
"pointer-events-none absolute right-2 flex size-3.5 items-center justify-center",
className,
)}
{...props}
>
{wrapWithItemIndicator ? (
<DropdownMenuPrimitive.ItemIndicator>
{children}
</DropdownMenuPrimitive.ItemIndicator>
) : (
children
)}
</span>
);
}
ModelSelectorItemIndicator.displayName = "ModelSelectorItemIndicator";
function ModelSelectorItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-2 text-sm font-normal text-primary outline-hidden transition-colors duration-0 select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
ModelSelectorItem.displayName = "ModelSelectorItem";
function ModelSelectorCheckboxItem({
className,
children,
checked,
icon: Icon,
title,
description,
disabled,
indicator,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
icon?: React.ComponentType<{ className?: string }>;
title?: string;
description?: string;
disabled?: boolean;
indicator?: React.ReactNode;
/** Custom content to show when selected. Renders inside ItemIndicator. Defaults to CheckIcon. */
}) {
const { filterQuery } = useModelSelectorContext();
const hasFilterableText = title != null || description != null;
if (
hasFilterableText &&
!matchesModelItemFilter(filterQuery, { title, description })
) {
return null;
}
const defaultContent = (
<>
{Icon && (
<ModelSelectorItemIcon className="size-8 rounded-md bg-muted">
<Icon className="size-4 text-muted-foreground" />
</ModelSelectorItemIcon>
)}
<div
data-slot="model-selector-checkbox-item-content"
className="min-w-0 flex-1"
>
{title != null && (
<ModelSelectorItemTitle className="font-medium">
{title}
</ModelSelectorItemTitle>
)}
{description != null && (
<ModelSelectorItemDescription className="text-muted-foreground">
{description}
</ModelSelectorItemDescription>
)}
</div>
</>
);
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="model-selector-checkbox-item"
className={cn(
"relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-md py-3 pr-3 pl-3 text-sm outline-hidden transition-colors duration-0 select-none hover:bg-accent focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
disabled={disabled}
{...props}
>
{children ?? defaultContent}
<ModelSelectorItemIndicator
className="right-3 size-4"
wrapWithItemIndicator={!disabled}
>
{disabled ? (
<HugeiconsIcon
icon={SquareLock01Icon}
strokeWidth={2.0}
className="size-4 opacity-50"
/>
) : (
(indicator ?? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2.0}
className="size-4"
/>
))
)}
</ModelSelectorItemIndicator>
</DropdownMenuPrimitive.CheckboxItem>
);
}
ModelSelectorCheckboxItem.displayName = "ModelSelectorCheckboxItem";
function ModelSelectorRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="model-selector-radio-group"
{...props}
/>
);
}
ModelSelectorRadioGroup.displayName = "ModelSelectorRadioGroup";
function ModelSelectorRadioItem({
className,
value,
children,
icon: Icon,
title,
description,
disabled,
indicator,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
icon?: React.ComponentType<{ className?: string }>;
title?: string;
description?: string;
/** Custom content to show when selected. Renders inside ItemIndicator. Defaults to CheckIcon. */
indicator?: React.ReactNode;
}) {
const { filterQuery } = useModelSelectorContext();
if (
!matchesModelItemFilter(filterQuery, {
value,
title,
description,
})
) {
return null;
}
const defaultContent = (
<>
{Icon && (
<ModelSelectorItemIcon>
<Icon className="size-4" />
</ModelSelectorItemIcon>
)}
<div
data-slot="model-selector-radio-item-content"
className="flex min-w-0 flex-1 flex-col gap-0.25"
>
{title != null && (
<ModelSelectorItemTitle className="text-sm font-normal">
{title}
</ModelSelectorItemTitle>
)}
{description != null && (
<ModelSelectorItemDescription className="font-[350] text-muted-foreground">
{description}
</ModelSelectorItemDescription>
)}
</div>
</>
);
return (
<DropdownMenuPrimitive.RadioItem
data-slot="model-selector-radio-item"
value={value}
disabled={disabled}
className={cn(
"relative flex min-h-9 cursor-pointer items-center gap-2 rounded-md py-2 pr-9 pl-3 text-sm outline-hidden transition-colors duration-0 select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children ?? defaultContent}
<ModelSelectorItemIndicator
className="right-3 size-4"
wrapWithItemIndicator={!disabled}
>
{disabled ? (
<HugeiconsIcon
icon={SquareLock01Icon}
strokeWidth={2.0}
className="size-4 opacity-50"
/>
) : (
(indicator ?? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={2.0}
className="size-4"
/>
))
)}
</ModelSelectorItemIndicator>
</DropdownMenuPrimitive.RadioItem>
);
}
ModelSelectorRadioItem.displayName = "ModelSelectorRadioItem";
function ModelSelectorLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="model-selector-label"
data-inset={inset}
className={cn(
"px-3 py-2 text-xs font-[450] text-muted-foreground data-inset:pl-8",
className,
)}
{...props}
/>
);
}
ModelSelectorLabel.displayName = "ModelSelectorLabel";
function ModelSelectorSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="model-selector-separator"
className={cn(
"mx-auto my-2 h-px w-[calc(100%-24px)] bg-border transition-opacity duration-150",
className,
)}
{...props}
/>
);
}
ModelSelectorSeparator.displayName = "ModelSelectorSeparator";
function ModelSelectorSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return (
<DropdownMenuPrimitive.Sub data-slot="model-selector-sub" {...props} />
);
}
ModelSelectorSub.displayName = "ModelSelectorSub";
function ModelSelectorSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="model-selector-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 text-sm outline-hidden transition-colors duration-0 select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-primary [&>svg:last-child]:transition-transform [&>svg:last-child]:duration-200 data-[state=open]:[&>svg:last-child]:rotate-90",
className,
)}
{...props}
>
{children}
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2.0}
className="ml-auto size-4"
/>
</DropdownMenuPrimitive.SubTrigger>
);
}
ModelSelectorSubTrigger.displayName = "ModelSelectorSubTrigger";
function ModelSelectorSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="model-selector-sub-content"
className={cn(
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden overscroll-none rounded-lg border border-accent bg-popover p-1 text-popover-foreground shadow-modal duration-200 ease-out data-[side=bottom]:slide-in-from-top-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-[state=closed]:duration-0 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",
className,
)}
{...props}
/>
);
}
ModelSelectorSubContent.displayName = "ModelSelectorSubContent";
export {
ModelSelector,
ModelSelectorPortal,
ModelSelectorTrigger,
ModelSelectorContent,
ModelSelectorSearch,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorItemTitle,
ModelSelectorItemDescription,
ModelSelectorItemIcon,
ModelSelectorItemIndicator,
ModelSelectorItem,
ModelSelectorCheckboxItem,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorSeparator,
ModelSelectorSub,
ModelSelectorSubTrigger,
ModelSelectorSubContent,
};
Update the import paths to match your project setup.
Usage
import {
ModelSelector,
ModelSelectorTrigger,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
} from "@/components/nexus-ui/model-selector";<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger />
<ModelSelectorContent>
<ModelSelectorGroup>
<ModelSelectorLabel>Select model</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>Examples
Basic
A minimal model selector with radio items. No icons, descriptions, or labels.
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
const models = [
{ value: "gpt-4", title: "GPT-4" },
{ value: "gpt-4o-mini", title: "GPT-4o Mini" },
{ value: "claude-3.5", title: "Claude 3.5" },
{ value: "gemini-1.5-flash", title: "Gemini 1.5 Flash" },
];
export default function ModelSelectorBasic() {
const [model, setModel] = React.useState("gpt-4");
return (
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger />
<ModelSelectorContent className="w-[200px]" align="start">
<ModelSelectorGroup>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem key={m.value} value={m.value} title={m.title} />
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
);
}
Trigger Variants
The trigger supports three variants: filled (default), outline, and ghost. Use outline or ghost when placing
the model selector inside a Prompt Input or other dense UI.
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import GeminiIcon from "@/components/svgs/gemini";
const models = [
{ value: "gpt-4", icon: ChatgptIcon, title: "GPT-4" },
{ value: "claude-3.5", icon: ClaudeIcon2, title: "Claude 3.5" },
{ value: "gemini-1.5-flash", icon: GeminiIcon, title: "Gemini 1.5 Flash" },
];
export default function ModelSelectorTriggerVariants() {
const [filled, setFilled] = React.useState("gpt-4");
const [outline, setOutline] = React.useState("claude-3.5");
const [ghost, setGhost] = React.useState("gemini-1.5-flash");
return (
<div className="flex flex-wrap items-center gap-4">
<ModelSelector value={filled} onValueChange={setFilled} items={models}>
<ModelSelectorTrigger variant="filled" />
<ModelSelectorContent className="w-[220px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Filled (default)</ModelSelectorLabel>
<ModelSelectorRadioGroup value={filled} onValueChange={setFilled}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
<ModelSelector value={outline} onValueChange={setOutline} items={models}>
<ModelSelectorTrigger variant="outline" />
<ModelSelectorContent className="w-[220px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Outline</ModelSelectorLabel>
<ModelSelectorRadioGroup value={outline} onValueChange={setOutline}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
<ModelSelector value={ghost} onValueChange={setGhost} items={models}>
<ModelSelectorTrigger variant="ghost" />
<ModelSelectorContent className="w-[220px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Ghost</ModelSelectorLabel>
<ModelSelectorRadioGroup value={ghost} onValueChange={setGhost}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
</div>
);
}
Custom Trigger
Pass custom children to ModelSelectorTrigger to override the default icon-and-title layout.
You control the content entirely—use your own copy, icons, and layout.
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import GeminiIcon from "@/components/svgs/gemini";
import { AiMagicIcon, ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const models = [
{ value: "gpt-4", icon: ChatgptIcon, title: "GPT-4" },
{ value: "gpt-4o-mini", icon: ChatgptIcon, title: "GPT-4o Mini" },
{ value: "claude-3.5", icon: ClaudeIcon2, title: "Claude 3.5" },
{ value: "gemini-1.5-flash", icon: GeminiIcon, title: "Gemini 1.5 Flash" },
];
export default function ModelSelectorCustomTrigger() {
const [model, setModel] = React.useState("gpt-4");
const selected = models.find((m) => m.value === model);
return (
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="outline" className="gap-2">
<HugeiconsIcon
icon={AiMagicIcon}
strokeWidth={2.0}
className="size-3.5 text-muted-foreground"
/>
<span className="text-muted-foreground">Using</span>
<span className="font-medium text-foreground">
{selected?.title ?? model}
</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2.0}
className="size-4 shrink-0 text-muted-foreground"
/>
</ModelSelectorTrigger>
<ModelSelectorContent className="w-[264px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Select model</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
);
}
With Sub-menus
Use ModelSelectorSub, ModelSelectorSubTrigger, ModelSelectorPortal, and ModelSelectorSubContent for nested
options like provider-specific model groups.
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorPortal,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorSub,
ModelSelectorSubContent,
ModelSelectorSubTrigger,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import V0Icon from "@/components/svgs/v0";
const openaiModels = [
{ value: "gpt-4", icon: ChatgptIcon, title: "GPT-4" },
{ value: "gpt-4o-mini", icon: ChatgptIcon, title: "GPT-4o Mini", disabled: true },
];
const claudeModels = [
{ value: "claude-3.5-sonnet", icon: ClaudeIcon2, title: "Claude 3.5 Sonnet" },
{ value: "claude-3.5-haiku", icon: ClaudeIcon2, title: "Claude 3.5 Haiku" },
];
const v0Models = [
{ value: "v0-auto", icon: V0Icon, title: "v0 Auto" },
{ value: "v0-pro", icon: V0Icon, title: "v0 Pro" },
{ value: "v0-max", icon: V0Icon, title: "v0 Max" },
];
export default function ModelSelectorWithSub() {
const [model, setModel] = React.useState("gpt-4");
return (
<ModelSelector
value={model}
onValueChange={setModel}
items={[...openaiModels, ...claudeModels, ...v0Models]}
>
<ModelSelectorTrigger />
<ModelSelectorContent className="w-[240px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>OpenAI</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{openaiModels.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
disabled={m.disabled}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
<ModelSelectorGroup>
<ModelSelectorLabel>Vercel</ModelSelectorLabel>
<ModelSelectorSub>
<ModelSelectorSubTrigger>
<V0Icon className="size-4" />
v0 Models
</ModelSelectorSubTrigger>
<ModelSelectorPortal>
<ModelSelectorSubContent>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{v0Models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorSubContent>
</ModelSelectorPortal>
</ModelSelectorSub>
</ModelSelectorGroup>
<ModelSelectorGroup>
<ModelSelectorLabel>Anthropic</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{claudeModels.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
);
}
With Checkbox
Use ModelSelectorCheckboxItem alone for multi-select. Each item is a checkbox—toggle models on or off. Use a custom
trigger to show the selection (e.g. "Select models", "GPT-4", or "3 models").
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorCheckboxItem,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import GeminiIcon from "@/components/svgs/gemini";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const models = [
{
value: "gpt-4",
icon: ChatgptIcon,
title: "GPT-4",
description: "Most capable",
},
{
value: "gpt-4o-mini",
icon: ChatgptIcon,
title: "GPT-4o Mini",
description: "Fast",
},
{
value: "claude-3.5",
icon: ClaudeIcon2,
title: "Claude 3.5",
description: "Strong reasoning",
},
{
value: "gemini-1.5-flash",
icon: GeminiIcon,
title: "Gemini 1.5 Flash",
description: "Fast and versatile",
},
];
export default function ModelSelectorWithCheckbox() {
const [selected, setSelected] = React.useState<string[]>([]);
const toggle = (value: string, checked: boolean) => {
setSelected((prev) =>
checked ? [...prev, value] : prev.filter((v) => v !== value),
);
};
const triggerLabel =
selected.length === 0
? "Select models"
: selected.length === 1
? (models.find((m) => m.value === selected[0])?.title ?? selected[0])
: `${selected.length} models`;
return (
<ModelSelector
value={selected[0] ?? ""}
onValueChange={() => {}}
items={models}
>
<ModelSelectorTrigger variant="outline">
<span className="truncate">{triggerLabel}</span>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2.0} className="size-4 shrink-0" />
</ModelSelectorTrigger>
<ModelSelectorContent className="w-[264px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Enabled models</ModelSelectorLabel>
{models.map((m) => (
<ModelSelectorCheckboxItem
key={m.value}
checked={selected.includes(m.value)}
onCheckedChange={(checked) => toggle(m.value, !!checked)}
icon={m.icon}
title={m.title}
description={m.description}
/>
))}
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
);
}
With Prompt Input
Place the model selector in a PromptInputActionGroup alongside attach, image, mic, and send buttons.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import PromptInput, {
PromptInputActions,
PromptInputAction,
PromptInputActionGroup,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import GeminiIcon from "@/components/svgs/gemini";
import {
ArrowUp02Icon,
Mic02Icon,
PlusSignIcon,
SquareIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const models = [
{
value: "gpt-4",
icon: ChatgptIcon,
title: "GPT-4",
description: "Most capable",
},
{
value: "gpt-4o-mini",
icon: ChatgptIcon,
title: "GPT-4o Mini",
description: "Fast",
},
{
value: "claude-3.5",
icon: ClaudeIcon2,
title: "Claude 3.5",
description: "Strong reasoning",
},
{
value: "gemini-1.5-flash",
icon: GeminiIcon,
title: "Gemini 1.5 Flash",
description: "Fast and versatile",
},
];
type InputStatus = "idle" | "loading" | "error" | "submitted";
export default function ModelSelectorWithPromptInput() {
const [model, setModel] = React.useState("gpt-4");
const [input, setInput] = React.useState("");
const [status, setStatus] = React.useState<InputStatus>("idle");
const doSubmit = React.useCallback((value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
setInput("");
setStatus("loading");
setTimeout(() => {
setStatus("submitted");
setTimeout(() => setStatus("idle"), 800);
}, 2500);
}, []);
const isLoading = status === "loading";
return (
<PromptInput onSubmit={doSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask anything..."
disabled={isLoading}
/>
<PromptInputActions>
<PromptInputActionGroup>
<PromptInputAction asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon icon={PlusSignIcon} strokeWidth={2.0} className="size-4" />
</Button>
</PromptInputAction>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction asChild>
<ModelSelector
value={model}
onValueChange={setModel}
items={models}
>
<ModelSelectorTrigger variant="ghost" />
<ModelSelectorContent className="w-[264px]" align="end">
<ModelSelectorGroup>
<ModelSelectorLabel>Select model</ModelSelectorLabel>
<ModelSelectorRadioGroup
value={model}
onValueChange={setModel}
>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
description={m.description}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
</PromptInputAction>
<PromptInputAction asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon icon={Mic02Icon} strokeWidth={2.0} className="size-4" />
</Button>
</PromptInputAction>
<PromptInputAction asChild>
<Button
type="button"
size="icon-sm"
className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
disabled={!isLoading && !input.trim()}
onClick={() => input.trim() && doSubmit(input)}
>
{isLoading ? (
<HugeiconsIcon icon={SquareIcon} strokeWidth={2.0} className="size-3.5 fill-current" />
) : (
<HugeiconsIcon icon={ArrowUp02Icon} strokeWidth={2.0} className="size-4" />
)}
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
);
}
With Search
Place ModelSelectorSearch first in ModelSelectorContent; the root owns the query and clears it when the menu closes (onOpenChange still runs if you pass it). Radio items filter on value, title, and description; checkbox items on title and description only; plain ModelSelectorItem rows are not filtered. Optional search onChange runs after internal updates. ModelSelectorEmpty needs root items to show no-match copy (default: No models found).
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorSearch,
ModelSelectorEmpty,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import GeminiIcon from "@/components/svgs/gemini";
const models = [
{
value: "gpt-4",
icon: ChatgptIcon,
title: "GPT-4",
description: "Most capable, best for complex tasks",
},
{
value: "gpt-4-turbo",
icon: ChatgptIcon,
title: "GPT-4 Turbo",
description: "Lower latency GPT-4 class model",
},
{
value: "gpt-4o",
icon: ChatgptIcon,
title: "GPT-4o",
description: "Multimodal flagship",
},
{
value: "gpt-4o-mini",
icon: ChatgptIcon,
title: "GPT-4o Mini",
description: "Fast and affordable",
},
{
value: "o1",
icon: ChatgptIcon,
title: "o1",
description: "Reasoning-optimized for hard problems",
},
{
value: "o3-mini",
icon: ChatgptIcon,
title: "o3-mini",
description: "Compact reasoning model",
},
{
value: "claude-3-opus",
icon: ClaudeIcon2,
title: "Claude 3 Opus",
description: "Highest capability Claude 3",
},
{
value: "claude-3.5",
icon: ClaudeIcon2,
title: "Claude 3.5 Sonnet",
description: "Strong reasoning and analysis",
},
{
value: "claude-3-sonnet",
icon: ClaudeIcon2,
title: "Claude 3 Sonnet",
description: "Balanced speed and quality",
},
{
value: "claude-3-haiku",
icon: ClaudeIcon2,
title: "Claude 3 Haiku",
description: "Lightweight and quick",
},
{
value: "gemini-2.0-flash",
icon: GeminiIcon,
title: "Gemini 2.0 Flash",
description: "Latest fast Gemini",
},
{
value: "gemini-1.5-pro",
icon: GeminiIcon,
title: "Gemini 1.5 Pro",
description: "Long context and reasoning",
},
{
value: "gemini-1.5-flash",
icon: GeminiIcon,
title: "Gemini 1.5 Flash",
description: "Fast and versatile",
},
{
value: "gemini-1.5-flash-8b",
icon: GeminiIcon,
title: "Gemini 1.5 Flash 8B",
description: "Smallest Gemini for simple tasks",
},
];
export default function ModelSelectorWithSearch() {
const [model, setModel] = React.useState("gpt-4");
return (
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="filled" />
<ModelSelectorContent className="w-[264px] pt-0 [--model-selector-content-max-height:311px]" align="start">
<ModelSelectorSearch
placeholder="Search models"
aria-label="Filter models"
/>
<ModelSelectorGroup>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
description={m.description}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
<ModelSelectorEmpty />
</ModelSelectorContent>
</ModelSelector>
);
}
With Items and Separators
Use ModelSelectorItem for non-selection actions (e.g. extended thinking toggle) and ModelSelectorSeparator
to divide sections.
"use client";
import * as React from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorItem,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import { ClaudeIcon2 } from "@/components/svgs/claude";
import { Switch } from "@/components/ui/switch";
const models = [
{
value: "gpt-4o-mini",
icon: ChatgptIcon,
title: "GPT-4o Mini",
description: "Fast and affordable",
},
{
value: "claude-3.5",
icon: ClaudeIcon2,
title: "Claude 3.5",
description: "Strong reasoning",
disabled: true,
},
];
export default function ModelSelectorWithItems() {
const [model, setModel] = React.useState("gpt-4o-mini");
const [extendedThinking, setExtendedThinking] = React.useState(false);
return (
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger />
<ModelSelectorContent className="w-[264px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Select model</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
description={m.description}
disabled={m.disabled}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
<ModelSelectorSeparator />
<ModelSelectorItem
onClick={() => setExtendedThinking(!extendedThinking)}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex flex-col gap-0.25">
<p className="text-sm font-normal text-foreground">
Extended thinking
</p>
<p className="text-xs font-[350] text-muted-foreground">
Think longer for complex tasks
</p>
</div>
<Switch
checked={extendedThinking}
onCheckedChange={setExtendedThinking}
/>
</div>
</ModelSelectorItem>
</ModelSelectorContent>
</ModelSelector>
);
}
Vercel AI SDK Integration
Use the Model Selector with the Vercel AI SDK to let users pick which model powers the chat. Pass the selected model to your API via prepareSendMessagesRequest.
Install the AI SDK
npm install ai @ai-sdk/react @ai-sdk/openaiCreate your chat API route
Create a route that reads model from the request body:
import { convertToModelMessages, streamText, UIMessage } from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages, model = "gpt-4o-mini" }: { messages: UIMessage[]; model?: string } =
await req.json();
const result = streamText({
model: openai(model),
system: "You are a helpful assistant.",
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Wire Model Selector + Prompt Input to useChat
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Button } from "@/components/ui/button";
import PromptInput, {
PromptInputActions,
PromptInputAction,
PromptInputActionGroup,
PromptInputTextarea,
} from "@/components/nexus-ui/prompt-input";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorLabel,
ModelSelectorRadioGroup,
ModelSelectorRadioItem,
ModelSelectorTrigger,
} from "@/components/nexus-ui/model-selector";
import ChatgptIcon from "@/components/svgs/chatgpt";
import {
ArrowUp02Icon,
PlusSignIcon,
SquareIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const models = [
{ value: "gpt-4", icon: ChatgptIcon, title: "GPT-4", description: "Most capable" },
{ value: "gpt-4o-mini", icon: ChatgptIcon, title: "GPT-4o Mini", description: "Fast" },
{ value: "gpt-4o", icon: ChatgptIcon, title: "GPT-4o", description: "Multimodal" },
];
export default function ChatWithModelSelector() {
const [model, setModel] = useState("gpt-4o-mini");
const { sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
prepareSendMessagesRequest: ({ body }) => ({
body: { ...body, model },
}),
}),
});
const [input, setInput] = useState("");
const isLoading = status !== "ready";
const handleSubmit = (value?: string) => {
const text = (value ?? input).trim();
if (text) {
sendMessage({ text });
setInput("");
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }} className="w-full">
<PromptInput onSubmit={handleSubmit}>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask anything..."
disabled={isLoading}
/>
<PromptInputActions>
<PromptInputActionGroup>
<ModelSelector value={model} onValueChange={setModel} items={models}>
<ModelSelectorTrigger variant="outline" />
<ModelSelectorContent className="w-[264px]" align="start">
<ModelSelectorGroup>
<ModelSelectorLabel>Select model</ModelSelectorLabel>
<ModelSelectorRadioGroup value={model} onValueChange={setModel}>
{models.map((m) => (
<ModelSelectorRadioItem
key={m.value}
value={m.value}
icon={m.icon}
title={m.title}
description={m.description}
/>
))}
</ModelSelectorRadioGroup>
</ModelSelectorGroup>
</ModelSelectorContent>
</ModelSelector>
<PromptInputAction asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full text-secondary-foreground active:scale-97 disabled:opacity-70 hover:dark:bg-secondary"
>
<HugeiconsIcon icon={PlusSignIcon} strokeWidth={2.0} className="size-4" />
</Button>
</PromptInputAction>
</PromptInputActionGroup>
<PromptInputActionGroup>
<PromptInputAction asChild>
<Button
type="submit"
size="icon-sm"
className="cursor-pointer rounded-full active:scale-97 disabled:opacity-70"
disabled={isLoading || !input.trim()}
>
{isLoading ? (
<HugeiconsIcon icon={SquareIcon} strokeWidth={2.0} className="size-3.5 fill-current" />
) : (
<HugeiconsIcon icon={ArrowUp02Icon} strokeWidth={2.0} className="size-4" />
)}
</Button>
</PromptInputAction>
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</form>
);
}For multi-provider support (OpenAI, Anthropic, etc.), add @ai-sdk/anthropic and similar, then map the model value to the correct provider in your API route.
API Reference
The Model Selector primitives extend Radix UI Dropdown Menu.
For props like open, onOpenChange, modal, dir, etc., refer to the Radix docs. Below are the props specific to
the Model Selector.
ModelSelector
Root component. Provides value, onValueChange, and items to children via context.
Prop
Type
ModelSelectorTrigger
The button that opens the dropdown. Shows selected model (icon + title) when items is provided, or custom children.
Supports asChild to merge into a child element (e.g. Button).
Prop
Type
ModelSelectorSearch
A search input bound to the root filter query; radio and checkbox items hide when they do not match. Place at the top of ModelSelectorContent. Accepts standard <input> props.
Prop
Type
ModelSelectorEmpty
Shown when the query is non-empty, items is set on the root, and none of those entries match the filter. Otherwise hidden. Default copy: No models found. Accepts standard <div> props.
Prop
Type
ModelSelectorItem
A plain menu item (no selection state). Use for toggles, links, or actions.
Prop
Type
ModelSelectorItemTitle
A paragraph for the item title. Used internally by CheckboxItem and RadioItem when title is provided. Accepts standard p element props.
Prop
Type
ModelSelectorItemDescription
A paragraph for the item description. Used internally by CheckboxItem and RadioItem when description is provided. Accepts standard p element props.
Prop
Type
ModelSelectorItemIcon
A span wrapper for the item icon. Used internally by CheckboxItem and RadioItem when icon is provided. Accepts standard span element props.
Prop
Type
ModelSelectorItemIndicator
A span that wraps selection indicators. Renders children inside Radix ItemIndicator by default. Use wrapWithItemIndicator={false} for always-visible content (e.g. LockIcon when disabled).
Prop
Type
ModelSelectorCheckboxItem
A checkbox-style item for multi-select. Shows a check indicator when checked is true, or a lock icon when disabled.
Prop
Type
ModelSelectorRadioItem
A radio-style item for single selection. Shows a check when selected, or a lock icon when disabled.
Prop
Type
Other primitives
ModelSelectorContent, ModelSelectorPortal, ModelSelectorGroup, ModelSelectorLabel, ModelSelectorRadioGroup,
ModelSelectorSeparator, ModelSelectorSub, ModelSelectorSubTrigger, and ModelSelectorSubContent accept the
same props as their Radix Dropdown Menu counterparts. See the
Radix Dropdown Menu documentation for details.
ModelSelectorItemTitle, ModelSelectorItemDescription, ModelSelectorItemIcon, and ModelSelectorItemIndicator
are low-level building blocks used by CheckboxItem and RadioItem. Use them when composing custom item content with children.