Select
A trigger and popup list for choosing one value from a known set.
Used for Northwind Freight in every payment run.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import { toast } from "@oration/canon/components/toast";import { ArrowLeftRightIcon, CreditCardIcon, LandmarkIcon, MailIcon } from "lucide-react";import * as React from "react";export function Hero() { const id = React.useId(); const methods = [ { value: "ach", label: "ACH transfer", icon: LandmarkIcon }, { value: "wire", label: "Wire transfer", icon: ArrowLeftRightIcon }, { value: "card", label: "Virtual card", icon: CreditCardIcon }, { value: "check", label: "Paper check", icon: MailIcon }, ]; const [method, setMethod] = React.useState("ach"); return ( <div className="flex w-full max-w-xl flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border sm:flex-row sm:items-center sm:justify-between"> <div className="min-w-0"> <Label htmlFor={id}>Default payment method</Label> <p className="mt-1.5 text-13 text-muted-foreground"> Used for Northwind Freight in every payment run. </p> </div> <Select items={methods} value={method} onValueChange={(next) => { if (!next) return; setMethod(next); toast.add({ type: "success", title: "Payment method updated", description: `Northwind Freight will be paid by ${methods.find((m) => m.value === next)?.label.toLowerCase()}.`, }); }} > <SelectTrigger id={id} className="w-full sm:w-52"> <SelectValue> {(value: string) => { const option = methods.find( (m) => m.value === value, ); if (!option) return null; return ( <> <option.icon aria-hidden="true" className="text-muted-foreground" /> {option.label} </> ); }} </SelectValue> </SelectTrigger> <SelectContent> <SelectGroup> {methods.map((option) => ( <SelectItem key={option.value} value={option.value}> <option.icon aria-hidden="true" className="text-muted-foreground" /> {option.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> );}Usage#
Select is the composable picker for one value, or a few, from a short known list: a trigger that shows the current value and a popup list built on Base UI Select. It is the base that Option select, Select field and most settings pickers wrap, so reach for it directly only when rows need icons, tags, groups or a formatted trigger. The common mistake is using it for lists people need to search, such as suppliers or teammates; past about 15 options it becomes a Combobox.
When to use
- To choose one value from a short, known list: payment terms, currency, run day, remittance format.
- When rows need more than text: an icon per payment method, a tag per tier, or a group label per account type.
- When the trigger should show a formatted value, such as the chosen tag or Priya Raman and 2 more.
- For a few values at once from a short list with
multiple, when chips in a field would be too heavy. - In toolbars at
size="sm"for view settings such as status and sort.
When not to use
- For a flat list of plain labels in an inspector or toolbar. The one-line wrapper does it in one element. Use Option select
- For a settings row, config page or sheet form field with a typed value. Use Select field
- For people, suppliers, invoices or any list long enough to search. Use Combobox
- For two to five modes of a view that should all stay visible. Use Segmented control
- For a list of actions such as Export or Delete. Actions belong in a menu. Use Dropdown menu
- For a time zone. Use Timezone select
The Option Hue Rule
Every field has a label
id, or an aria-label in a toolbar where the value explains itself. The placeholder is never the label.Anatomy#
- Trigger. A 32px native button (28px at
sm) with 10px corners, a 1px Field Stroke, 10px left and 8px right padding, and 14px text. - Value.
SelectValueshows the chosen item's label when the root hasitems, the placeholder in Slate Meta when empty, or whatever its children function returns. - Chevron. A 16px chevron in Slate Meta, drawn by the trigger.
- Popup. Popover White with 10px corners, the overlay shadow and a 1px ink ring at 10%. It matches the trigger width, never narrower than 9rem, and scrolls within the available height.
- Group label.
SelectLabelinside aSelectGroup: 12px Slate Meta. The group adds the 4px inset around its rows. - Item. An 8px-corner row, 14px text, with icons at 16px. It fills Menu Hover when highlighted.
- Check. The item indicator, a 16px check 8px from the right edge, shown on the selected item only.
- Separator.
SelectSeparator, a Hairline that runs edge to edge between groups.
Examples#
Label and placeholder
Pass items to the root so the trigger shows labels, point a Label at the trigger's id, and give SelectValue a placeholder for the empty state.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function LabelAndPlaceholder() { const id = React.useId(); const terms = [ { value: "receipt", label: "Due on receipt" }, { value: "net15", label: "Net 15" }, { value: "net30", label: "Net 30" }, { value: "net45", label: "Net 45" }, { value: "net60", label: "Net 60" }, ]; const [value, setValue] = React.useState<string | null>(null); return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Label htmlFor={id}>Payment terms</Label> <Select items={terms} value={value} onValueChange={(next) => { setValue(next); toast.add({ title: "Payment terms set", description: `Halcyon invoices are now ${terms.find((t) => t.value === next)?.label}.`, }); }} > <SelectTrigger id={id} className="w-full"> <SelectValue placeholder="Choose terms" /> </SelectTrigger> <SelectContent> <SelectGroup> {terms.map((term) => ( <SelectItem key={term.value} value={term.value}> {term.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> );}Sizes
Default is 32px, the control height across the suite. sm is 28px with 8px corners, for toolbars and dense panels; pass text-[13px] to match their type.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import * as React from "react";export function Sizes() { const defaultId = React.useId(); const smallId = React.useId(); const [currency, setCurrency] = React.useState("usd"); const [period, setPeriod] = React.useState("30d"); const currencies = [ { value: "usd", label: "US dollar" }, { value: "cad", label: "Canadian dollar" }, { value: "eur", label: "Euro" }, ]; const periods = [ { value: "7d", label: "Last 7 days" }, { value: "30d", label: "Last 30 days" }, { value: "qtd", label: "Quarter to date" }, ]; return ( <div className="flex flex-wrap items-end gap-6"> <div className="flex flex-col gap-2"> <Label htmlFor={defaultId}>Currency</Label> <Select items={currencies} value={currency} onValueChange={(next) => next && setCurrency(next)} > <SelectTrigger id={defaultId} className="w-44"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectGroup> {currencies.map((option) => ( <SelectItem key={option.value} value={option.value} > {option.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> <div className="flex flex-col gap-2"> <Label htmlFor={smallId} className="text-13"> Period </Label> <Select items={periods} value={period} onValueChange={(next) => next && setPeriod(next)} > <SelectTrigger id={smallId} size="sm" className="w-40 text-[13px]" > <SelectValue /> </SelectTrigger> <SelectContent> <SelectGroup> {periods.map((option) => ( <SelectItem key={option.value} value={option.value} className="text-[13px]" > {option.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> </div> );}Groups, labels and separators
Group related rows under a SelectLabel and split groups with a SelectSeparator, as the chart of accounts does.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function GroupsAndSeparators() { const id = React.useId(); const groups = [ { label: "Operating expenses", accounts: [ { value: "6100", label: "6100 Freight and shipping" }, { value: "6200", label: "6200 Software subscriptions" }, { value: "6300", label: "6300 Office supplies" }, ], }, { label: "Cost of goods sold", accounts: [ { value: "5000", label: "5000 Raw materials" }, { value: "5100", label: "5100 Packaging" }, ], }, ]; const items = groups.flatMap((group) => group.accounts); const [account, setAccount] = React.useState("6100"); return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Label htmlFor={id}>GL account</Label> <Select items={items} value={account} onValueChange={(next) => { if (!next) return; setAccount(next); toast.add({ title: "Line coded", description: `INV-20931 line 1 now posts to ${items.find((i) => i.value === next)?.label}.`, }); }} > <SelectTrigger id={id} className="w-full tabular-nums"> <SelectValue /> </SelectTrigger> <SelectContent> {groups.map((group, index) => ( <React.Fragment key={group.label}> {index > 0 ? <SelectSeparator /> : null} <SelectGroup> <SelectLabel>{group.label}</SelectLabel> {group.accounts.map((option) => ( <SelectItem key={option.value} value={option.value} className="tabular-nums" > {option.label} </SelectItem> ))} </SelectGroup> </React.Fragment> ))} </SelectContent> </Select> </div> );}Tags as values
Option values such as tier or stage render as tags in the rows and in the trigger, through a children function on SelectValue.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function TagValues() { const id = React.useId(); const tiers: { value: string; label: string; color: TagColor }[] = [ { value: "strategic", label: "Strategic", color: "violet" }, { value: "preferred", label: "Preferred", color: "teal" }, { value: "standard", label: "Standard", color: "gray" }, { value: "probation", label: "Probation", color: "orange" }, ]; const [tier, setTier] = React.useState("preferred"); return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Label htmlFor={id}>Supplier tier</Label> <Select items={tiers} value={tier} onValueChange={(next) => { if (!next) return; setTier(next); toast.add({ title: "Tier changed", description: `Orchard Street is now ${tiers.find((t) => t.value === next)?.label}.`, }); }} > <SelectTrigger id={id} className="w-full"> <SelectValue> {(value: string) => { const option = tiers.find((t) => t.value === value); return option ? ( <Tag color={option.color}>{option.label}</Tag> ) : null; }} </SelectValue> </SelectTrigger> <SelectContent> <SelectGroup> {tiers.map((option) => ( <SelectItem key={option.value} value={option.value}> <Tag color={option.color}>{option.label}</Tag> </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> );}Several values
With multiple, the list stays open while people toggle rows and the trigger summarizes the choice. Past a handful of values, use Combobox with chips.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Multiple() { const id = React.useId(); const people = [ { value: "maya", label: "Maya Okafor" }, { value: "priya", label: "Priya Raman" }, { value: "tomas", label: "Tomás Ferreira" }, { value: "jordan", label: "Jordan Lee" }, { value: "aisha", label: "Aisha Bello" }, ]; const [notify, setNotify] = React.useState<string[]>(["priya", "jordan"]); const nameOf = (value: string) => people.find((p) => p.value === value)?.label ?? value; return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Label htmlFor={id}>Notify when a run is approved</Label> <Select multiple items={people} value={notify} onValueChange={(next) => setNotify(next)} onOpenChange={(open) => { if (!open) toast.add({ title: "Notifications saved", description: `${notify.length} people hear about approved runs.`, }); }} > <SelectTrigger id={id} className="w-full"> <SelectValue placeholder="Nobody"> {(value: string[]) => { const [first, ...rest] = value; if (!first) return "Nobody"; return rest.length ? `${nameOf(first)} and ${rest.length} more` : nameOf(first); }} </SelectValue> </SelectTrigger> <SelectContent alignItemWithTrigger={false}> <SelectGroup> {people.map((person) => ( <SelectItem key={person.value} value={person.value}> {person.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> );}Popup position
By default the popup overlaps the trigger so the chosen row lines up with the value. alignItemWithTrigger={false} opens it below instead, which suits toolbars and multiple selection.
import { Label } from "@oration/canon/components/label";import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import * as React from "react";export function PopupPosition() { const alignedId = React.useId(); const belowId = React.useId(); const days = [ { value: "mon", label: "Monday" }, { value: "tue", label: "Tuesday" }, { value: "wed", label: "Wednesday" }, { value: "thu", label: "Thursday" }, { value: "fri", label: "Friday" }, ]; const [aligned, setAligned] = React.useState("thu"); const [below, setBelow] = React.useState("thu"); return ( <div className="flex flex-wrap items-start gap-6"> <div className="flex flex-col gap-2"> <Label htmlFor={alignedId}>Run day, aligned</Label> <Select items={days} value={aligned} onValueChange={(next) => next && setAligned(next)} > <SelectTrigger id={alignedId} className="w-44"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectGroup> {days.map((day) => ( <SelectItem key={day.value} value={day.value}> {day.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> <div className="flex flex-col gap-2"> <Label htmlFor={belowId}>Run day, below</Label> <Select items={days} value={below} onValueChange={(next) => next && setBelow(next)} > <SelectTrigger id={belowId} className="w-44"> <SelectValue /> </SelectTrigger> <SelectContent alignItemWithTrigger={false} align="start"> <SelectGroup> {days.map((day) => ( <SelectItem key={day.value} value={day.value}> {day.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> </div> </div> );}In a toolbar
Small selects above an invoice list filter and sort it in place. Their labels are screen-reader only because each value names itself.
- INV-20931Northwind Freight$18,240.00
- INV-20918Orchard Street$9,875.00
- INV-20927Halcyon$4,310.50
- INV-20902Brightline Freight$1,260.00
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,} from "@oration/canon/components/select";import * as React from "react";export function InAToolbar() { const statusId = React.useId(); const sortId = React.useId(); const invoices = [ { id: "INV-20931", supplier: "Northwind Freight", amount: 18240, status: "open", }, { id: "INV-20927", supplier: "Halcyon", amount: 4310.5, status: "paid", }, { id: "INV-20918", supplier: "Orchard Street", amount: 9875, status: "open", }, { id: "INV-20902", supplier: "Brightline Freight", amount: 1260, status: "overdue", }, ]; const statuses = [ { value: "all", label: "All statuses" }, { value: "open", label: "Open" }, { value: "overdue", label: "Overdue" }, { value: "paid", label: "Paid" }, ]; const sorts = [ { value: "amount", label: "Amount, high to low" }, { value: "supplier", label: "Supplier, A to Z" }, ]; const [status, setStatus] = React.useState("all"); const [sort, setSort] = React.useState("amount"); const rows = invoices .filter((invoice) => status === "all" || invoice.status === status) .sort((a, b) => sort === "amount" ? b.amount - a.amount : a.supplier.localeCompare(b.supplier), ); return ( <div className="w-full max-w-xl overflow-hidden rounded-xl bg-card text-left shadow-border"> <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2"> <label htmlFor={statusId} className="sr-only"> Status </label> <Select items={statuses} value={status} onValueChange={(next) => next && setStatus(next)} > <SelectTrigger id={statusId} size="sm" className="text-[13px]" > <SelectValue /> </SelectTrigger> <SelectContent alignItemWithTrigger={false} align="start"> <SelectGroup> {statuses.map((option) => ( <SelectItem key={option.value} value={option.value} className="text-[13px]" > {option.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> <label htmlFor={sortId} className="sr-only"> Sort by </label> <Select items={sorts} value={sort} onValueChange={(next) => next && setSort(next)} > <SelectTrigger id={sortId} size="sm" className="text-[13px]" > <SelectValue /> </SelectTrigger> <SelectContent alignItemWithTrigger={false} align="start"> <SelectGroup> {sorts.map((option) => ( <SelectItem key={option.value} value={option.value} className="text-[13px]" > {option.label} </SelectItem> ))} </SelectGroup> </SelectContent> </Select> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {rows.length} of {invoices.length} </span> </div> <ul className="divide-y divide-border"> {rows.map((invoice) => ( <li key={invoice.id} className="flex h-9 items-center gap-3 px-3 text-13" > <span className="w-24 shrink-0 text-muted-foreground tabular-nums"> {invoice.id} </span> <span className="min-w-0 flex-1 truncate"> {invoice.supplier} </span> <span className="font-medium tabular-nums"> {invoice.amount.toLocaleString("en-US", { style: "currency", currency: "USD", })} </span> </li> ))} </ul> </div> );}States#
Rows fill Menu Hover when highlighted by pointer or keyboard. The selected row carries the check; disabled rows drop to 50%.
import { Select, SelectTrigger, SelectValue } from "@oration/canon/components/select";import { cn } from "@oration/canon/lib/utils";import { CheckIcon } from "lucide-react";export function StatesMatrix() { const terms = [ { value: "net30", label: "Net 30" }, { value: "net45", label: "Net 45" }, ]; const triggers = [ { state: "Rest", value: "net30", className: "" }, { state: "Focus", value: "net30", className: "border-ring ring-3 ring-ring/50", }, { state: "Placeholder", value: null, className: "" }, { state: "Invalid", value: null, className: "", invalid: true }, { state: "Disabled", value: "net30", className: "", disabled: true }, ]; const item = "relative flex items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm"; return ( <div className="flex w-full flex-col items-center gap-8" inert> <div className="grid w-full grid-cols-2 gap-x-3 gap-y-4 sm:grid-cols-5"> {triggers.map((trigger) => ( <div key={trigger.state} className="flex flex-col items-center gap-2" > <Select items={terms} defaultValue={trigger.value} disabled={trigger.disabled} > <SelectTrigger tabIndex={-1} aria-label={`Payment terms, ${trigger.state.toLowerCase()}`} aria-invalid={trigger.invalid || undefined} className={cn( "w-full max-w-36", trigger.className, )} > <SelectValue placeholder="Choose terms" /> </SelectTrigger> </Select> <span className="text-xs text-muted-foreground"> {trigger.state} </span> </div> ))} </div> <div className="flex flex-wrap items-start justify-center gap-6"> <div className="w-52 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10"> <div className={item}> Due on receipt <span className="absolute right-2 text-xs text-muted-foreground"> Rest </span> </div> <div className={cn(item, "bg-accent")}> Net 15 <span className="absolute right-2 text-xs text-muted-foreground"> Highlighted </span> </div> <div className={item}> Net 30 <span className="absolute right-2 flex size-4 items-center justify-center"> <CheckIcon aria-hidden="true" className="size-4" /> </span> </div> <div className={cn(item, "opacity-50")}> Net 90 <span className="absolute right-2 text-xs"> Disabled </span> </div> </div> <p className="max-w-56 text-13 text-muted-foreground"> Rows fill Menu Hover when highlighted by pointer or keyboard. The selected row carries the check; disabled rows drop to 50%. </p> </div> </div> );}| State | Treatment |
|---|---|
| Rest | Transparent fill and a Field Stroke border; 30% input fill in dark. |
| Hover | Dark theme only: the fill deepens to 50% input. Light has no hover change. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50%. |
| Open | The popup is showing and aria-expanded is true. The trigger itself does not change. |
| Placeholder | With no value, data-placeholder sets the value text to Slate Meta. |
| Highlighted item | The item under the pointer or keyboard highlight fills Menu Hover (--accent). |
| Selected item | Shows the check at the right. The row is not tinted. |
| Disabled | disabled on the root dims the trigger to 50% with a not-allowed cursor. Disabled items dim to 50% and are skipped by the keyboard. |
| Invalid | aria-invalid on the trigger draws a red border and a 3px red ring at 20%. |
| Read-only | readOnly on the root opens the list but blocks a new choice. It is not styled; say so in the description. |
Behavior#
- Clicking the trigger, or Enter, Space, Down or Up on it, opens the popup with the selected item highlighted. Choosing an item closes it and focus returns to the trigger.
- By default (
alignItemWithTrigger) the popup overlaps the trigger so the selected row sits exactly over the value. It falls back to opening below when space is short or when it was opened by touch, andsideandalignare ignored while it is aligned. - Set
alignItemWithTrigger={false}to open below the trigger at a 4px offset, centered unless you passalign="start". Only this mode animates: 100ms fade and zoom from 95%. - Typing on a closed trigger picks the next matching item, like a native select. Typing in an open list moves the highlight only. There is no filtering; that is Combobox.
- The select is modal while open: page scroll locks and outside clicks only close it. Scroll arrows appear at the top and bottom when the list overflows.
- Controlled with
valueandonValueChange, or uncontrolled withdefaultValue. PassitemssoSelectValueshows labels instead of raw values;onValueChangecan reportnull, so guard it. - With
multiple,valueis an array and the popup stays open while people toggle rows. Format the trigger with a children function onSelectValue. nameposts a hidden input with forms;requiredandreadOnlywork as on native fields.
Choosing a select#
Six components pick a value from a list. A time zone is always Timezone select. Past about 15 options, or for several values, use Combobox. Otherwise decide by what a row has to show and where the control sits.
| Component | Reach for it when | List length | Rows show |
|---|---|---|---|
| Select (this page) | Rows need icons, tags, groups or separators, the trigger shows a formatted value, or people pick several values from a short list. You compose the parts. | 2 to about 15 | Anything: icons, tags, two-line rows |
| Option select | A flat list of strings or { value, label } pairs in a dense inspector, run bar or toolbar. One line, full width, 13px. | 2 to about 15 | Text |
| Select field | A typed value in a settings row, agent config page, sheet form or filter bar. The preset sets the width and height for that surface. | 2 to about 15 | Text |
| Native select | The platform picker is the better control: phone-first forms, or a plain list that must post with a native form. | Any, plain labels | Text, grouped by optgroup |
| Combobox | People, suppliers, invoices or any list long enough to search, or several values shown as removable chips. | About 15 or more, or unknown | Anything; filters as you type |
| Timezone select | A time zone. Always, instead of a hand-written list of zones. | Every IANA zone | City, zone, offset and local time |
Do and don't#
id, and use the placeholder for an empty value such as Choose terms.Content#
- Label the field with a noun for the value: Payment terms, GL account, Supplier tier. Not Select terms.
- Option labels are sentence case, short and parallel: Net 15, Net 30, Due on receipt.
- Placeholders name the action on an empty field: Choose terms, Nobody. Never Select… or Please select.
- Put the most common or recommended option first, or keep a natural order (days, amounts) rather than alphabetical.
- Group labels are plain nouns, not questions: Operating expenses.
- Format several values as Priya Raman and 2 more, not a comma list that truncates.
Accessibility#
- The trigger is a native button with
role="combobox",aria-haspopup="listbox"andaria-expanded; the popup is alistboxofoptionrows. Base UI moves focus into the list and back to the trigger. - Name it with a
<Label htmlFor>pointing at the triggerid, oraria-labelonSelectTrigger. The Canon wrapper does not export Base UI'sSelect.Label. - Icons inside rows are decorative: mark them
aria-hidden. The row's text is its name. - Tags in rows are read as their text, so the label must stand alone without its hue.
- Rows are 28px tall, above the 24px minimum. On touch the popup opens below instead of overlapping.
- The popup's zoom is a short CSS animation; it only runs when
alignItemWithTriggeris off.
| Keys | Action |
|---|---|
| Enter | On the trigger, opens the list. On a row, selects it and closes the list. |
| Space | Same as Enter. |
| ↓↑ | On the trigger, opens the list. In the list, moves the highlight, skipping disabled rows. |
| HomeEnd | Highlights the first or last row. |
| A–Z | Closed: selects the next matching row. Open: highlights it. |
| Esc | Closes the list without changing the value. |
| Tab | Closes the list and moves focus on. |
Design tokens#
| Token | Used for |
|---|---|
--input | Trigger stroke; 30% and 50% fill in dark |
--muted-foreground | Placeholder, chevron, group labels |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid border and ring at 20% |
--popover | Popup background |
--accent | Highlighted row |
--border | Separator |
shadow-md | The overlay shadow, with a 1px ring of ink at 10% |
--radius-lg | 10px trigger and popup corners |
--radius-md | 8px row corners and the sm trigger |
API reference#
Select
The root. Holds the value and open state; renders no element. It is Base UI Select.Root as is.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Value | Value[] | null | No default | The selected value. Use with onValueChange. |
defaultValue | Value | Value[] | null | No default | The initial value when uncontrolled. |
onValueChange | (value: Value | Value[] | null, details) => void | No default | Called when the value changes. Can report null. |
items | { value; label: ReactNode }[] | Record<string, ReactNode> | Group[] | No default | The options, so SelectValue can render the label of the selected value. |
multiple | boolean | false | Lets people select several rows; value becomes an array. |
open | boolean | No default | Controls the popup. Pair with onOpenChange. |
onOpenChange | (open: boolean, details) => void | No default | Called when the popup opens or closes. |
modal | boolean | true | Locks page scroll and blocks outside pointer events while open. |
disabled | boolean | false | Dims the trigger and ignores interaction. |
readOnly | boolean | false | Opens the list but prevents a new choice. |
required | boolean | false | Requires a value before a form submits. |
name | string | No default | Posts the value with a form through a hidden input. |
itemToStringLabel | (value: Value) => string | No default | Label for object values when they aren't { value, label }. |
isItemEqualToValue | (item: Value, value: Value) => boolean | No default | Custom equality for object values. Defaults to Object.is. |
SelectTrigger
The button that shows the value and opens the list. Draws its own chevron.
Other props spread onto Base UI Select.Trigger (native <button>).
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "default" | 32px, or 28px with 8px corners for toolbars. |
id | string | No default | Point a <Label htmlFor> at it to name the select. |
aria-invalid | boolean | No default | Draws the red border and ring. |
className | string | No default | Merged after the base classes. Set the width here: the trigger is w-fit by default. |
SelectValue
The current value inside the trigger.
Other props spread onto Base UI Select.Value (<span>).
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | ReactNode | No default | Shown in Slate Meta when there is no value. |
children | ReactNode | (value) => ReactNode | No default | Replaces the default label, for icons, tags or a summary of several values. |
SelectContent
Portal, positioner and popup in one, with scroll arrows and the list inside.
Other props spread onto Base UI Select.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
alignItemWithTrigger | boolean | true | Overlaps the trigger so the selected row sits over the value. false opens below. |
side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "bottom" | Where to open when not aligned with the trigger. |
sideOffset | number | 4 | Gap from the trigger in pixels. |
align | "start" | "center" | "end" | "center" | Alignment against the trigger when not aligned with it. |
alignOffset | number | 0 | Shift along the alignment axis. |
SelectItem
One option. Children render inside Select.ItemText, followed by the check.
Other props spread onto Base UI Select.Item.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | Value | No default | The value this row selects. |
disabled | boolean | false | Dims the row and skips it in keyboard navigation. |
label | string | No default | Text used for typeahead when the children aren't plain text. |
SelectGroup
Groups rows and adds the 4px inset. Wrap rows in one even when there is only one group.
Other props spread onto Base UI Select.Group.
No props of its own.
SelectLabel
The 12px heading of a group.
Other props spread onto Base UI Select.GroupLabel.
No props of its own.
SelectSeparator
A Hairline between groups.
Other props spread onto Base UI Select.Separator.
No props of its own.
SelectScrollUpButton
The arrow that appears at the top of an overflowing list. SelectContent renders it and SelectScrollDownButton for you.
Other props spread onto Base UI Select.ScrollUpArrow.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Rows placed straight in SelectContent sit flush against the popup edge: only SelectGroup adds the 4px inset menus use. Always wrap rows in a group. Option select and Select field don't, so their rows touch the edge.
The trigger has no hover change in light and no open state, while Timezone select draws an indigo border when open. The select family doesn't agree on these two states.
The popup opens in 100ms from a 95% scale, and not at all in the default aligned mode. DESIGN.md asks popovers for 160ms in from 0.97 and 110ms out.
Base UI's Select.Label is not exported, so naming the trigger relies on a separate Label or aria-label.