Skip to content

Interaction states

Rest, hover, focus, pressed, selected, disabled, invalid, loading and read-only, and how each is drawn.

Every state, one treatment#

Twelve states, each drawn the same way on every control. A person who learns what focus or selection looks like once can read it everywhere.

Interaction states
StateTreatmentClasses and tokens
RestThe default look. Fields carry a Field Stroke border; outline buttons the control shadow.border-input, shadow-xs
HoverA fill or a color step over 150ms. Ghost and outline buttons turn Well Gray, grid rows Row Mist, menu items Menu Hover, interactive cards deepen their lift.hover:bg-muted, hover:bg-surface, bg-accent, hover:shadow-border-hover
Focus visibleControls get an indigo border and a 3px ring at 40 to 50%. Bare links get a 2px solid outline at a 2px offset.focus-visible:border-ring ring-3 ring-ring/40
PressedButtons scale to 0.96 when motion is allowed.motion-safe:active:scale-[0.96]
Selected or checkedRows and chips take a 6% indigo tint with a 22% inset ring. Checkboxes, radios and switches fill indigo. Choice cards take a 1.5 to 2px indigo ring.bg-primary/6 with an inset ring, data-checked:bg-primary
Open or expandedThe trigger holds its hover fill while its menu is open, and chevrons rotate 180°.aria-expanded:bg-muted, rotate-180
Disabled50% opacity, no pointer events. Fields also take a faint input-tinted fill.disabled:opacity-50, disabled:bg-input/50
Read-onlyValues read as plain text: no stroke, no fill, no chevron, full opacity. Edit buttons and the save bar go.[data-settings-mode="read"]
InvalidA red border and a 3px red ring at 20% (40% in dark), with the message beside the field.aria-invalid:border-destructive ring-3 ring-destructive/20
Loading or pendingSkeletons shaped like the final layout. A pending button keeps its width and shows a spinner.skeleton-shimmer, PendingButton, aria-busy
DraggingThe card holds the hover lift and the cursor turns to grabbing.shadow-border-hover, cursor-grabbing
Live or runningIndigo with a label: a running ring and spinner, a pulsing dot, a shimmering label.ring-2 ring-primary/60, motion-safe:animate-ping, text-shimmer

The state matrix#

Eight controls forced into each state, drawn with the classes the components use. A dash means the control has no distinct look for that state.

Forced states

Switch the preview to dark: borders become faint white hairlines, rings lift and the 6% tint still reads.

