Skip to content

Native select

The platform select, styled to match, for plain lists on phones and native forms.

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

Transfer call

Lands in the exceptions queue. The agent stays on until someone picks up, then reads the summary.

import { Label } from "@oration/canon/components/label";import { NativeSelect, NativeSelectOption } from "@oration/canon/components/native-select";import * as React from "react";export function Hero() {    const destinations = {        exceptions: {            name: "AP exceptions",            method: "warm",            queue: "exceptions queue",        },        onboarding: {            name: "Vendor onboarding",            method: "cold",            queue: "onboarding queue",        },        treasury: {            name: "Treasury desk",            method: "warm",            queue: "treasury line",        },    } as const;    type Key = keyof typeof destinations;    const id = React.useId();    const [value, setValue] = React.useState<Key | "">("exceptions");    const destination = value ? destinations[value] : null;    return (        <div className="flex w-full max-w-xs flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <p className="text-sm font-medium">Transfer call</p>            <div className="flex flex-col gap-1.5">                <Label htmlFor={id} className="text-13">                    Transfer to                </Label>                <NativeSelect                    id={id}                    value={value}                    onChange={(event) =>                        setValue(event.target.value as Key | "")                    }                    className="w-full"                >                    <NativeSelectOption value="">                        Choose a destination                    </NativeSelectOption>                    {(Object.keys(destinations) as Key[]).map((key) => (                        <NativeSelectOption key={key} value={key}>                            {destinations[key].name}, {destinations[key].method}{" "}                            transfer                        </NativeSelectOption>                    ))}                </NativeSelect>            </div>            {destination ? (                <p className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-xs text-pretty text-muted-foreground">                    Lands in the {destination.queue}.{" "}                    {destination.method === "warm"                        ? "The agent stays on until someone picks up, then reads the summary."                        : "The agent drops off as soon as the call rings through."}                </p>            ) : null}        </div>    );}

Usage#

Native select is the platform <select> dressed to match the other fields: a 32px box with the Field Stroke, 10px corners and a Slate Meta chevron. The list itself is drawn by the browser and the operating system, so it is the right control on phones and in plain forms that post natively, and it is what the workflow step inspectors use today. The thing people get wrong is className: it lands on the wrapper, not the <select>, so width goes there and everything else goes through a descendant selector.

When to use

  • For a short list of plain text labels where the platform picker is the better control, such as a phone-first form.
  • In workflow and procedure inspectors, where each step picks a destination, variable or tool from a short list.
  • When the value must post with a native <form> and no JavaScript state, such as a server action.
  • For a list grouped by optgroup, such as a chart of accounts split into expenses and liabilities.

When not to use

  • When rows need icons, tags, two lines or a formatted trigger. The platform list only draws text. Use Select
  • For a settings row or sheet field with a typed value on desktop. Use Select field
  • For suppliers, people or invoices. Native type-ahead only matches the first letters, so long lists need search. Use Combobox
  • For two to five modes that should all stay visible. Use Segmented control
  • For a time zone. Use Timezone select

Every field has a label

Point a visible Label at the select's id. An empty first option such as Choose a destination is a prompt, never the label.

Selects match inputs

32px tall, 10px corners, a 1px Field Stroke and a transparent fill (input at 30% in dark), so a native select sits in a form column beside inputs without a seam.

Anatomy#

  1. Wrapper. A relative w-fit box that receives className, dims to 50% when the select is disabled and positions the chevron.
  2. Select. The native <select>, appearance-none, 32px (28px at sm), 10px corners, a 1px Field Stroke, 10px left and 32px right padding, 14px text.
  3. Selected option. The browser draws the chosen option's text. An empty-value first option acts as the prompt.
  4. Chevron. A 16px chevron in Slate Meta, 10px from the right edge, pointer-events-none so clicks reach the select.

Examples#

Label and prompt option

Point a Label at the select's id and start the list with a value="" option that says what to do. It reads as the empty state until someone chooses.

