Skip to content

Select

A trigger and popup list for choosing one value from a known set.

Status
Stable
Category
Selection
Adoption
Not used yet
import { Select } from "@oration/canon/components/select";
packages/canon/src/components/select.tsx

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

Tags inside rows and in the trigger are fine when the values are select options such as tier or stage. The ten hues never color-code sections, statuses or actions.

Every field has a label

A select always has a visible label linked by id, or an aria-label in a toolbar where the value explains itself. The placeholder is never the label.

Anatomy#

Net 30
Standard terms
Net 15
Net 30
Net 45
Due on receipt
  1. Trigger. A 32px native button (28px at sm) with 10px corners, a 1px Field Stroke, 10px left and 8px right padding, and 14px text.
  2. Value. SelectValue shows the chosen item's label when the root has items, the placeholder in Slate Meta when empty, or whatever its children function returns.
  3. Chevron. A 16px chevron in Slate Meta, drawn by the trigger.
  4. 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.
  5. Group label. SelectLabel inside a SelectGroup: 12px Slate Meta. The group adds the 4px inset around its rows.
  6. Item. An 8px-corner row, 14px text, with icons at 16px. It fills Menu Hover when highlighted.
  7. Check. The item indicator, a 16px check 8px from the right edge, shown on the selected item only.
  8. 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>    );}

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.

4 of 4
  • 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#

Rest
Focus
Placeholder
Invalid
Disabled
Due on receiptRest
Net 15Highlighted
Net 30
Net 90Disabled

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>    );}
States
StateTreatment
RestTransparent fill and a Field Stroke border; 30% input fill in dark.
HoverDark theme only: the fill deepens to 50% input. Light has no hover change.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%.
OpenThe popup is showing and aria-expanded is true. The trigger itself does not change.
PlaceholderWith no value, data-placeholder sets the value text to Slate Meta.
Highlighted itemThe item under the pointer or keyboard highlight fills Menu Hover (--accent).
Selected itemShows the check at the right. The row is not tinted.
Disableddisabled on the root dims the trigger to 50% with a not-allowed cursor. Disabled items dim to 50% and are skipped by the keyboard.
Invalidaria-invalid on the trigger draws a red border and a 3px red ring at 20%.
Read-onlyreadOnly 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, and side and align are ignored while it is aligned.
  • Set alignItemWithTrigger={false} to open below the trigger at a 4px offset, centered unless you pass align="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 value and onValueChange, or uncontrolled with defaultValue. Pass items so SelectValue shows labels instead of raw values; onValueChange can report null, so guard it.
  • With multiple, value is an array and the popup stays open while people toggle rows. Format the trigger with a children function on SelectValue.
  • name posts a hidden input with forms; required and readOnly work 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.

Which select to use
ComponentReach for it whenList lengthRows 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 15Anything: icons, tags, two-line rows
Option selectA flat list of strings or { value, label } pairs in a dense inspector, run bar or toolbar. One line, full width, 13px.2 to about 15Text
Select fieldA 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 15Text
Native selectThe platform picker is the better control: phone-first forms, or a plain list that must post with a native form.Any, plain labelsText, grouped by optgroup
ComboboxPeople, suppliers, invoices or any list long enough to search, or several values shown as removable chips.About 15 or more, or unknownAnything; filters as you type
Timezone selectA time zone. Always, instead of a hand-written list of zones.Every IANA zoneCity, zone, offset and local time

Do and don't#

Do. Give the select a visible label linked with id, and use the placeholder for an empty value such as Choose terms.
Don't. Put the label in the placeholder. It disappears the moment a value is chosen.
Do. Use a dropdown menu button for actions such as Export CSV or Add to payment run.
Don't. Use a select as an action menu. A select stores a value, and screen readers announce it as one.

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" and aria-expanded; the popup is a listbox of option rows. Base UI moves focus into the list and back to the trigger.
  • Name it with a <Label htmlFor> pointing at the trigger id, or aria-label on SelectTrigger. The Canon wrapper does not export Base UI's Select.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 alignItemWithTrigger is off.
Keyboard interactions
KeysAction
EnterOn the trigger, opens the list. On a row, selects it and closes the list.
SpaceSame as Enter.
↓↑On the trigger, opens the list. In the list, moves the highlight, skipping disabled rows.
HomeEndHighlights the first or last row.
A–ZClosed: selects the next matching row. Open: highlights it.
EscCloses the list without changing the value.
TabCloses the list and moves focus on.

Design tokens#

Design tokens
TokenUsed for
--inputTrigger stroke; 30% and 50% fill in dark
--muted-foregroundPlaceholder, chevron, group labels
--ringFocus border and 3px ring at 50%
--destructiveInvalid border and ring at 20%
--popoverPopup background
--accentHighlighted row
--borderSeparator
shadow-mdThe overlay shadow, with a 1px ring of ink at 10%
--radius-lg10px trigger and popup corners
--radius-md8px 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.

Props of Select
PropTypeDefaultDescription
valueValue | Value[] | nullNo defaultThe selected value. Use with onValueChange.
defaultValueValue | Value[] | nullNo defaultThe initial value when uncontrolled.
onValueChange(value: Value | Value[] | null, details) => voidNo defaultCalled when the value changes. Can report null.
items{ value; label: ReactNode }[] | Record<string, ReactNode> | Group[]No defaultThe options, so SelectValue can render the label of the selected value.
multiplebooleanfalseLets people select several rows; value becomes an array.
openbooleanNo defaultControls the popup. Pair with onOpenChange.
onOpenChange(open: boolean, details) => voidNo defaultCalled when the popup opens or closes.
modalbooleantrueLocks page scroll and blocks outside pointer events while open.
disabledbooleanfalseDims the trigger and ignores interaction.
readOnlybooleanfalseOpens the list but prevents a new choice.
requiredbooleanfalseRequires a value before a form submits.
namestringNo defaultPosts the value with a form through a hidden input.
itemToStringLabel(value: Value) => stringNo defaultLabel for object values when they aren't { value, label }.
isItemEqualToValue(item: Value, value: Value) => booleanNo defaultCustom 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>).

Props of SelectTrigger
PropTypeDefaultDescription
size"sm" | "default""default"32px, or 28px with 8px corners for toolbars.
idstringNo defaultPoint a <Label htmlFor> at it to name the select.
aria-invalidbooleanNo defaultDraws the red border and ring.
classNamestringNo defaultMerged 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>).

Props of SelectValue
PropTypeDefaultDescription
placeholderReactNodeNo defaultShown in Slate Meta when there is no value.
childrenReactNode | (value) => ReactNodeNo defaultReplaces 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.

Props of SelectContent
PropTypeDefaultDescription
alignItemWithTriggerbooleantrueOverlaps 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.
sideOffsetnumber4Gap from the trigger in pixels.
align"start" | "center" | "end""center"Alignment against the trigger when not aligned with it.
alignOffsetnumber0Shift along the alignment axis.

SelectItem

One option. Children render inside Select.ItemText, followed by the check.

Other props spread onto Base UI Select.Item.

Props of SelectItem
PropTypeDefaultDescription
valueRequiredValueNo defaultThe value this row selects.
disabledbooleanfalseDims the row and skips it in keyboard navigation.
labelstringNo defaultText 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.