Native select
The platform select, styled to match, for plain lists on phones and native forms.
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
Label at the select's id. An empty first option such as Choose a destination is a prompt, never the label.Selects match inputs
Anatomy#
- Wrapper. A relative
w-fitbox that receivesclassName, dims to 50% when the select is disabled and positions the chevron. - Select. The native
<select>,appearance-none, 32px (28px atsm), 10px corners, a 1px Field Stroke, 10px left and 32px right padding, 14px text. - Selected option. The browser draws the chosen option's text. An empty-value first option acts as the prompt.
- Chevron. A 16px chevron in Slate Meta, 10px from the right edge,
pointer-events-noneso 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#
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> );}| State | Treatment |
|---|---|
| Rest | Transparent fill and the Field Stroke. In dark, the input color at 30%. |
| Hover | No change in light. In dark, the fill rises to the input color at 50%. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50%. |
| Open | The platform draws the list. Options use the Canvas and CanvasText system colors so they stay legible in either theme. |
| Invalid | With aria-invalid, a red border and a 3px red ring at 20% (40% in dark). |
| Disabled | The wrapper drops to 50% opacity and the select takes no pointer events. |
Behavior#
- Controlled with
valueandonChange, readingevent.target.value; uncontrolled withdefaultValue. 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 toundefinedin 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.
classNamegoes to the wrapper. PassclassName="w-full"to fill a column; reach the<select>itself with a descendant selector such as[&>select]:text-[13px].- The
sizeprop is the Canon height ("default"or"sm"), replacing the HTMLsizeattribute.multipleisn'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.
| Component | Reach for it when | List length | Rows show |
|---|---|---|---|
| Select | 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 (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 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#
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
htmlForandid; link a hint or error witharia-describedby. - Set
aria-invalidwhen 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.
| Keys | Action |
|---|---|
| Tab | Moves focus to the select. |
| ↑↓ | Changes the value in place on Windows and Linux; opens the list on macOS. |
| Space | Opens the list. Alt + ↓ also opens it on Windows. |
| A–Z | Jumps to the next option starting with that letter. |
| Enter | Chooses the highlighted option while the list is open. |
| Esc | Closes the list without changing the value. |
Design tokens#
| Token | Used for |
|---|---|
--input | Field Stroke; 30% and 50% fills in dark |
--ring | Focus border and 3px ring at 50% |
--destructive | Invalid border and 3px ring at 20% (40% in dark) |
--muted-foreground | Chevron |
--radius-lg | 10px corners; 8px at sm |
Canvas / CanvasText | System 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).
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "default" | 32px, or 28px with 8px corners for dense panels. |
value | string | No default | Controlled value. Pair with onChange. |
defaultValue | string | No default | Initial value when uncontrolled. |
onChange | (event: React.ChangeEvent<HTMLSelectElement>) => void | No default | Read the new value from event.target.value. |
disabled | boolean | false | Dims the wrapper to 50% and blocks interaction. |
aria-invalid | boolean | No default | Draws the red border and ring. |
className | string | No default | Applied to the wrapper. Use it for width ("w-full") and descendant selectors. |
NativeSelectOption
An <option> in system colors.
Other props spread onto <option>.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The submitted value. Use "" for the prompt option. |
disabled | boolean | No default | Shown but not choosable. |
NativeSelectOptGroup
An <optgroup> in system colors.
Other props spread onto <optgroup>.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | The 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.