V2 componentsUpdated Oct 14, 2026
Select
A single-choice picker. Pass items to the root so the trigger can show the selected label and size itself to the widest option.
Source: packages/ui/src/components/select
import { Select, SelectItemType } from '@highlight/ui'Change the props; the preview and the snippet update together.
const VISIBILITY = [ { label: 'Private', value: 'private', icon: <Lock /> }, { label: 'Team', value: 'team', icon: <Users /> }, { label: 'Anyone with the link', value: 'public', icon: <Globe /> },] <Select items={VISIBILITY} defaultValue="team"> <Select.Trigger placeholder="Choose…" /> <Select.Content> <Select.Item value="private" icon={<Lock />}>Private</Select.Item> <Select.Item value="team" icon={<Users />}>Team</Select.Item> <Select.Item value="public" icon={<Globe />}>Anyone with the link</Select.Item> </Select.Content></Select>import { Select, type SelectItemType } from '@highlight/ui' const MODELS: Array<SelectItemType> = [ { label: 'Claude Fable 5.1', value: 'fable' }, { label: 'Claude Opus 5', value: 'opus' }, { label: 'Claude Sonnet 5', value: 'sonnet' }, { label: 'Claude Haiku 4.5', value: 'haiku' },] export default function SelectBasic() { return ( <Select items={MODELS} defaultValue="fable"> <Select.Trigger /> <Select.Content> {MODELS.map((item) => ( <Select.Item key={String(item.value)} value={item.value}> {item.label} </Select.Item> ))} </Select.Content> </Select> )}sm, md, lg and xl are the shared field ramp, so a trigger and a button in one row line up.
import { CONTROL_SHELL_SIZES, Select, type SelectItemType } from '@highlight/ui' const STATUSES: Array<SelectItemType> = [ { label: 'Todo', value: 'todo' }, { label: 'In progress', value: 'in-progress' }, { label: 'Done', value: 'done' },] function StatusItems() { return ( <Select.Content> {STATUSES.map((item) => ( <Select.Item key={String(item.value)} value={item.value}> {item.label} </Select.Item> ))} </Select.Content> )} export default function SelectSizes() { return ( <div className="flex flex-wrap items-end justify-center gap-4"> {CONTROL_SHELL_SIZES.map((size) => ( <Select key={size} items={STATUSES} defaultValue="in-progress"> <Select.Trigger size={size} /> <StatusItems /> </Select> ))} </div> )}An icon on an item shows beside the label in both the list and the trigger. value and onValueChange make it controlled, the same shape as Base UI.
import { useState } from 'react'import { Globe, Lock, Users } from 'lucide-react'import { Select, type SelectItemType } from '@highlight/ui' const VISIBILITY: Array<SelectItemType> = [ { label: 'Private', value: 'private', icon: <Lock /> }, { label: 'Team', value: 'team', icon: <Users /> }, { label: 'Anyone with the link', value: 'public', icon: <Globe /> },] export default function SelectIcons() { const [value, setValue] = useState<string | null>('team') return ( <Select items={VISIBILITY} value={value} onValueChange={(next) => setValue(next as string | null)}> <Select.Trigger placeholder="Choose…" /> <Select.Content> {VISIBILITY.map((item) => ( <Select.Item key={String(item.value)} value={item.value} icon={item.icon}> {item.label} </Select.Item> ))} </Select.Content> </Select> )}Field pairs the label and note with the trigger and generates the wiring. Pass nativeLabel={false} on the label: a trigger is button-like, and a native label hands it hover it should not have.
Anyone with the link can read, but not edit.
import { Field, Select, type SelectItemType } from '@highlight/ui' const VISIBILITY: Array<SelectItemType> = [ { label: 'Private', value: 'private' }, { label: 'Team', value: 'team' }, { label: 'Anyone with the link', value: 'public' },] function VisibilityItems() { return ( <Select.Content> {VISIBILITY.map((item) => ( <Select.Item key={String(item.value)} value={item.value}> {item.label} </Select.Item> ))} </Select.Content> )} export default function SelectField() { return ( <div className="flex w-full max-w-md flex-col"> <Field> {/* nativeLabel={false}: a native label hands its clicks and hover to the control, and on a trigger that reads as the button lighting up whenever the pointer crosses the words. */} <Field.Label nativeLabel={false}>Visibility</Field.Label> <Select items={VISIBILITY} defaultValue="team"> <Select.Trigger /> <VisibilityItems /> </Select> <Field.Note>Anyone with the link can read, but not edit.</Field.Note> </Field> </div> )}description adds a second line under an option, the same row Menu.ItemText draws. The trigger still shows only the label and icon.
import { useState } from 'react'import { Brain, Gauge, Zap } from 'lucide-react'import { Select, type SelectItemType } from '@highlight/ui' const EFFORT: Array<SelectItemType> = [ { label: 'Fast', value: 'low', icon: <Zap />, description: 'Answers right away. Best for quick questions.' }, { label: 'Balanced', value: 'medium', icon: <Gauge />, description: 'Thinks a little before answering.' }, { label: 'Thorough', value: 'high', icon: <Brain />, description: 'Works through hard problems step by step.' },] export default function SelectDescriptions() { const [value, setValue] = useState<string | null>('medium') return ( <Select items={EFFORT} value={value} onValueChange={(next) => setValue(next as string | null)}> <Select.Trigger placeholder="Choose…" /> <Select.Content> {EFFORT.map((item) => ( <Select.Item key={String(item.value)} value={item.value} icon={item.icon} description={item.description}> {item.label} </Select.Item> ))} </Select.Content> </Select> )}The root, then any compound sub-components. Every part accepts className and forwards the rest of its props to the element or Base UI part beneath it.
Select- Base UI Select.Root. Owns the value and open state; pass items so the trigger can show the chosen label.
Select.Trigger- size: 'sm' | 'md' | 'lg' | 'xl', default 'md'; placeholder; hideIcon; minWidth.
Select.Content- Portal + Positioner + Popup with scroll arrows. showScrollArrows, positionerProps.
Select.Item- value, icon, onClick.
Select.Value / Icon- Base UI parts for custom triggers.
Props added on top of the underlying element or Base UI part.
| Prop | Type | Default | Description |
|---|---|---|---|
items | Array<SelectItemType> | — | label, value, optional icon. Drives the trigger label and width. |
autoWidth | boolean | true | Size the trigger to the widest label. |
minWidth | string | — | Explicit trigger min-width; disables autoWidth. |
className | string | — | Applied to the column wrapping the trigger, not the trigger. |
- Captioning is Field’s job. Select has no Label of its own: it was bare text above the trigger that associated with nothing, so clicking it focused nothing and a screen reader never connected the two.
- Every Base UI Select.Root prop passes through: value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, disabled, readOnly, required, name, multiple, modal.
- Trigger, Input and Combobox.Input share one controlShellVariants shell, so they cannot drift on height, padding, radius or any state.
- items is read from context by Trigger, so a custom trigger built from Select.Value gets the same labels without repeating the list.