Skip to content

Option select

A one-line Select for a flat list of string options.

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

When a run fails

import { Label } from "@oration/canon/components/label";import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const retryId = React.useId();    const notifyId = React.useId();    const [retry, setRetry] = React.useState("3 times, 5 minutes apart");    const [notify, setNotify] = React.useState("Workflow owner");    return (        <div className="flex w-full max-w-72 flex-col gap-4 rounded-xl bg-sidebar p-4 text-left shadow-border">            <h3 className="text-13 font-medium">When a run fails</h3>            <div className="flex flex-col gap-1.5">                <Label htmlFor={retryId} className="text-13 font-normal">                    Retry failed steps                </Label>                <OptionSelect                    id={retryId}                    label="Retry failed steps"                    value={retry}                    onValueChange={(next) => {                        setRetry(next);                        toast.add({                            title: "Retry policy saved",                            description: next,                        });                    }}                    options={[                        "Don't retry",                        "Once, after 5 minutes",                        "3 times, 5 minutes apart",                        "5 times, 1 hour apart",                    ]}                    className="bg-card"                />            </div>            <div className="flex flex-col gap-1.5">                <Label htmlFor={notifyId} className="text-13 font-normal">                    Notify on failure                </Label>                <OptionSelect                    id={notifyId}                    label="Notify on failure"                    value={notify}                    onValueChange={(next) => {                        setNotify(next);                        toast.add({                            title: "Failure alerts updated",                            description: `${next} will hear when the invoice sync fails.`,                        });                    }}                    options={[                        "Workflow owner",                        "Owner and #ap-alerts",                        "Nobody",                    ]}                    className="bg-card"                />            </div>        </div>    );}

Usage#

Option select is Select in one element: pass a flat list of strings or { value, label } pairs and it renders a full-width trigger and the list. It is the dense picker for workflow inspectors, run bars and sequence settings, where one line of text per option is all a row needs. The part people get wrong is the name: label becomes the trigger's aria-label, so it must repeat the visible label word for word.

When to use

  • In a workflow inspector or side panel: Notify on failure, Retry failed steps.
  • In a run bar or toolbar at size="sm": Sample record.
  • For times, delays and other short lists where the label is the value: 9:00 AM, 1 hour.
  • When the stored value is an id but people read a name, with { value, label } pairs.

When not to use

  • In a settings row, agent config page or sheet form. Those surfaces use the presets with a typed value. Use Select field
  • When rows need icons, tags, groups or a formatted trigger. Use Select
  • For people, suppliers or any list long enough to search. Use Combobox
  • For two to four modes that should stay visible side by side. Use Segmented control
  • For a time zone. Use Timezone select

The Thirteen-Fourteen Rule

Option select is dense UI: its trigger and rows are meant to read at 13px beside the 13px labels of inspectors and toolbars. Today they render at 14px; see the known gaps.

Label in name

The visible label and the label prop say the same words, so speech users can say what they see and screen readers announce what is on screen.

Anatomy#

Workflow owner
Workflow owner
Owner and #ap-alerts
Nobody
  1. Trigger. A Select trigger at full width with min-w-0, 32px or 28px tall, 10px corners and a Field Stroke. className lands here.
  2. Value. The selected option's label, one line, truncated when the trigger is narrow.
  3. Popup. The Select popup, aligned over the trigger so the selected row sits on the value. contentClassName lands here.
  4. Row. One per option, in the order given. Filled with Menu Hover when highlighted.
  5. Check. Marks the selected row.

Examples#

Strings or value and label pairs

Pass plain strings when the label is the value. Pass { value, label } pairs when the stored value is an id, and disabled: true for an option that can't be chosen right now.

import { Label } from "@oration/canon/components/label";import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function StringsAndPairs() {    const formatId = React.useId();    const approverId = React.useId();    const [format, setFormat] = React.useState("PDF");    const [approver, setApprover] = React.useState("priya");    return (        <div className="grid w-full max-w-lg gap-4 sm:grid-cols-2">            <div className="flex flex-col gap-1.5">                <Label htmlFor={formatId} className="text-13">                    Remittance format                </Label>                <OptionSelect                    id={formatId}                    label="Remittance format"                    value={format}                    onValueChange={(next) => {                        setFormat(next);                        toast.add({                            title: `Remittances will attach as ${next}`,                        });                    }}                    options={["PDF", "CSV", "EDI 820"]}                />            </div>            <div className="flex flex-col gap-1.5">                <Label htmlFor={approverId} className="text-13">                    Second approver                </Label>                <OptionSelect                    id={approverId}                    label="Second approver"                    value={approver}                    onValueChange={(next) => {                        setApprover(next);                        toast.add({ title: "Approver changed" });                    }}                    options={[                        { value: "maya", label: "Maya Okafor" },                        { value: "priya", label: "Priya Raman" },                        { value: "tomas", label: "Tomás Ferreira" },                        {                            value: "wen",                            label: "Wen Zhou, on leave",                            disabled: true,                        },                    ]}                />            </div>        </div>    );}

