Select
A dropdown for picking one value from a list - grouped items, labels, separators and scroll buttons, with a popper-positioned content panel.
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export default function SelectDemo() {
return (
<div className="flex flex-wrap items-center gap-3">
<Select>
<SelectTrigger className="w-56">
<SelectValue placeholder="Select a region" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Americas</SelectLabel>
<SelectItem value="iad">us-east-1 · Virginia</SelectItem>
<SelectItem value="sfo">us-west-2 · Oregon</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Select>
<SelectTrigger variant="soft" className="w-44">
<SelectValue placeholder="Soft variant" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
<Select>
<SelectTrigger tone="danger" className="w-44">
<SelectValue placeholder="Danger tone" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
</div>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/selectDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install radix-ui@^1.6.1 lucide-react@^1.23.0 tw-animate-css@^1.4.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/select.tsx"use client"
import type * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { Select as SelectPrimitive } from "@/components/ui/primitives"
import { cn } from "@/lib/utils"
const selectTriggerVariants = cva(
"flex w-fit items-center justify-between gap-2 whitespace-nowrap outline-none transition-[color,box-shadow] duration-(--motion-fast) data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:shrink-0 [&_i]:text-base [&_i]:leading-none",
{
variants: {
variant: {
outline: "border border-field-border bg-transparent shadow-xs dark:bg-input/30",
soft: "border border-transparent bg-surface-3 dark:bg-input/50",
ghost: "border border-transparent bg-transparent hover:bg-surface-3",
},
size: {
sm: "h-8 rounded-md px-2.5 text-sm",
md: "h-9 rounded-lg px-3 text-sm",
lg: "h-10 rounded-lg px-3.5 text-base md:text-sm",
},
tone: {
neutral: "",
success:
"border-success focus-visible:border-success focus-visible:ring-success/20 dark:focus-visible:ring-success/40",
danger:
"border-danger focus-visible:border-danger focus-visible:ring-danger/20 dark:focus-visible:ring-danger/40",
},
},
defaultVariants: { variant: "outline", size: "md", tone: "neutral" },
}
)
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup(props: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
variant,
size = "md",
tone,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> &
VariantProps<typeof selectTriggerVariants>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-variant={variant ?? "outline"}
data-size={size ?? "md"}
data-tone={tone ?? "neutral"}
className={cn(selectTriggerVariants({ variant, size, tone, className }))}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
position={position}
className={cn(
"relative z-50 max-w-[calc(100vw-2rem)] max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default select-none items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-none transition-colors duration-(--motion-fast) focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 [&_i]:shrink-0 [&_i]:text-base [&_i]:leading-none",
className
)}
{...props}
>
<span className="absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
selectTriggerVariants,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the tone colors will be missing.
Usage
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
<Select>
<SelectTrigger className="w-56">
<SelectValue placeholder="Select a region" />
</SelectTrigger>
<SelectContent>
<SelectItem value="iad">us-east-1 · Virginia</SelectItem>
<SelectItem value="sfo">us-west-2 · Oregon</SelectItem>
</SelectContent>
</Select>The trigger is w-fit by default - give it a width class to keep it stable while the value changes. The content panel matches the trigger width automatically.
Examples
Trigger sizes
Trigger variants
Trigger tones
tone colors the border and focus ring for validation states, matching the ai2 Input and Textarea.
Groups and separators
Disabled
disabled on the root disables the whole select; on an item it grays out that option and skips it in keyboard navigation.
Invalid
Set aria-invalid on the trigger when validation fails. The danger border and ring style is built in and matches every other ai2 form control.
Props
SelectTrigger carries the ai2-specific props. Every part also accepts its radix props - value, defaultValue and onValueChange on the root included.
SelectTrigger props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "outline" | "soft" | "ghost" | "outline" | Visual style of the trigger: bordered, filled or transparent. |
size | "sm" | "md" | "lg" | "md" | Height of the trigger: 32, 36 or 40px. |
tone | "neutral" | "success" | "danger" | "neutral" | Validation look: colors the trigger border and focus ring. |
ai2 Select: a dropdown picker for React, built on radix-ui
The ai2 Select is a shadcn-compatible select component for React, built on the radix-ui Select primitive and styled with Tailwind CSS v4. It replaces the native select with a fully styled trigger and a popper-positioned panel that supports groups, labels, separators, scroll buttons for long lists and a check indicator on the selected item.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Select?
The core anatomy is Select, SelectTrigger, SelectValue, SelectContent and SelectItem, extended by SelectGroup, SelectLabel, SelectSeparator and the two scroll buttons. It matches shadcn/ui exactly, so existing snippets, muscle memory and AI agents keep working without changes.
The trigger carries the ai2-specific props from the shared input contract: a variant of outline, soft or ghost, a size of sm, md or lg (32, 36 or 40 pixels tall) and a tone of neutral, success or danger that colors the border and focus ring for validation states. The content renders in a portal with popper positioning, matches the trigger width, and caps its height to the space available in the viewport.
Why use it
- Accessible by construction: The radix-ui Select primitive handles roles, state, focus and full keyboard support: arrow keys move through items, Enter selects, Escape closes and typing jumps to matching options.
- Scroll buttons for long lists: The panel height is capped to the available viewport space, and chevron scroll buttons appear at the top and bottom whenever the list overflows.
- Visible selection: The selected item shows a check icon in a reserved slot on the right, so the current value is obvious even in long grouped lists.
- Trigger-matched panel width: With the default popper positioning the content matches the trigger width automatically, so the closed and open states always line up.
- Agent-readable metadata: The registry item describes its parts and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, its dependencies and the @ai2/tokens theme to your project.
- 3 variants, 3 sizes and 3 tones: The trigger offers outline, soft and ghost variants, sm, md and lg heights of 32, 36 and 40 pixels, and neutral, success and danger tones, aligned with the ai2 Input contract.
- Groups, labels and separators: SelectGroup, SelectLabel and SelectSeparator structure long option lists, like regions grouped by continent in the example above.
- Portal and popper positioning: The panel renders in a portal, escapes overflow-hidden parents, and animates open and close with a fade and zoom tied to data-state.
- Placeholder and invalid styling: The trigger mutes placeholder text through data-placeholder, shows the ai2 focus-visible ring, and switches to the danger border and ring under aria-invalid.
- TypeScript source: Every part is typed end to end and forwards its radix props, including value, defaultValue and onValueChange on the root.
Production tips
- Fix the trigger width: The trigger is w-fit by default and would resize as the value changes. Give it a width class, like w-56, so the layout stays stable.
- Group anything past a handful: Once a list passes seven or eight options, SelectGroup with a SelectLabel per section makes scanning dramatically faster than one flat list.
- Lead item text with the keyword: Typeahead matches what users type against the item text, so start options with the word people think of first, like the region name rather than a code.
- Disable at the right level: disabled on the root grays out the whole control; disabled on a single item keeps it visible but unselectable and skipped by keyboard navigation.
- Know when you need a combobox: Select is single-value and filter-free by design. When users need to search within many options, reach for the ai2 Combobox instead of stuffing a filter into the panel.
Works with the rest of ai2
The select composes naturally with the rest of the registry. Pair it with an ai2 Label for the accessible name, or drop it into an ai2 Field to pick up descriptions and validation messages alongside other form controls.
For neighboring pickers, the ai2 Combobox adds text filtering for long lists, the ai2 Dropdown Menu covers actions rather than values, and the ai2 Radio Group keeps two to five options always visible. Everything shares one token source, so combinations stay visually consistent in both modes.