Interaction states across eight controls
ControlRestHoverFocusPressed or onDisabledInvalid
Button
Input
Not drawn
Not drawn
Checkbox
Not drawn
Switch
Not drawn
Tab
Paid
Paid
Paid
Paid
Paid
Not drawn
Menu item
Request W-9
Request W-9
Request W-9
Not drawn
Request W-9
Not drawn
List row
Halcyon
Halcyon
Halcyon
Halcyon
Halcyon
Not drawn
Grid row
Halcyon$9,612
Halcyon$9,612
Not drawn
Halcyon$9,612
Not drawnNot drawn
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Input } from "@oration/canon/components/input";import { Switch } from "@oration/canon/components/switch";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Matrix() {    const states = [        "Rest",        "Hover",        "Focus",        "Pressed or on",        "Disabled",        "Invalid",    ] as const;    type State = (typeof states)[number];    const tab = (className: string) => (        <span className="inline-flex h-8 items-center rounded-lg bg-muted p-[3px]">            <span                className={cn(                    "flex h-[26px] items-center rounded-md border border-transparent px-2 text-13 font-medium",                    className,                )}            >                Paid            </span>        </span>    );    const track = (on: boolean, className?: string, hover = false) => (        <span            className={cn(                "inline-flex h-[18px] w-8 items-center rounded-full p-0.5",                on ? "justify-end bg-primary" : "justify-start bg-input",                className,            )}            style={                hover                    ? {                          backgroundColor:                              "color-mix(in oklab, var(--input), rgb(var(--overlay)) 10%)",                      }                    : undefined            }        >            <span className="size-3.5 rounded-full bg-white shadow-[0_1px_2px_oklch(0_0_0/0.2)]" />        </span>    );    const item = (className: string) => (        <span            className={cn(                "flex h-7 w-full items-center rounded-md px-1.5 text-13",                className,            )}        >            Request W-9        </span>    );    const row = (className: string) => (        <span            className={cn(                "flex h-8 w-full items-center rounded-md px-2 text-13",                className,            )}        >            Halcyon        </span>    );    const gridRow = (className: string) => (        <span            className={cn(                "flex h-9 w-full items-center justify-between border-y border-border px-2 text-13",                className,            )}        >            <span>Halcyon</span>            <span className="tabular-nums">$9,612</span>        </span>    );    const selected =        "bg-primary/6 shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--primary)_22%,transparent)]";    const rows: { name: string; cells: Record<State, React.ReactNode> }[] = [        {            name: "Button",            cells: {                Rest: <Button variant="outline">Save</Button>,                Hover: (                    <Button                        variant="outline"                        className="bg-muted text-foreground"                    >                        Save                    </Button>                ),                Focus: (                    <Button                        variant="outline"                        className="border-ring ring-3 ring-ring/40"                    >                        Save                    </Button>                ),                "Pressed or on": (                    <Button variant="outline" className="scale-[0.96] bg-muted">                        Save                    </Button>                ),                Disabled: (                    <Button variant="outline" disabled>                        Save                    </Button>                ),                Invalid: (                    <Button variant="outline" aria-invalid>                        Save                    </Button>                ),            },        },        {            name: "Input",            cells: {                Rest: <Input defaultValue="Halcyon" />,                Hover: null,                Focus: (                    <Input                        defaultValue="Halcyon"                        className="border-ring ring-3 ring-ring/50"                    />                ),                "Pressed or on": null,                Disabled: <Input defaultValue="Halcyon" disabled />,                Invalid: <Input defaultValue="Halcyon" aria-invalid />,            },        },        {            name: "Checkbox",            cells: {                Rest: <Checkbox aria-label="Rest" />,                Hover: null,                Focus: (                    <Checkbox                        aria-label="Focus"                        className="border-ring ring-3 ring-ring/50"                    />                ),                "Pressed or on": <Checkbox aria-label="Checked" checked />,                Disabled: <Checkbox aria-label="Disabled" checked disabled />,                Invalid: <Checkbox aria-label="Invalid" aria-invalid />,            },        },        {            name: "Switch",            cells: {                Rest: track(false),                Hover: track(false, undefined, true),                Focus: track(false, "ring-3 ring-ring/50"),                "Pressed or on": track(true),                Disabled: track(true, "opacity-50"),                Invalid: null,            },        },        {            name: "Tab",            cells: {                Rest: tab("text-muted-foreground"),                Hover: tab("text-foreground"),                Focus: tab(                    "border-ring text-muted-foreground ring-3 ring-ring/50",                ),                "Pressed or on": tab("bg-background text-foreground shadow-sm"),                Disabled: tab("text-muted-foreground opacity-50"),                Invalid: null,            },        },        {            name: "Menu item",            cells: {                Rest: item(""),                Hover: item("bg-accent"),                Focus: item("bg-accent"),                "Pressed or on": null,                Disabled: item("opacity-50"),                Invalid: null,            },        },        {            name: "List row",            cells: {                Rest: row(""),                Hover: row("bg-muted"),                Focus: row("ring-3 ring-ring/40"),                "Pressed or on": row(selected),                Disabled: row("opacity-50"),                Invalid: null,            },        },        {            name: "Grid row",            cells: {                Rest: gridRow("bg-background"),                Hover: gridRow("bg-surface"),                Focus: null,                "Pressed or on": gridRow(selected),                Disabled: null,                Invalid: null,            },        },    ];    return (        <div className="w-full min-w-0 overflow-x-auto">            <table className="w-full min-w-[46rem] border-separate border-spacing-x-2 border-spacing-y-3 text-left">                <caption className="sr-only">                    Interaction states across eight controls                </caption>                <thead>                    <tr>                        <th scope="col" className="w-24">                            <span className="sr-only">Control</span>                        </th>                        {states.map((state) => (                            <th                                key={state}                                scope="col"                                className="text-center text-xs font-normal text-muted-foreground"                            >                                {state}                            </th>                        ))}                    </tr>                </thead>                <tbody>                    {rows.map((entry) => (                        <tr key={entry.name}>                            <th                                scope="row"                                className="text-13 font-normal whitespace-nowrap text-muted-foreground"                            >                                {entry.name}                            </th>                            {states.map((state) => (                                <td key={state} className="align-middle">                                    {entry.cells[state] ? (                                        <div                                            inert                                            className="pointer-events-none flex justify-center"                                        >                                            {entry.cells[state]}                                        </div>                                    ) : (                                        <span className="block text-center text-subtle-foreground">                                            <span aria-hidden="true">–</span>                                            <span className="sr-only">                                                Not drawn                                            </span>                                        </span>                                    )}                                </td>                            ))}                        </tr>                    ))}                </tbody>            </table>        </div>    );}
  • Menus have no separate focus ring. The highlighted item is the focus, for pointer and keyboard alike, in Menu Hover.
  • Inputs and checkboxes don't change on hover. The pointer cursor and the label are enough, and a hover fill on a field reads as disabled.
  • Grid rows aren't focus targets; the link in the first cell is. See Data grid.

