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.
| State | Treatment | Classes and tokens |
|---|---|---|
| Rest | The default look. Fields carry a Field Stroke border; outline buttons the control shadow. | border-input, shadow-xs |
| Hover | A 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 visible | Controls 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 |
| Pressed | Buttons scale to 0.96 when motion is allowed. | motion-safe:active:scale-[0.96] |
| Selected or checked | Rows 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 expanded | The trigger holds its hover fill while its menu is open, and chevrons rotate 180°. | aria-expanded:bg-muted, rotate-180 |
| Disabled | 50% opacity, no pointer events. Fields also take a faint input-tinted fill. | disabled:opacity-50, disabled:bg-input/50 |
| Read-only | Values read as plain text: no stroke, no fill, no chevron, full opacity. Edit buttons and the save bar go. | [data-settings-mode="read"] |
| Invalid | A 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 pending | Skeletons shaped like the final layout. A pending button keeps its width and shows a spinner. | skeleton-shimmer, PendingButton, aria-busy |
| Dragging | The card holds the hover lift and the cursor turns to grabbing. | shadow-border-hover, cursor-grabbing |
| Live or running | Indigo 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.
| Control | Rest | Hover | Focus | Pressed or on | Disabled | Invalid |
|---|---|---|---|---|---|---|
| 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 drawn | Not 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.
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> );}// 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, neverfocus. A click shouldn't leave a ring behind; a Tab should always show one. - Every component sets
outline-noneand draws the ring itself. A hand-built control that dropsoutline-nonewithout adding the ring has no focus state. - The ring is
--ring: Quiet Indigo in light, lifted tooklch(0.65 0.16 272)in dark so it reads on the dark plane. - Don't clip the ring.
overflow-hiddenon 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.
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> );}// 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-pressedoraria-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.
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> );}[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 | Read-only | |
|---|---|---|
| Means | Unavailable until something changes: a step isn't done, a run is sending. | Visible but not yours to change: a member viewing admin settings. |
| Looks | 50% opacity, cursor-not-allowed on fields. | Plain text in ink. No border, fill or chevron. Full opacity. |
| Markup | disabled 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. |
| Explain | Say 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.
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> );}<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-invalidon 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.
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
The Owned States Rule
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.
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
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> );}| State | Ring | Mark |
|---|---|---|
| Running | 2px indigo at 60% | Spinner and Running |
| Passed | 1px green at 50% | Check and Passed |
| Failed | 2px red at 60% | The error and Failed |
| Skipped | None, 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
- Northwind Freight
- Halcyon
- Orchard Street
- 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.