import { Label } from "@oration/canon/components/label";import { NativeSelect, NativeSelectOption } from "@oration/canon/components/native-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function LabelAndPrompt() {    const id = React.useId();    const [variable, setVariable] = React.useState("");    return (        <div className="flex w-full max-w-xs flex-col gap-1.5 text-left">            <Label htmlFor={id}>Save the answer to</Label>            <NativeSelect                id={id}                value={variable}                onChange={(event) => {                    setVariable(event.target.value);                    if (event.target.value) {                        toast.add({                            title: "Variable set",                            description: `The answer is saved to ${event.target.value}.`,                        });                    }                }}                className="w-full"            >                <NativeSelectOption value="">                    Choose a variable                </NativeSelectOption>                <NativeSelectOption value="invoice_number">                    invoice_number                </NativeSelectOption>                <NativeSelectOption value="remittance_date">                    remittance_date                </NativeSelectOption>                <NativeSelectOption value="supplier_contact">                    supplier_contact                </NativeSelectOption>            </NativeSelect>        </div>    );}

Sizes

Default is 32px, the control height across the suite. sm is 28px with 8px corners for dense panels; reach the text with [&>select]:text-[13px] because className styles the wrapper.

import { Label } from "@oration/canon/components/label";import { NativeSelect, NativeSelectOption } from "@oration/canon/components/native-select";import * as React from "react";export function Sizes() {    const defaultId = React.useId();    const smallId = React.useId();    return (        <div className="flex flex-wrap items-end gap-4 text-left">            <div className="flex flex-col gap-1.5">                <Label htmlFor={defaultId}>Default</Label>                <NativeSelect                    id={defaultId}                    defaultValue="net30"                    className="w-40"                >                    <NativeSelectOption value="net15">                        Net 15                    </NativeSelectOption>                    <NativeSelectOption value="net30">                        Net 30                    </NativeSelectOption>                    <NativeSelectOption value="net45">                        Net 45                    </NativeSelectOption>                </NativeSelect>            </div>            <div className="flex flex-col gap-1.5">                <Label htmlFor={smallId} className="text-13">                    Small                </Label>                <NativeSelect                    id={smallId}                    size="sm"                    defaultValue="net30"                    className="w-40 [&>select]:text-[13px]"                >                    <NativeSelectOption value="net15">                        Net 15                    </NativeSelectOption>                    <NativeSelectOption value="net30">                        Net 30                    </NativeSelectOption>                    <NativeSelectOption value="net45">                        Net 45                    </NativeSelectOption>                </NativeSelect>            </div>        </div>    );}

Option groups

NativeSelectOptGroup adds headings the platform draws. A disabled option stays visible so people know it exists.

import { Label } from "@oration/canon/components/label";import { NativeSelect, NativeSelectOptGroup, NativeSelectOption } from "@oration/canon/components/native-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function OptionGroups() {    const id = React.useId();    const [account, setAccount] = React.useState("6100");    return (        <div className="flex w-full max-w-xs flex-col gap-1.5 text-left">            <Label htmlFor={id}>GL account</Label>            <NativeSelect                id={id}                value={account}                onChange={(event) => {                    setAccount(event.target.value);                    toast.add({                        title: "GL account changed",                        description: `Invoice INV-20418 now codes to ${event.target.value}.`,                    });                }}                className="w-full"            >                <NativeSelectOptGroup label="Expenses">                    <NativeSelectOption value="6100">                        6100 Freight and shipping                    </NativeSelectOption>                    <NativeSelectOption value="6200">                        6200 Software subscriptions                    </NativeSelectOption>                    <NativeSelectOption value="6300">                        6300 Office supplies                    </NativeSelectOption>                </NativeSelectOptGroup>                <NativeSelectOptGroup label="Liabilities">                    <NativeSelectOption value="2000">                        2000 Accounts payable                    </NativeSelectOption>                    <NativeSelectOption value="2100">                        2100 Accrued expenses                    </NativeSelectOption>                </NativeSelectOptGroup>                <NativeSelectOptGroup label="Assets">                    <NativeSelectOption value="1400" disabled>                        1400 Prepaid expenses, locked for close                    </NativeSelectOption>                </NativeSelectOptGroup>            </NativeSelect>        </div>    );}