Where components drift

Switch draws its unchecked track in Menu Hover (--accent) where DESIGN.md names Field Stroke, and its focus is a 1px ring at a 2px offset rather than the 3px ring every other control uses. The Data grid draws selected rows with the 6% tint but no 22% inset ring. The matrix follows DESIGN.md.

Focus#

Focus is indigo, always visible from the keyboard and never shown for a mouse click. It is the one state every interactive element must draw.

Rings and outlines

The button takes a 3px ring at 40% and an indigo border. The field and checkbox take the ring at 50%. The bare link takes the 2px outline from the base layer.

View remittance

Click in the preview, then press Tab.

import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import Link from "next/link";import * as React from "react";export function FocusRings() {    const [emails, setEmails] = React.useState(true);    return (        <div className="flex w-full max-w-md flex-col gap-4">            <div className="flex flex-wrap items-center gap-2">                <Button                    type="button"                    variant="outline"                    onClick={() => toast.add({ title: "Draft saved" })}                >                    Save draft                </Button>                <Input                    aria-label="Supplier name"                    defaultValue="Northwind Freight"                    className="w-48"                />            </div>            <div className="flex flex-wrap items-center gap-4 text-13">                <label className="flex items-center gap-2">                    <Checkbox                        checked={emails}                        onCheckedChange={(next) => setEmails(next === true)}                    />                    Email remittances                </label>                <Link                    href="/design/foundations/states#focus"                    className="font-medium text-foreground underline decoration-border-strong underline-offset-[3px]"                >                    View remittance                </Link>            </div>            <p className="text-xs text-muted-foreground">                Click in the preview, then press Tab.            </p>        </div>    );}
Focus recipes
// Controls: an indigo border and a 3px ring"outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/40"  // buttons"outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"  // fields, checkboxes// Rows and cards with no border of their own: the ring alone"outline-none focus-visible:ring-3 focus-visible:ring-ring/40"// Bare links, from the base layer in globals.css. Nothing to add.:where(a, button, [role="button"], summary):focus-visible {  outline: 2px solid var(--ring);  outline-offset: 2px;  border-radius: 6px;}
  • Use focus-visible, never focus. A click shouldn't leave a ring behind; a Tab should always show one.
  • Every component sets outline-none and draws the ring itself. A hand-built control that drops outline-none without adding the ring has no focus state.
  • The ring is --ring: Quiet Indigo in light, lifted to oklch(0.65 0.16 272) in dark so it reads on the dark plane.
  • Don't clip the ring. overflow-hidden on a parent hides it; inset the ring (ring-inset) on rows that fill their container.

Selected, checked and expanded#

Selection is indigo at a whisper: a 6% tint with a 22% inset ring for rows and chips, a solid fill only for checkboxes, radios and switches.

Rows, chips and choice cards