Sizes

Default is 32px for inspector fields. sm is 28px, for run bars and toolbars. The trigger fills its container, so size it with a wrapper or className.

import { OptionSelect } from "@oration/canon/components/option-select";import * as React from "react";export function Sizes() {    const [record, setRecord] = React.useState("inv-20931");    const [delay, setDelay] = React.useState("1 hour");    const records = [        { value: "inv-20931", label: "INV-20931, Northwind Freight" },        { value: "inv-20927", label: "INV-20927, Halcyon" },        { value: "inv-20918", label: "INV-20918, Orchard Street" },    ];    return (        <div className="flex flex-wrap items-center gap-6">            <div className="w-52">                <OptionSelect                    label="Wait before reminding"                    value={delay}                    onValueChange={setDelay}                    options={["15 minutes", "1 hour", "1 day", "3 days"]}                />            </div>            <div className="w-56">                <OptionSelect                    size="sm"                    label="Sample record"                    value={record}                    onValueChange={setRecord}                    options={records}                />            </div>        </div>    );}

Empty value

An empty string shows the placeholder until someone chooses. There is no way to clear back to empty from the list.

Escalation queue
import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Placeholder() {    const [queue, setQueue] = React.useState("");    return (        <div className="flex w-56 flex-col gap-1.5">            <span className="text-13 font-medium">Escalation queue</span>            <OptionSelect                label="Escalation queue"                value={queue}                onValueChange={(next) => {                    setQueue(next);                    toast.add({ title: `Escalations go to ${next}` });                }}                placeholder="Choose a queue"                options={["AP exceptions", "Vendor onboarding", "Treasury"]}            />        </div>    );}

Disabled

Disable the select while its value is locked, and say why beside it.

Reminder channel

Locked while the Q3 dunning sequence is running.

import { Button } from "@oration/canon/components/button";import { OptionSelect } from "@oration/canon/components/option-select";import * as React from "react";export function Disabled() {    const [channel, setChannel] = React.useState("Email");    const [locked, setLocked] = React.useState(true);    return (        <div className="flex w-full max-w-sm flex-col gap-3">            <div className="flex items-center justify-between gap-3">                <span className="text-13 font-medium">Reminder channel</span>                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() => setLocked((value) => !value)}                >                    {locked ? "Unlock" : "Lock"}                </Button>            </div>            <OptionSelect                label="Reminder channel"                value={channel}                onValueChange={setChannel}                options={["Email", "SMS", "Voice call"]}                disabled={locked}            />            <p className="text-xs text-muted-foreground">                {locked                    ? "Locked while the Q3 dunning sequence is running."                    : "Changes apply to reminders not yet sent."}            </p>        </div>    );}

Sending window

Two selects read as one sentence. The error is written beside them because Option select can't mark itself invalid.

Sending hours
to

Reminders to suppliers go out in their own time zone.

import { OptionSelect } from "@oration/canon/components/option-select";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function SendingWindow() {    const times = [        "7:00 AM",        "8:00 AM",        "9:00 AM",        "10:00 AM",        "3:00 PM",        "4:00 PM",        "5:00 PM",        "6:00 PM",    ];    const [start, setStart] = React.useState("9:00 AM");    const [end, setEnd] = React.useState("5:00 PM");    const invalid = times.indexOf(end) <= times.indexOf(start);    return (        <div className="flex w-full max-w-md flex-col gap-2 rounded-xl bg-card p-4 text-left shadow-border">            <span className="text-13 font-medium">Sending hours</span>            <div className="flex items-center gap-2 text-13">                <div className="w-32">                    <OptionSelect                        label="Window starts"                        value={start}                        onValueChange={setStart}                        options={times}                    />                </div>                <span className="text-muted-foreground">to</span>                <div className="w-32">                    <OptionSelect                        label="Window ends"                        value={end}                        onValueChange={setEnd}                        options={times}                    />                </div>            </div>            <p                role={invalid ? "alert" : undefined}                className={cn(                    "text-xs",                    invalid ? "text-destructive" : "text-muted-foreground",                )}            >                {invalid                    ? "Choose an end time after the start time."                    : "Reminders to suppliers go out in their own time zone."}            </p>        </div>    );}

In a run bar

The small size beside the test run button in a workflow's run bar.