Validation

Validate on submit, set aria-invalid, link the message with aria-describedby and move focus to the select. Changing the value clears the error.

import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { NativeSelect, NativeSelectOption } from "@oration/canon/components/native-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Validation() {    const id = React.useId();    const [method, setMethod] = React.useState("");    const [error, setError] = React.useState(false);    return (        <form            className="flex w-full max-w-xs flex-col gap-3 text-left"            onSubmit={(event) => {                event.preventDefault();                if (!method) {                    setError(true);                    document.getElementById(id)?.focus();                    return;                }                toast.add({                    type: "success",                    title: "Payment method saved",                    description:                        "Northwind Freight is paid by the new method from Friday.",                });            }}        >            <div className="flex flex-col gap-1.5">                <Label htmlFor={id}>Payment method</Label>                <NativeSelect                    id={id}                    value={method}                    aria-invalid={error || undefined}                    aria-describedby={error ? `${id}-error` : undefined}                    onChange={(event) => {                        setMethod(event.target.value);                        setError(false);                    }}                    className="w-full"                >                    <NativeSelectOption value="">                        Choose a method                    </NativeSelectOption>                    <NativeSelectOption value="ach">                        ACH transfer                    </NativeSelectOption>                    <NativeSelectOption value="wire">Wire</NativeSelectOption>                    <NativeSelectOption value="check">                        Paper check                    </NativeSelectOption>                </NativeSelect>                {error ? (                    <p id={`${id}-error`} className="text-xs text-destructive">                        Choose how Northwind Freight gets paid.                    </p>                ) : null}            </div>            <Button type="submit" className="self-start">                Save method            </Button>        </form>    );}

States#

Rest
Focus
Invalid
Disabled
import { NativeSelect, NativeSelectOption } from "@oration/canon/components/native-select";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", className: "", invalid: false, disabled: false },        {            name: "Focus",            className:                "[&>select]:border-ring [&>select]:ring-3 [&>select]:ring-ring/50",            invalid: false,            disabled: false,        },        { name: "Invalid", className: "", invalid: true, disabled: false },        { name: "Disabled", className: "", invalid: false, disabled: true },    ];    return (        <div className="grid w-full grid-cols-2 gap-4 sm:grid-cols-4">            {states.map((state) => (                <div key={state.name} className="flex flex-col gap-1.5">                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <NativeSelect                        aria-label={`Payment terms, ${state.name.toLowerCase()}`}                        tabIndex={-1}                        defaultValue="net30"                        aria-invalid={state.invalid || undefined}                        disabled={state.disabled}                        className={cn(                            "pointer-events-none w-full",                            state.className,                        )}                    >                        <NativeSelectOption value="net30">                            Net 30                        </NativeSelectOption>                    </NativeSelect>                </div>            ))}        </div>    );}
States
StateTreatment
RestTransparent fill and the Field Stroke. In dark, the input color at 30%.
HoverNo change in light. In dark, the fill rises to the input color at 50%.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%.
OpenThe platform draws the list. Options use the Canvas and CanvasText system colors so they stay legible in either theme.
InvalidWith aria-invalid, a red border and a 3px red ring at 20% (40% in dark).
DisabledThe wrapper drops to 50% opacity and the select takes no pointer events.