Toggle the filters, select invoices and pick a payment method. Each selection reads in indigo without competing with the one filled button.

Filters
Payment method
import { Checkbox } from "@oration/canon/components/checkbox";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Selection() {    const invoices = [        {            id: "INV-20931",            supplier: "Northwind Freight",            amount: "$18,240.00",        },        { id: "INV-20932", supplier: "Halcyon", amount: "$9,612.50" },        { id: "INV-20935", supplier: "Orchard Street", amount: "$4,120.00" },    ];    const [picked, setPicked] = React.useState<string[]>(["INV-20932"]);    const [chips, setChips] = React.useState<string[]>(["Due this week"]);    const [method, setMethod] = React.useState("ACH");    const toggle = (list: string[], value: string) =>        list.includes(value)            ? list.filter((item) => item !== value)            : [...list, value];    const tint =        "bg-primary/6 shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--primary)_22%,transparent)]";    return (        <div className="flex w-full max-w-lg flex-col gap-5">            <fieldset className="flex flex-wrap gap-1.5">                <legend className="sr-only">Filters</legend>                {["Due this week", "Over $10,000", "Needs approval"].map(                    (chip) => {                        const on = chips.includes(chip);                        return (                            <button                                key={chip}                                type="button"                                aria-pressed={on}                                onClick={() =>                                    setChips((list) => toggle(list, chip))                                }                                className={cn(                                    "flex h-7 items-center rounded-md px-2 text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40",                                    on                                        ? tint                                        : "border border-dashed border-border-strong text-muted-foreground hover:bg-muted hover:text-foreground",                                )}                            >                                {chip}                            </button>                        );                    },                )}            </fieldset>            <div className="overflow-hidden rounded-xl bg-card shadow-border">                {invoices.map((invoice) => {                    const on = picked.includes(invoice.id);                    return (                        <label                            key={invoice.id}                            className={cn(                                "flex h-9 cursor-pointer items-center gap-3 border-b border-border px-3 text-13 last:border-b-0",                                on ? tint : "hover:bg-surface",                            )}                        >                            <Checkbox                                checked={on}                                onCheckedChange={() =>                                    setPicked((list) =>                                        toggle(list, invoice.id),                                    )                                }                            />                            <span className="flex-1">{invoice.supplier}</span>                            <span className="tabular-nums">                                {invoice.amount}                            </span>                        </label>                    );                })}            </div>            <fieldset className="grid grid-cols-3 gap-2">                <legend className="mb-2 text-13 font-medium">                    Payment method                </legend>                {["ACH", "Check", "Wire"].map((option) => {                    const on = method === option;                    return (                        <label                            key={option}                            className={cn(                                "flex h-14 cursor-pointer flex-col items-start justify-center rounded-[10px] bg-card px-3 text-left text-13 transition-shadow duration-150 has-focus-visible:ring-3 has-focus-visible:ring-ring/40",                                on                                    ? "ring-[1.5px] ring-primary"                                    : "shadow-border",                            )}                        >                            <input                                type="radio"                                name="payment-method"                                value={option}                                checked={on}                                onChange={() => setMethod(option)}                                className="sr-only"                            />                            <span className="font-medium">{option}</span>                            <span className="text-xs text-muted-foreground">                                {option === "ACH"                                    ? "1 to 2 days"                                    : option === "Check"                                      ? "5 to 7 days"                                      : "Same day"}                            </span>                        </label>                    );                })}            </fieldset>        </div>    );}
Selection recipes
// Selected rows, active filter chips, selected list items"bg-primary/6 shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--primary)_22%,transparent)]"// Selected choice cards"ring-[1.5px] ring-primary"

Open and expanded

Open the menu. The trigger keeps its Well Gray fill while the menu is open (aria-expanded:bg-muted in Button). The disclosure rotates its chevron over 200ms.