Sample record
import { Button } from "@oration/canon/components/button";import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import { PlayIcon } from "lucide-react";import * as React from "react";export function RunBar() {    const [sample, setSample] = React.useState("inv-20931");    const samples = [        { value: "inv-20931", label: "INV-20931, Northwind Freight" },        { value: "inv-20927", label: "INV-20927, Halcyon" },        { value: "inv-20918", label: "INV-20918, Orchard Street" },    ];    return (        <div className="flex w-full max-w-xl flex-wrap items-center gap-2 rounded-xl bg-card px-3 py-2 text-left shadow-border">            <span className="text-13 text-muted-foreground">Sample record</span>            <div className="w-60">                <OptionSelect                    size="sm"                    label="Sample record"                    value={sample}                    onValueChange={setSample}                    options={samples}                />            </div>            <Button                type="button"                size="sm"                className="ml-auto"                onClick={() =>                    toast.add({                        type: "success",                        title: "Test run passed",                        description: `${samples.find((s) => s.value === sample)?.label} matched 3 of 3 steps.`,                    })                }            >                <PlayIcon data-icon="inline-start" aria-hidden="true" />                Run test            </Button>        </div>    );}

States#

States
StateTreatment
RestTransparent over the surface. Inspectors on the rail pass bg-card so the field reads as white.
Focus visibleIndigo border and a 3px Focus Indigo ring at 50%.
OpenThe popup overlaps the trigger with the selected row aligned to the value.
Emptyvalue="" with a placeholder shows the placeholder in Slate Meta.
Highlighted rowMenu Hover fill under the pointer or keyboard highlight.
Disableddisabled dims the trigger to 50%. A disabled option dims its row and is skipped by the keyboard.

Behavior#

  • Everything behaves as Select: click or Enter, Space, Down or Up to open, typeahead to jump, Escape to close, focus back on the trigger.
  • Strings are turned into { value: s, label: s }, so the value you store is the text you show. Use pairs when the value is an id.
  • Controlled only: value and onValueChange are required. The callback only fires with a string, never null.
  • The trigger is w-full; size it with its container or className, as the product does with w-32 and w-48.
  • It posts nothing with a form: there is no name, required or readOnly. Use Select when the value must submit natively.

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 select (this page)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 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. Repeat the visible label in label: Notify on failure above, label="Notify on failure" on the component.
Don't. Give label different words, such as Alert recipients dropdown. Screen readers announce that instead of the label people see.

Content#

  • Options are short and parallel, sentence case, one line: Workflow owner, Owner and #ap-alerts, Nobody.
  • Put the default or recommended option first.
  • Name the outcome, not the mechanism: 3 times, 5 minutes apart reads better than Retry policy B.
  • Placeholders name the action: Choose a queue.
  • Explain a disabled option in its label or beside the field: Wen Zhou, on leave.

Accessibility#

  • label is required and becomes the trigger's aria-label. It overrides any <Label htmlFor> for the accessible name, so the two must match.
  • The trigger is a Base UI combobox button with a listbox popup; keyboard and focus handling come from Select.
  • There is no way to pass aria-invalid or aria-describedby. Write errors as text beside the field, and announce them with role="alert" when they appear.
  • Rows are at least 28px tall.
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
--cardThe bg-card fill inspectors pass on the rail
--ringFocus border and ring
--popoverPopup background
--accentHighlighted row
--muted-foregroundPlaceholder and chevron
shadow-mdThe overlay shadow

API reference#

OptionSelect

A controlled Select with the trigger, value and rows built in. Also exports the SelectOption type.

Props of OptionSelect
PropTypeDefaultDescription
valueRequiredstringNo defaultThe selected value. "" shows the placeholder.
onValueChangeRequired(value: string) => voidNo defaultCalled with the chosen value.
optionsRequiredreadonly (string | SelectOption)[]No defaultStrings, or { value: string; label: ReactNode; disabled?: boolean } pairs.
labelRequiredstringNo defaultThe accessible name, set as aria-label on the trigger. Match the visible label.
idstringNo defaultThe trigger's id, for a <Label htmlFor>.
size"sm" | "default""default"32px, or 28px for run bars and toolbars.
placeholderstringNo defaultShown when value is empty.
disabledbooleanNo defaultDims the trigger and blocks interaction.
classNamestringNo defaultClasses for the trigger, such as a width or bg-card.
contentClassNamestringNo defaultClasses for the popup.

Known gaps#

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

The 13px size doesn't apply. The component adds text-13 to the trigger and rows through the cn package, which keeps the base text-sm beside it, and text-sm wins in the stylesheet. Trigger and rows render at 14px.

Rows are rendered straight into the popup without a SelectGroup, so they sit flush against its edge with no 4px inset.

There is no aria-invalid or aria-describedby pass-through. The sequence sending window marks an invalid end time with className="border-destructive", which draws no ring and isn't announced.

label always sets aria-label, even when a visible label is linked by id, so the two can drift apart.

No name, required or readOnly, so the value can't post with a native form.