Select field
Pre-dressed Select variants for settings, config panels and forms.
Payments
Defaults for new suppliers. Each supplier can override them.
import { SettingsSelect } from "@oration/canon/components/select-field";import { descriptionId, SettingsGroup, SettingsRow, SettingsSection } from "@oration/canon/components/settings-section";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { type Terms = "receipt" | "net15" | "net30" | "net45"; type Format = "pdf" | "csv" | "edi"; const termsId = React.useId(); const formatId = React.useId(); const [terms, setTerms] = React.useState<Terms>("net30"); const [format, setFormat] = React.useState<Format>("pdf"); return ( <div className="w-full max-w-2xl text-left"> <SettingsSection title="Payments" description="Defaults for new suppliers. Each supplier can override them." > <SettingsGroup> <SettingsRow label="Default payment terms" description="When invoices fall due after they're approved." htmlFor={termsId} > <SettingsSelect<Terms> id={termsId} describedBy={descriptionId(termsId)} value={terms} onValueChange={(next) => { setTerms(next); toast.add({ type: "success", title: "Payment terms saved", }); }} options={[ { value: "receipt", label: "Due on receipt" }, { value: "net15", label: "Net 15" }, { value: "net30", label: "Net 30" }, { value: "net45", label: "Net 45" }, ]} /> </SettingsRow> <SettingsRow label="Remittance advice" description="Attached to the email suppliers get when a payment is sent." htmlFor={formatId} > <SettingsSelect<Format> id={formatId} describedBy={descriptionId(formatId)} value={format} onValueChange={(next) => { setFormat(next); toast.add({ type: "success", title: "Remittance format saved", }); }} options={[ { value: "pdf", label: "PDF" }, { value: "csv", label: "CSV" }, { value: "edi", label: "EDI 820" }, ]} /> </SettingsRow> </SettingsGroup> </SettingsSection> </div> );}Usage#
Select field is a typed, controlled Select for plain-text options, with four presets that size it for the surface it sits on: SettingsSelect for settings rows, ConfigSelect for agent config pages, FieldSelect for sheet and dialog forms, and ChoiceSelect for filter bars and rule builders. It is the most used select in the suite: settings pages alone use it in nearly fifty files. The mistake to avoid is picking a preset by name alone: FieldSelect and ChoiceSelect default to the 28px size, so a stacked form field needs size="default".
When to use
- In a settings row, with
SettingsSelecton the trailing side: Default payment terms, Remittance advice. - On an agent config page, with
ConfigSelect: Default language. - In a sheet or dialog form under a stacked label, with
FieldSelectatsize="default": Supplier contact, Queue. - In a filter bar, table header or rule builder, with
ChoiceSelectand an explicit width: Tier, Sort by. - Whenever the value is a string union and you want
onValueChangetyped to it.
When not to use
- In a workflow inspector, run bar or sequence panel, where the one-line picker is already the norm. Use Option select
- When rows need icons, tags or groups, or several values. Use Select
- For people, suppliers, records or any list long enough to search. Use Combobox
- For a setting that is on or off. Use Switch
- For two to four modes where each needs a sentence of explanation. Use Choice card
Every field has a label
htmlFor as the select's id so the row label names it. Use label only where there is no visible label, such as a table cell or filter bar.The Thirteen-Fourteen Rule
text-[13px] through className so the value matches the 13px around it.Anatomy#
- Trigger. A Select trigger whose width and height come from the preset:
SettingsSelectandConfigSelectare full width and 256px from 640px up;FieldSelectis full width;ChoiceSelectshrinks to fit. - Value. The selected option's label, or the placeholder in Slate Meta when
valueis"". - Popup. The Select popup, aligned over the trigger so the selected row sits on the value.
- Row. One per option, text only, filled with Menu Hover when highlighted.
- Check. Marks the selected row.
Examples#
Presets
Four presets over one typed select. Each sets the width and height for a surface: settings rows, agent config pages, stacked forms and inline choices.
SettingsSelect
Full width, 256px from 640px up. 32px.
ConfigSelect
The same classes, for agent config pages.
FieldSelect
Full width at every size. 28px unless you pass a size.
ChoiceSelect
Shrinks to its content. 28px. Set the width.
import { ChoiceSelect, ConfigSelect, FieldSelect, SettingsSelect } from "@oration/canon/components/select-field";import * as React from "react";export function Presets() { const levels = [ { value: "standard", label: "Standard review" }, { value: "two", label: "Two approvers" }, { value: "cfo", label: "CFO sign-off" }, ] as const; type Level = (typeof levels)[number]["value"]; const [settings, setSettings] = React.useState<Level>("standard"); const [config, setConfig] = React.useState<Level>("two"); const [field, setField] = React.useState<Level>("standard"); const [choice, setChoice] = React.useState<Level>("cfo"); const rows = [ { name: "SettingsSelect", note: "Full width, 256px from 640px up. 32px.", control: ( <SettingsSelect<Level> label="Approval level, settings" value={settings} onValueChange={setSettings} options={levels} /> ), }, { name: "ConfigSelect", note: "The same classes, for agent config pages.", control: ( <ConfigSelect<Level> label="Approval level, config" value={config} onValueChange={setConfig} options={levels} /> ), }, { name: "FieldSelect", note: "Full width at every size. 28px unless you pass a size.", control: ( <FieldSelect<Level> label="Approval level, field" value={field} onValueChange={setField} options={levels} /> ), }, { name: "ChoiceSelect", note: "Shrinks to its content. 28px. Set the width.", control: ( <ChoiceSelect<Level> label="Approval level, choice" value={choice} onValueChange={setChoice} options={levels} className="w-40" /> ), }, ]; return ( <div className="flex w-full max-w-2xl flex-col divide-y divide-border rounded-xl bg-card text-left shadow-border"> {rows.map((row) => ( <div key={row.name} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between" > <div className="min-w-0"> <p className="font-mono text-xs text-foreground"> {row.name} </p> <p className="text-13 text-muted-foreground"> {row.note} </p> </div> <div className="flex w-full justify-end sm:w-72"> {row.control} </div> </div> ))} </div> );}In a stacked form
FieldSelect fills its column. Pass size="default" so it matches the 32px inputs around it, and point a Label at its id.
import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { FieldSelect } from "@oration/canon/components/select-field";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function SheetForm() { const contactId = React.useId(); const whenId = React.useId(); const queueId = React.useId(); const [contact, setContact] = React.useState(""); const [when, setWhen] = React.useState("tomorrow-am"); const [queue, setQueue] = React.useState("ap-exceptions"); return ( <form className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-popover p-4 text-left shadow-lg" onSubmit={(event) => { event.preventDefault(); if (!contact) { toast.add({ type: "error", title: "Choose who to call back", }); return; } toast.add({ type: "success", title: "Callback scheduled", description: "It's on the AP exceptions queue for tomorrow morning.", }); }} > <p className="text-base leading-none font-medium"> Schedule a callback </p> <div className="flex flex-col gap-1.5"> <Label htmlFor={contactId}>Supplier contact</Label> <FieldSelect id={contactId} size="default" placeholder="Choose a contact" value={contact} onValueChange={setContact} options={[ { value: "maya", label: "Maya Okafor, Northwind Freight", }, { value: "tomas", label: "Tomás Ferreira, Halcyon" }, { value: "aisha", label: "Aisha Bello, Orchard Street", }, ]} /> </div> <div className="grid grid-cols-2 gap-3"> <div className="flex flex-col gap-1.5"> <Label htmlFor={whenId}>When</Label> <FieldSelect id={whenId} size="default" value={when} onValueChange={setWhen} options={[ { value: "today-pm", label: "This afternoon" }, { value: "tomorrow-am", label: "Tomorrow morning" }, { value: "tomorrow-pm", label: "Tomorrow afternoon", }, ]} /> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={queueId}>Queue</Label> <FieldSelect id={queueId} size="default" value={queue} onValueChange={setQueue} options={[ { value: "ap-exceptions", label: "AP exceptions" }, { value: "onboarding", label: "Vendor onboarding" }, ]} /> </div> </div> <div className="flex justify-end gap-2"> <Button type="button" variant="ghost" onClick={() => toast.add({ title: "Callback discarded" })} > Cancel </Button> <Button type="submit">Schedule</Button> </div> </form> );}In a filter bar
ChoiceSelect shrinks to its content, so give it a width. Its label is screen-reader only because each value names itself.
- Northwind Freight$412,000
- Brightline Freight$143,200
- Halcyon$96,400
- Orchard Street$18,750
import { ChoiceSelect } from "@oration/canon/components/select-field";import * as React from "react";export function FilterBar() { type Tier = "all" | "strategic" | "preferred" | "standard"; type Sort = "spend" | "name"; const suppliers = [ { name: "Northwind Freight", tier: "strategic", spend: 412000 }, { name: "Halcyon", tier: "preferred", spend: 96400 }, { name: "Orchard Street", tier: "standard", spend: 18750 }, { name: "Brightline Freight", tier: "preferred", spend: 143200 }, ]; const [tier, setTier] = React.useState<Tier>("all"); const [sort, setSort] = React.useState<Sort>("spend"); const rows = suppliers .filter((s) => tier === "all" || s.tier === tier) .sort((a, b) => sort === "spend" ? b.spend - a.spend : a.name.localeCompare(b.name), ); 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"> <ChoiceSelect<Tier> label="Tier" value={tier} onValueChange={setTier} options={[ { value: "all", label: "All tiers" }, { value: "strategic", label: "Strategic" }, { value: "preferred", label: "Preferred" }, { value: "standard", label: "Standard" }, ]} className="w-32 text-[13px]" /> <ChoiceSelect<Sort> label="Sort by" value={sort} onValueChange={setSort} options={[ { value: "spend", label: "Spend, high to low" }, { value: "name", label: "Name, A to Z" }, ]} className="w-44 text-[13px]" /> <span className="ml-auto text-xs text-muted-foreground tabular-nums"> {rows.length} suppliers </span> </div> <ul className="divide-y divide-border"> {rows.map((supplier) => ( <li key={supplier.name} className="flex h-9 items-center justify-between gap-3 px-3 text-13" > <span className="truncate">{supplier.name}</span> <span className="font-medium tabular-nums"> {supplier.spend.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0, })} </span> </li> ))} </ul> </div> );}In a table cell
A size="sm" SettingsSelect edits a row in place. Name it after the row with label, since the column header alone does not reach the control.
| Disposition | Follow-up |
|---|---|
| Remittance sent | |
| Disputed invoice | |
| Bank details changed |
import { SettingsSelect } from "@oration/canon/components/select-field";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function TableCell() { type Action = "none" | "email" | "task" | "hold"; const [codes, setCodes] = React.useState< { name: string; followUp: Action }[] >([ { name: "Remittance sent", followUp: "email" }, { name: "Disputed invoice", followUp: "task" }, { name: "Bank details changed", followUp: "hold" }, ]); const actions: { value: Action; label: string }[] = [ { value: "none", label: "No follow-up" }, { value: "email", label: "Send email" }, { value: "task", label: "Create task" }, { value: "hold", label: "Hold payments" }, ]; return ( <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border"> <table className="w-full text-13"> <thead> <tr className="border-b border-border text-xs text-muted-foreground"> <th scope="col" className="h-8 px-3 text-left font-medium" > Disposition </th> <th scope="col" className="h-8 px-3 text-left font-medium" > Follow-up </th> </tr> </thead> <tbody> {codes.map((code, index) => ( <tr key={code.name} className="border-b border-border last:border-0" > <td className="px-3 py-1.5">{code.name}</td> <td className="px-3 py-1.5"> <SettingsSelect<Action> label={`Follow-up for ${code.name}`} size="sm" value={code.followUp} onValueChange={(followUp) => { setCodes((all) => all.map((c, i) => i === index ? { ...c, followUp } : c, ), ); toast.add({ title: "Follow-up changed", description: `${code.name}: ${actions.find((a) => a.value === followUp)?.label}.`, }); }} options={actions} className="text-[13px] sm:w-40" /> </td> </tr> ))} </tbody> </table> </div> );}The base select
SelectField has no preset width or height. Use it when none of the presets fits, and link its hint with describedBy.
Sent 3 days before an invoice falls due.
import { Label } from "@oration/canon/components/label";import { SelectField } from "@oration/canon/components/select-field";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Base() { type Channel = "email" | "sms" | "voice"; const id = React.useId(); const [channel, setChannel] = React.useState<Channel | "">(""); return ( <div className="flex w-full max-w-xs flex-col gap-1.5"> <Label htmlFor={id}>First reminder</Label> <SelectField<Channel> id={id} describedBy={`${id}-hint`} placeholder="Choose a channel" value={channel} onValueChange={(next) => { setChannel(next); toast.add({ title: "First reminder set", description: next, }); }} options={[ { value: "email", label: "Email" }, { value: "sms", label: "SMS" }, { value: "voice", label: "Voice call" }, ]} className="w-full" /> <p id={`${id}-hint`} className="text-xs text-muted-foreground"> Sent 3 days before an invoice falls due. </p> </div> );}States#
| State | Treatment |
|---|---|
| Rest | Transparent with a Field Stroke; 30% input fill in dark. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 50%. |
| Open | The popup overlaps the trigger with the selected row aligned. |
| Empty | value="" is passed to Select as null, so the placeholder shows in Slate Meta. |
| Highlighted row | Menu Hover fill under the pointer or keyboard highlight. |
| Disabled | disabled dims the trigger to 50% with a not-allowed cursor, as on the config page when the feature it belongs to is off. |
Behavior#
- Keyboard, focus and positioning are Select's: click or Enter, Space, Down or Up opens; typeahead jumps; Escape closes and focus returns to the trigger.
- Controlled only.
valueisV | "", where""means nothing chosen yet, andonValueChangeis typed toVand never reports empty. - The value type is inferred from
options, or set it:SettingsSelect<PaymentTerms>. classNameis required on the baseSelectFieldand optional on the presets, which merge yours after their own. On the presets a responsive width such assm:w-40replaces the preset'ssm:w-64.describedBysetsaria-describedby. In a settings row passdescriptionId(htmlFor)so the row description is read with the field.- There is no way to clear back to empty from the list once a value is chosen.
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 (this page) | 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#
htmlFor as its id, so the visible Payment terms label names it.label such as Select. Screen readers announce Select, not Payment terms.size="default" to FieldSelect in a stacked form so it matches the 32px inputs around it.FieldSelect at its 28px default beside 32px inputs. The field row looks misaligned and cramped.Content#
- Row labels are nouns for the setting: Default payment terms, not Choose default payment terms.
- Options are sentence case and parallel: Net 15, Net 30, Due on receipt.
- In filter bars, make the all option read as a value: All tiers, All languages.
- Sort options say the order: Spend, high to low.
- Placeholders name the action: Choose a contact.
- Put what the setting changes in the row description, not in the options.
Accessibility#
- The trigger is a Base UI combobox button with a listbox popup. Name it with a linked label through
id, orlabel(set asaria-label) when there is no visible label. labeloverrides the linked label if both are set, so when you pass both, use the same words.describedBypoints at the helper text or row description.- There is no invalid state: the presets can't pass
aria-invalid. Put errors in text beside the field and announce them withrole="alert". - The 28px presets meet the 24px target minimum; use
size="default"on touch-first forms.
| Keys | Action |
|---|---|
| Enter | Opens the list, or selects the highlighted row. |
| ↓↑ | Opens the list, then moves the highlight. |
| A–Z | Selects (closed) or highlights (open) the next match. |
| Esc | Closes without changing the value. |
Design tokens#
| Token | Used for |
|---|---|
--input | Trigger stroke |
--ring | Focus border and ring |
--popover | Popup background |
--accent | Highlighted row |
--muted-foreground | Placeholder and chevron |
shadow-md | The overlay shadow |
--radius-lg | 10px corners; 8px at 28px |
API reference#
SelectField
The base: a controlled Select with text rows. Generic over the value type V extends string. Also exports the SelectFieldOption<V> type, { value: V; label: ReactNode }.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | V | "" | No default | The selected value. "" shows the placeholder. |
onValueChangeRequired | (value: V) => void | No default | Called with the chosen value. |
optionsRequired | readonly SelectFieldOption<V>[] | No default | The rows, in order. |
classNameRequired | string | No default | Trigger classes. There is no default width; the presets supply one. |
id | string | No default | The trigger's id, for a linked label. |
label | string | No default | Sets aria-label on the trigger. |
describedBy | string | No default | Sets aria-describedby on the trigger. |
size | "sm" | "default" | "default" | 32px, or 28px with 8px corners. |
placeholder | string | No default | Shown while value is empty. |
disabled | boolean | No default | Dims the trigger and blocks interaction. |
SettingsSelect
For settings rows. w-full sm:w-64, 32px. Takes every SelectField prop; className is optional and merged after the preset.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after w-full sm:w-64. |
ConfigSelect
For agent config pages. Identical to SettingsSelect today: w-full sm:w-64, 32px.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Merged after w-full sm:w-64. |
FieldSelect
For sheet and dialog forms. w-full, 28px by default.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "sm" | Pass "default" beside 32px inputs. |
className | string | No default | Merged after w-full. |
ChoiceSelect
For filter bars, table headers and rule builders. min-w-0, 28px by default, and as wide as its content unless you set a width.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "sm" | 28px, or 32px in a form grid. |
className | string | No default | Merged after min-w-0. Set a width such as w-40. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
SettingsSelect and ConfigSelect are the same component with the same classes. Two names for one preset invite them to drift.
FieldSelect defaults to 28px, but six of its ten call sites pass size="default" to match the 32px form fields around them. The default doesn't match its main use.
There is no aria-invalid pass-through and no per-option disabled, so the presets can't show an error state or an unavailable option.
Rows are rendered straight into the popup without a SelectGroup, so they sit flush against its edge with no 4px inset.