Skip to content

Select field

Pre-dressed Select variants for settings, config panels and forms.

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

Payments

Defaults for new suppliers. Each supplier can override them.

When invoices fall due after they're approved.
Attached to the email suppliers get when a payment is sent.
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 SettingsSelect on 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 FieldSelect at size="default": Supplier contact, Queue.
  • In a filter bar, table header or rule builder, with ChoiceSelect and an explicit width: Tier, Sort by.
  • Whenever the value is a string union and you want onValueChange typed 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

In a settings row, pass the row's 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

Settings and form fields read at 14px. In filter bars and table cells, pass text-[13px] through className so the value matches the 13px around it.

Anatomy#

Net 30
Net 15
Net 30
Net 45
  1. Trigger. A Select trigger whose width and height come from the preset: SettingsSelect and ConfigSelect are full width and 256px from 640px up; FieldSelect is full width; ChoiceSelect shrinks to fit.
  2. Value. The selected option's label, or the placeholder in Slate Meta when value is "".
  3. Popup. The Select popup, aligned over the trigger so the selected row sits on the value.
  4. Row. One per option, text only, filled with Menu Hover when highlighted.
  5. 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.

Schedule a callback

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.

4 suppliers
  • 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.

DispositionFollow-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#

States
StateTreatment
RestTransparent with a Field Stroke; 30% input fill in dark.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%.
OpenThe popup overlaps the trigger with the selected row aligned.
Emptyvalue="" is passed to Select as null, so the placeholder shows in Slate Meta.
Highlighted rowMenu Hover fill under the pointer or keyboard highlight.
Disableddisabled 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. value is V | "", where "" means nothing chosen yet, and onValueChange is typed to V and never reports empty.
  • The value type is inferred from options, or set it: SettingsSelect<PaymentTerms>.
  • className is required on the base SelectField and optional on the presets, which merge yours after their own. On the presets a responsive width such as sm:w-40 replaces the preset's sm:w-64.
  • describedBy sets aria-describedby. In a settings row pass descriptionId(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.

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 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 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 the row's htmlFor as its id, so the visible Payment terms label names it.
Payment terms
Don't. Leave the row label unlinked and fall back to a generic label such as Select. Screen readers announce Select, not Payment terms.
Do. Pass size="default" to FieldSelect in a stacked form so it matches the 32px inputs around it.
Don't. Leave 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, or label (set as aria-label) when there is no visible label.
  • label overrides the linked label if both are set, so when you pass both, use the same words.
  • describedBy points 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 with role="alert".
  • The 28px presets meet the 24px target minimum; use size="default" on touch-first forms.
Keyboard interactions
KeysAction
EnterOpens the list, or selects the highlighted row.
↓↑Opens the list, then moves the highlight.
A–ZSelects (closed) or highlights (open) the next match.
EscCloses without changing the value.

Design tokens#

Design tokens
TokenUsed for
--inputTrigger stroke
--ringFocus border and ring
--popoverPopup background
--accentHighlighted row
--muted-foregroundPlaceholder and chevron
shadow-mdThe overlay shadow
--radius-lg10px 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 }.

Props of SelectField
PropTypeDefaultDescription
valueRequiredV | ""No defaultThe selected value. "" shows the placeholder.
onValueChangeRequired(value: V) => voidNo defaultCalled with the chosen value.
optionsRequiredreadonly SelectFieldOption<V>[]No defaultThe rows, in order.
classNameRequiredstringNo defaultTrigger classes. There is no default width; the presets supply one.
idstringNo defaultThe trigger's id, for a linked label.
labelstringNo defaultSets aria-label on the trigger.
describedBystringNo defaultSets aria-describedby on the trigger.
size"sm" | "default""default"32px, or 28px with 8px corners.
placeholderstringNo defaultShown while value is empty.
disabledbooleanNo defaultDims 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.

Props of SettingsSelect
PropTypeDefaultDescription
classNamestringNo defaultMerged after w-full sm:w-64.

ConfigSelect

For agent config pages. Identical to SettingsSelect today: w-full sm:w-64, 32px.

Props of ConfigSelect
PropTypeDefaultDescription
classNamestringNo defaultMerged after w-full sm:w-64.

FieldSelect

For sheet and dialog forms. w-full, 28px by default.

Props of FieldSelect
PropTypeDefaultDescription
size"sm" | "default""sm"Pass "default" beside 32px inputs.
classNamestringNo defaultMerged 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.

Props of ChoiceSelect
PropTypeDefaultDescription
size"sm" | "default""sm"28px, or 32px in a form grid.
classNamestringNo defaultMerged 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.