Skip to content
goodtabs UI
BrowseBrowse components

Model select

A select for AI models, each with a one-line description and mono meta.

npx shadcn@latest add https://ui.goodtabs.co/r/model-select.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/model-select

Four Claude models with a description and context window each.

With a retired model

A disabled legacy option stays in the list so people know where it went.

Compact

No descriptions and the sm size: single-line items at 28px.

Usage

import { ModelSelect } from "@/components/ui/model-select"

<ModelSelect
  models={[
    { value: "claude-fable-5-1", label: "Claude Fable 5.1", description: "Long agentic sessions.", meta: "1M context" },
    { value: "claude-haiku-5-5", label: "Claude Haiku 5.5", description: "Fastest for simple calls.", meta: "500K context" },
  ]}
  value={model}
  onValueChange={setModel}
/>

Props

PropTypeDefaultDescription
models{ value: string; label: string; description?: string; meta?: string; disabled?: boolean }[]The models to choose from, in order.
value, defaultValue, onValueChangestring, string, (value: string) => voidControlled or uncontrolled, as on radix Select.
widthstring"14rem"Width of the trigger, any CSS length.
placeholderstring"Choose a model"Trigger text before a choice.
size"sm" | "default""default"Trigger height: 28px or 32px.
aria-labelstring"Model"Accessible name of the trigger.

Source

model-select/model-select.tsx
"use client";

import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { Select, SelectContent, SelectGroup, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";

export type ModelOption = {
  value: string;
  label: string;
  /** One line under the label, in the muted tone. */
  description?: string;
  /** Mono meta on the right: context window, price. */
  meta?: string;
  disabled?: boolean;
};

type ModelSelectProps = Omit<React.ComponentProps<typeof Select>, "children"> & {
  /** The models to choose from, in order. */
  models: ModelOption[];
  /** Width of the trigger, any CSS length. */
  width?: string;
  /** Trigger text before a choice is made. */
  placeholder?: string;
  /** Trigger height: 28px or 32px. */
  size?: "sm" | "default";
  /** Accessible name of the trigger. */
  "aria-label"?: string;
  /** Classes for the trigger. */
  className?: string;
};

/**
 * A select for AI models. Each option carries a label, a one-line description and a mono meta line.
 * The trigger shows the label only, so it stays one line wide.
 */
export function ModelSelect({
  models,
  width = "14rem",
  placeholder = "Choose a model",
  size = "default",
  "aria-label": ariaLabel = "Model",
  className,
  ...props
}: ModelSelectProps) {
  return (
    <Select {...props}>
      <SelectTrigger
        size={size}
        aria-label={ariaLabel}
        data-slot="model-select"
        className={cn("max-w-full", className)}
        style={{ width }}
      >
        <SelectValue placeholder={placeholder} />
      </SelectTrigger>
      <SelectContent position="popper" align="start" className="min-w-64 rounded-md border shadow-md ring-0">
        <SelectGroup>
          {models.map((model) => {
            const detailed = Boolean(model.description);
            return (
              <SelectPrimitive.Item
                key={model.value}
                value={model.value}
                disabled={model.disabled}
                className={cn(
                  "group/model-item relative flex w-full cursor-default flex-col justify-center gap-0.5 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
                  !detailed && "h-8 py-0",
                )}
              >
                <span className="pointer-events-none absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center">
                  <SelectPrimitive.ItemIndicator>
                    <CheckIcon className="size-4" aria-hidden="true" />
                  </SelectPrimitive.ItemIndicator>
                </span>
                <span className="flex items-center gap-3">
                  <SelectPrimitive.ItemText>{model.label}</SelectPrimitive.ItemText>
                  {model.meta ? (
                    <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground group-focus/model-item:text-foreground/70">
                      {model.meta}
                    </span>
                  ) : null}
                </span>
                {model.description ? (
                  <span className="text-xs text-muted-foreground group-focus/model-item:text-foreground/70">
                    {model.description}
                  </span>
                ) : null}
              </SelectPrimitive.Item>
            );
          })}
        </SelectGroup>
      </SelectContent>
    </Select>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/model-select/prompt.md, or the Copy AI prompt button above.

When to use

  • Choosing a model in a chat composer, playground or settings page
  • Any select where each option needs a line of help and a figure such as a limit or price
  • When the trigger must stay compact while the options carry detail

When not to use

  • For long lists that need search; build a combobox on Command instead
  • For options without descriptions, use the plain shadcn Select
  • For switching between two or three modes inline, use segmented-tabs

Agent notes

  • A demo or parent that passes onValueChange must be a client component
  • Items are built on radix Select.Item directly so only the label lands in the trigger; the description and meta stay in the list
  • Options without a description render as single 32px rows; mix them freely with detailed ones
  • The content opens in popper mode under the trigger and is at least 16rem wide; width sets the trigger only