import { Button } from "@oration/canon/components/button";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { ChevronDownIcon } from "lucide-react";import * as React from "react";export function Expanded() {    const [open, setOpen] = React.useState(false);    return (        <div className="flex w-full max-w-md flex-col gap-4">            <div>                <DropdownMenu>                    <DropdownMenuTrigger                        render={<Button type="button" variant="outline" />}                    >                        Scheduled                        <ChevronDownIcon                            data-icon="inline-end"                            aria-hidden="true"                        />                    </DropdownMenuTrigger>                    <DropdownMenuContent className="w-44">                        <DropdownMenuItem                            onClick={() =>                                toast.add({ title: "Moved to Needs approval" })                            }                        >                            Needs approval                        </DropdownMenuItem>                        <DropdownMenuItem                            onClick={() =>                                toast.add({ title: "Moved to Paid" })                            }                        >                            Paid                        </DropdownMenuItem>                    </DropdownMenuContent>                </DropdownMenu>            </div>            <div className="rounded-xl bg-card shadow-border">                <button                    type="button"                    aria-expanded={open}                    aria-controls="remittance-panel"                    onClick={() => setOpen((value) => !value)}                    className="flex h-10 w-full items-center justify-between rounded-xl px-3 text-13 font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/40"                >                    Remittance details                    <ChevronDownIcon                        aria-hidden="true"                        className={cn(                            "size-4 text-muted-foreground transition-transform duration-200 ease-out motion-reduce:transition-none",                            open && "rotate-180",                        )}                    />                </button>                {open ? (                    <p                        id="remittance-panel"                        className="border-t border-border px-3 py-3 text-13 text-muted-foreground"                    >                        Sent to ap@northwindfreight.com on Sep 25. ACH settles                        Oct 5.                    </p>                ) : null}            </div>        </div>    );}
  • Selection uses aria-selected, aria-checked, aria-pressed or aria-current, never only a class. Style from the attribute where you can.
  • Text selection is indigo at 22% and the caret is indigo, from the base layer.
  • A selected tab in a track is the raised white pill; a line tab gets a 2px ink underline. Neither is indigo. See Tabs.

Disabled and read-only#

Disabled says a control can't be used right now. Read-only says this person can see the value but not change it. They look different on purpose.

The read-only settings mode

View as a member. Fields lose their stroke and fill and read as plain text at full opacity, the chevron and the Change button go, and a notice says why.

You can view these settings. Only admins can change them.

The address suppliers see on remittance emails.
Email remittances
Bank account
Checking ending in 4410, verified Sep 14.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { Switch } from "@oration/canon/components/switch";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { EyeIcon } from "lucide-react";import * as React from "react";export function ReadOnly() {    const [role, setRole] = React.useState<"admin" | "member">("member");    const [emails, setEmails] = React.useState(true);    const read = role === "member";    return (        <div className="flex w-full max-w-xl flex-col gap-4">            <SegmentedControl<"admin" | "member">                label="Viewing as"                value={role}                onValueChange={setRole}                options={[                    { value: "admin", label: "Admin" },                    { value: "member", label: "Member" },                ]}            />            <div data-settings-mode={read ? "read" : undefined}>                {read ? (                    <p                        role="status"                        className="mb-4 flex items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-muted-foreground"                    >                        <EyeIcon                            aria-hidden="true"                            className="size-3.5 shrink-0"                        />                        You can view these settings. Only admins can change                        them.                    </p>                ) : null}                <fieldset disabled={read} className="contents">                    <SettingsGroup>                        <SettingsRow                            label="Remittance sender"                            description="The address suppliers see on remittance emails."                            htmlFor="remit-from"                        >                            <Input                                id="remit-from"                                defaultValue="payments@cedarline.io"                                className="sm:w-60"                            />                        </SettingsRow>                        <SettingsRow                            label="Note to suppliers"                            htmlFor="remit-note"                        >                            <Textarea                                id="remit-note"                                defaultValue="Questions about this payment? Reply to this email."                                className="min-h-16 sm:w-60"                            />                        </SettingsRow>                        <SettingsRow label="Email remittances" inline>                            <Switch                                aria-label="Email remittances"                                checked={emails}                                onCheckedChange={setEmails}                                disabled={read}                            />                        </SettingsRow>                        <SettingsRow                            label="Bank account"                            description="Checking ending in 4410, verified Sep 14."                        >                            <Button                                type="button"                                variant="outline"                                size="sm"                                onClick={() =>                                    toast.add({ title: "Bank account change" })                                }                            >                                Change                            </Button>                        </SettingsRow>                    </SettingsGroup>                </fieldset>            </div>        </div>    );}
apps/web/src/app/globals.css
[data-settings-mode="read"] {  & :disabled,  & [data-disabled] {    opacity: 1;    cursor: default;  }  &    :is(      [data-slot="input"],      [data-slot="textarea"],      [data-slot="select-trigger"]    ) {    border-color: transparent;    background: transparent;    box-shadow: none;    padding-inline: 0;    color: var(--foreground);  }  & [data-slot="select-trigger"] > svg {    display: none;  }  & [data-slot="settings-row"] [data-slot="button"],  & [data-slot="save-bar"] {    display: none;  }}
Disabled and read-only
DisabledRead-only
MeansUnavailable until something changes: a step isn't done, a run is sending.Visible but not yours to change: a member viewing admin settings.
Looks50% opacity, cursor-not-allowed on fields.Plain text in ink. No border, fill or chevron. Full opacity.
Markupdisabled on the control, or aria-disabled when it must stay focusable.data-settings-mode="read" on the page, with a fieldset disabled around the form.
ExplainSay why in a tooltip or the description when it isn't obvious.One notice at the top: You can view these settings. Only admins can change them.