Behavior#

  • Controlled with value and onChange, reading event.target.value; uncontrolled with defaultValue. Every value is a string.
  • For an empty state, make the first option value="" with a prompt such as Choose a destination, as the inspectors do, and map "" back to undefined in the change handler.
  • The open list, its scrolling, type-ahead and the phone wheel picker are the platform's. None of them can be styled.
  • className goes to the wrapper. Pass className="w-full" to fill a column; reach the <select> itself with a descendant selector such as [&>select]:text-[13px].
  • The size prop is the Canon height ("default" or "sm"), replacing the HTML size attribute. multiple isn't supported by the fixed 32px height.

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
SelectRows 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 select (this page)The 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 and start the list with an empty prompt option.
Don't. Use the first option as the label. Once someone picks a value, the label is gone.
Do. Keep native selects to short lists of plain labels.
Don't. Put 60 suppliers in a native list. Type-ahead matches only leading letters, so people scroll.

Content#

  • Labels name the thing being chosen: Transfer to, Variable, GL account.
  • The prompt option starts with a verb: Choose a destination, not Select… or --.
  • Option labels are sentence case and parallel. Add a qualifier after a comma when two options would read the same: AP exceptions, warm transfer.
  • Don't put instructions in options. Put them in a description under the field.

Accessibility#

  • It is a real <select>, so screen readers announce it as a combo box or pop-up button with its value, and the platform handles the list.
  • Link the label with htmlFor and id; link a hint or error with aria-describedby.
  • Set aria-invalid when the value fails validation and write the reason beside the field.
  • On touch devices the platform shows a native picker, which meets hit-size and zoom expectations without extra work.
  • The chevron is aria-hidden.
Keyboard interactions
KeysAction
TabMoves focus to the select.
↑↓Changes the value in place on Windows and Linux; opens the list on macOS.
SpaceOpens the list. Alt + ↓ also opens it on Windows.
A–ZJumps to the next option starting with that letter.
EnterChooses the highlighted option while the list is open.
EscCloses the list without changing the value.

Design tokens#

Design tokens
TokenUsed for
--inputField Stroke; 30% and 50% fills in dark
--ringFocus border and 3px ring at 50%
--destructiveInvalid border and 3px ring at 20% (40% in dark)
--muted-foregroundChevron
--radius-lg10px corners; 8px at sm
Canvas / CanvasTextSystem colors for options and optgroups in the platform list

API reference#

NativeSelect

The wrapper, the <select> and the chevron. className styles the wrapper; every other prop goes to the <select>.

Other props spread onto <select> (except size).

Props of NativeSelect
PropTypeDefaultDescription
size"sm" | "default""default"32px, or 28px with 8px corners for dense panels.
valuestringNo defaultControlled value. Pair with onChange.
defaultValuestringNo defaultInitial value when uncontrolled.
onChange(event: React.ChangeEvent<HTMLSelectElement>) => voidNo defaultRead the new value from event.target.value.
disabledbooleanfalseDims the wrapper to 50% and blocks interaction.
aria-invalidbooleanNo defaultDraws the red border and ring.
classNamestringNo defaultApplied to the wrapper. Use it for width ("w-full") and descendant selectors.

NativeSelectOption

An <option> in system colors.

Other props spread onto <option>.

Props of NativeSelectOption
PropTypeDefaultDescription
valueRequiredstringNo defaultThe submitted value. Use "" for the prompt option.
disabledbooleanNo defaultShown but not choosable.

NativeSelectOptGroup

An <optgroup> in system colors.

Other props spread onto <optgroup>.

Props of NativeSelectOptGroup
PropTypeDefaultDescription
labelRequiredstringNo defaultThe group heading the platform draws.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

className lands on the wrapper, unlike every other field in packages/canon, so className="text-[13px]" doesn't reach the text. Use [&>select]:text-[13px].

The select carries placeholder:text-muted-foreground, which has no effect on a <select>. An empty prompt option renders in full ink, not Slate Meta.

The size prop replaces the HTML size attribute, and the fixed height breaks multiple, so there is no native list box variant.

The registry describes it as for long lists. Long lists belong in Combobox, which can search; native type-ahead only matches leading letters.