Model Selector

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-selector
pnpm dlx shadcn@latest add @nexus-ui/model-selector
yarn dlx shadcn@latest add @nexus-ui/model-selector
bunx shadcn@latest add @nexus-ui/model-selector

Copy and paste the following code into your project.

components/nexus-ui/model-selector.tsx
"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>
  );
}

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/openai

Create your chat API route

Create a route that reads model from the request body:

app/api/chat/route.ts
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.