The read-only rule lives in the app's stylesheet, not in packages/canon, because it is tied to the settings layout. SettingsAccess in components/settings/settings-access.tsx applies it for members.

Invalid#

A red border and a 3px ring at 20%, and a sentence that says how to fix it. The color marks the field; the words do the work.

A field that can't be saved

Type a ninth digit to clear the error. The message sits under the field with an icon and is tied to it by aria-describedby.

Routing numbers are 9 digits.

import { Button } from "@oration/canon/components/button";import { Field, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import { CircleAlertIcon } from "lucide-react";import * as React from "react";export function Invalid() {    const [value, setValue] = React.useState("02100002");    const [touched, setTouched] = React.useState(true);    const invalid = touched && !/^\d{9}$/.test(value);    return (        <form            className="flex w-full max-w-xs flex-col gap-3"            onSubmit={(event) => {                event.preventDefault();                setTouched(true);                if (/^\d{9}$/.test(value)) {                    toast.add({ type: "success", title: "Bank account saved" });                }            }}        >            <Field data-invalid={invalid || undefined}>                <FieldLabel htmlFor="routing">Routing number</FieldLabel>                <Input                    id="routing"                    inputMode="numeric"                    value={value}                    aria-invalid={invalid || undefined}                    aria-describedby={invalid ? "routing-error" : undefined}                    onChange={(event) => setValue(event.target.value)}                    onBlur={() => setTouched(true)}                    className="font-mono"                />                {invalid ? (                    <p                        id="routing-error"                        className="flex items-center gap-1.5 text-13 text-destructive"                    >                        <CircleAlertIcon                            aria-hidden="true"                            className="size-3.5 shrink-0"                        />                        Routing numbers are 9 digits.                    </p>                ) : null}            </Field>            <div>                <Button type="submit">Save bank account</Button>            </div>        </form>    );}
Marking a field invalid
<Input  id="routing"  aria-invalid={invalid || undefined}  aria-describedby={invalid ? "routing-error" : undefined}/>{invalid ? (  <p id="routing-error" className="flex items-center gap-1.5 text-13 text-destructive">    <CircleAlertIcon aria-hidden="true" className="size-3.5" />    Routing numbers are 9 digits.  </p>) : null}
  • Set aria-invalid on the control; the red border and ring come from the component. Don't hand-color the border.
  • Validate on blur or submit, not on every keystroke. A field that turns red while someone is typing is wrong too early.
  • The message says what to do: Routing numbers are 9 digits, not Invalid input. See Forms.
  • A failed save is a toast or an inline alert, not an invalid field.

Loading and pending#

Data loads into skeletons shaped like what's coming. An action in flight keeps its button in place, busy, until it lands.

Skeleton rows and a pending button

Approve the run: the button keeps its width, shows a spinner and ignores clicks until it finishes. Reload to see the skeleton.

Loading suppliers
import { Button } from "@oration/canon/components/button";import { PendingButton } from "@oration/canon/components/pending-button";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Pending() {    const [pending, setPending] = React.useState(false);    const [loading, setLoading] = React.useState(true);    React.useEffect(() => {        if (!loading) return;        const id = window.setTimeout(() => setLoading(false), 1400);        return () => window.clearTimeout(id);    }, [loading]);    const suppliers = [        ["Northwind Freight", "$18,240.00"],        ["Halcyon", "$9,612.50"],        ["Orchard Street", "$4,120.00"],    ];    return (        <div className="flex w-full max-w-md flex-col gap-4">            <SkeletonReveal                loading={loading}                label="Loading suppliers"                className="overflow-hidden rounded-xl bg-card shadow-border"                skeleton={suppliers.map(([name]) => (                    <div                        key={name}                        className="flex h-9 items-center justify-between gap-3 border-b border-border px-3 last:border-b-0"                    >                        <span className="skeleton-shimmer h-2 w-32 rounded-full" />                        <span className="skeleton-shimmer h-2 w-16 rounded-full" />                    </div>                ))}            >                {suppliers.map(([name, amount]) => (                    <div                        key={name}                        className="flex h-9 items-center justify-between gap-3 border-b border-border px-3 text-13 last:border-b-0"                    >                        <span>{name}</span>                        <span className="tabular-nums">{amount}</span>                    </div>                ))}            </SkeletonReveal>            <div className="flex flex-wrap items-center gap-2">                <PendingButton                    type="button"                    pending={pending}                    onClick={() => {                        setPending(true);                        window.setTimeout(() => {                            setPending(false);                            toast.add({                                type: "success",                                title: "Payment run approved",                                description: "3 invoices leave Friday, Oct 2.",                            });                        }, 1500);                    }}                >                    Approve run                </PendingButton>                <Button                    type="button"                    variant="outline"                    onClick={() => setLoading(true)}                >                    Reload                </Button>            </div>        </div>    );}

The Label-Beside-Color Rule

Status is never color alone. A dot, tint or ring always travels with a text label (On track, At risk, Running, Passed), so the state reads in grayscale.

The Owned States Rule

Every component that shows data owns its loading, empty and error states, with a skeleton shaped like its final layout. A whole page is never gated on loading.

Loading, empty and error states belong to the component that shows the data, and are covered in full in Data states. Use Pending button for actions and Skeleton for data.

Dragging and live#

A dragged card lifts with the hover shadow. Live work carries indigo, a label and, when motion is allowed, a little movement.

Dragging a card

Drag the Orchard Street card. It holds the hover lift while it moves and settles back on release.

Needs approval
HalcyonINV-20932$9,612.50
import { cn } from "@oration/canon/lib/utils";import { GripVerticalIcon } from "lucide-react";import * as React from "react";export function Dragging() {    const [drag, setDrag] = React.useState<{        x: number;        y: number;        startX: number;        startY: number;    } | null>(null);    return (        <div className="flex w-full max-w-md items-start gap-4 rounded-xl bg-muted/60 p-3">            <div className="flex w-full flex-col gap-2">                <span className="text-13 font-medium text-muted-foreground">                    Needs approval                </span>                <button                    type="button"                    aria-roledescription="Draggable card"                    aria-label="INV-20935, Orchard Street"                    onPointerDown={(event) => {                        event.currentTarget.setPointerCapture(event.pointerId);                        setDrag({                            x: 0,                            y: 0,                            startX: event.clientX,                            startY: event.clientY,                        });                    }}                    onPointerMove={(event) => {                        if (!drag) return;                        setDrag({                            ...drag,                            x: event.clientX - drag.startX,                            y: event.clientY - drag.startY,                        });                    }}                    onPointerUp={() => setDrag(null)}                    onPointerCancel={() => setDrag(null)}                    style={{                        transform: drag                            ? `translate(${drag.x}px, ${drag.y}px)`                            : undefined,                    }}                    className={cn(                        "flex w-full touch-none items-center gap-2 rounded-xl bg-card p-3 text-left text-13 outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/40",                        drag                            ? "relative z-10 cursor-grabbing shadow-border-hover"                            : "cursor-grab shadow-border transition-[transform,box-shadow] duration-200 ease-out motion-reduce:transition-none",                    )}                >                    <GripVerticalIcon                        aria-hidden="true"                        className="size-4 shrink-0 text-subtle-foreground"                    />                    <span className="flex min-w-0 flex-1 flex-col">                        <span className="font-medium">Orchard Street</span>                        <span className="font-mono text-xs text-muted-foreground">                            INV-20935                        </span>                    </span>                    <span className="tabular-nums">$4,120.00</span>                </button>                <div className="flex items-center gap-2 rounded-xl bg-card p-3 text-13 shadow-border">                    <GripVerticalIcon                        aria-hidden="true"                        className="size-4 shrink-0 text-subtle-foreground"                    />                    <span className="flex min-w-0 flex-1 flex-col">                        <span className="font-medium">Halcyon</span>                        <span className="font-mono text-xs text-muted-foreground">                            INV-20932                        </span>                    </span>                    <span className="tabular-nums">$9,612.50</span>                </div>            </div>        </div>    );}

Running, passed, failed and skipped

Flow nodes during a test run, a live status label and an in-progress label. Every one has words.

  • Match remittanceRunning
  • Check W-9Passed
  • Post to NetSuiteFailed
  • Notify Priya RamanSkipped
Payment run sendingMatching remittances…
import { Spinner } from "@oration/canon/components/spinner";import { StatusLabel } from "@oration/canon/components/status-dot";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, XIcon } from "lucide-react";export function Live() {    const nodes = [        {            name: "Match remittance",            label: "Running",            ring: "ring-2 ring-primary/60",            icon: <Spinner className="size-3.5 text-primary" />,        },        {            name: "Check W-9",            label: "Passed",            ring: "ring-1 ring-success/50",            icon: (                <CheckIcon                    aria-hidden="true"                    className="size-3.5 text-success"                />            ),        },        {            name: "Post to NetSuite",            label: "Failed",            ring: "ring-2 ring-destructive/60",            icon: (                <XIcon                    aria-hidden="true"                    className="size-3.5 text-destructive"                />            ),        },        {            name: "Notify Priya Raman",            label: "Skipped",            ring: "shadow-border opacity-60",            icon: null,        },    ];    return (        <div className="flex w-full max-w-lg flex-col gap-4">            <ul className="grid gap-3 sm:grid-cols-2">                {nodes.map((node) => (                    <li                        key={node.name}                        className={cn(                            "flex items-center justify-between gap-3 rounded-xl bg-card px-3 py-2.5",                            node.ring,                        )}                    >                        <span className="text-13 font-medium">{node.name}</span>                        <span className="flex items-center gap-1.5 text-xs text-muted-foreground">                            {node.icon}                            {node.label}                        </span>                    </li>                ))}            </ul>            <div className="flex flex-wrap items-center gap-4">                <StatusLabel tone="primary" pulse>                    Payment run sending                </StatusLabel>                <span className="text-shimmer text-13">                    Matching remittances…                </span>            </div>        </div>    );}
Run states
StateRingMark
Running2px indigo at 60%Spinner and Running
Passed1px green at 50%Check and Passed
Failed2px red at 60%The error and Failed
SkippedNone, the node fades to 60%Skipped

Never color alone#

Every state that uses color also changes something else: a label, an icon, a ring, a weight. The screen still reads in grayscale.

  • Paid
  • Due in 2 days
  • Payment failed
Do. Pair each dot with its status in words, through Status label.
  • Northwind Freight
  • Halcyon
  • Orchard Street
Don't. Mark rows with colored dots and nothing else. A color-blind approver can't tell Paid from Payment failed.
  • Focus adds a ring as well as an indigo border. Selection adds a tint and a ring, and the checkbox fills.
  • Invalid adds a message and an icon under the field.
  • Active nav items add medium weight and an ink icon to their fill.
  • Test every new state in grayscale before review.