Accessibility
The accessibility bar for every screen: focus, keyboard, names, contrast, targets and motion.
The bar for every screen#
Accessibility is a release criterion for anything a customer sees, not polish for later. Canon aims at WCAG 2.2 AA and builds the common cases into its components so most screens meet it by default.
Most of the work is already done if you use the platform and the components. Native buttons and links bring their keyboard behavior, Base UI brings roles, roving focus and focus return to menus, tabs, dialogs and selects, and the base layer of globals.css draws a focus ring on anything that can take focus. What stays with you is the part no component can know: what a control is called, what a page is about, which order things should be read in, and what to say when something changes.
Review every screen with two walks. First with the keyboard alone, where every flow finishes without a pointer. Then with a screen reader, where every control announces a name, a role and its state.
What this page can and can't promise
Canon is WCAG-minded, not certified. Following this page catches the common failures, but full validation needs manual testing with assistive technology (VoiceOver, NVDA, JAWS, TalkBack, switch and voice control) and review by an accessibility specialist. No product-specific audit is on record yet.
| Area | Canon does it | You do it |
|---|---|---|
| Structure | The shell renders the skip link, main and a labelled sidebar nav. | One h1 per page, headings in outline order, landmarks for any extra regions. |
| Names | Base UI wires labels to triggers, dialogs to their titles, and menus to their buttons. | Write the label, the aria-label on icon-only buttons and the dialog title. |
| Keyboard | Menus, tabs, radios, selects, comboboxes and dialogs follow the ARIA patterns. Global keys live in the shell. | Keep custom widgets on the same patterns. Don't trap focus outside overlays. |
| Focus | Every component draws a visible ring on :focus-visible. | Never remove a ring without a replacement. |
| Color | Tokens are tuned for contrast in both themes. Status components pair color with text. | Use the right token for text. Never let color carry meaning alone. |
| Motion | MotionConfig and the reduced-motion block in globals.css cover components. | Gate any hand-written animation on the same preference. |
Semantics and landmarks#
Structure is navigation. Screen reader users jump by landmark and heading, so the outline is the first thing they read.
The skip link
The first Tab on every workspace page shows Skip to content. It moves focus to main, past the sidebar's dozens of links.
Invoices on hold
Click here, then press Tab. The skip link appears first, before the sidebar.
import { SkipLink as SkipToContent } from "@oration/canon/components/skip-link";import { FileTextIcon, InboxIcon, UsersIcon } from "lucide-react";import * as React from "react";export function SkipLink() { const mainId = React.useId(); const [arrived, setArrived] = React.useState(false); return ( <div className="relative grid h-64 w-full max-w-xl grid-cols-[9rem_minmax(0,1fr)] overflow-hidden rounded-xl bg-card text-left shadow-border"> <SkipToContent href={`#${mainId}`} contained onClick={(event) => { event.preventDefault(); document.getElementById(mainId)?.focus(); setArrived(true); }} /> <nav aria-label="Demo workspace" className="flex flex-col gap-0.5 border-r border-border bg-sidebar p-2" > {[ { label: "Inbox", icon: InboxIcon }, { label: "Suppliers", icon: UsersIcon }, { label: "Invoices", icon: FileTextIcon }, ].map((item) => ( <a key={item.label} href="#skip-link-demo" className="flex h-7 items-center gap-2 rounded-md px-2 text-13 text-sidebar-foreground hover:bg-sidebar-accent" > <item.icon aria-hidden="true" className="size-4" /> {item.label} </a> ))} </nav> <div className="flex min-w-0 flex-col"> <div className="flex h-10 items-center border-b border-border px-3 text-13 text-muted-foreground"> Invoices </div> {/* In the product this is <main id="main" tabIndex={-1}>. */} <section id={mainId} tabIndex={-1} aria-label="Demo content" onBlur={() => setArrived(false)} className="m-2 flex flex-1 flex-col gap-1 rounded-lg p-3 outline-none focus-visible:ring-2 focus-visible:ring-ring" > <h4 className="text-sm font-semibold text-foreground"> Invoices on hold </h4> <p className="text-13 text-muted-foreground"> {arrived ? "Focus is on main. The next Tab goes to the first control in the page, not back through the sidebar." : "Click here, then press Tab. The skip link appears first, before the sidebar."} </p> </section> </div> </div> );}// apps/web/src/app/(app)/layout.tsx<SidebarProvider> <SkipLink href="#main" /> <AppSidebar /> <SidebarInset id="main" tabIndex={-1} className="min-w-0 outline-none"> {children} </SidebarInset></SidebarProvider>| Region | Element | Name |
|---|---|---|
| Skip link | <a href="#main">, first in the DOM | Skip to content |
| Sidebar | <nav> inside the sidebar | {app} navigation, such as CRM navigation, so two navs never share a name |
| Content | SidebarInset as main, with id="main" and tabIndex={-1} | Implicit. There is exactly one main |
| Copilot | <aside> panel | Copilot |
| Page sections | <section aria-labelledby> pointing at the section's heading | The visible heading |
- One
h1per page. Document pages show it as Headline through Page title. Dense list pages and full-bleed tools keep it in the app header as screen-reader text. - Headings follow the outline, not the size you want. A card heading under an h1 is an h2 set in Title type. See Typography.
- Use the element for the job:
<button>acts,<a href>goes somewhere and supports ⌘-click and middle-click,<table>holds tabular data. Never a<div onClick>. - Lists of things are
<ul>or<ol>. Screen readers announce the count, which is free context. - Remove ARIA before adding it. A wrong role is worse than none.
Accessible names#
Every control announces what it is. The name comes from a visible label where there is one, and from aria-label only where there isn't.
aria-label="Archive invoice", matched by the tooltip. The icon is aria-hidden.| Control | Name source | Example |
|---|---|---|
| Text field, select, textarea | A visible Label with htmlFor, or sr-only when the context labels it | Remittance email |
| Icon-only button | aria-label plus a Tooltip with the same words | Download invoice PDF |
| Button with text | Its text. Don't add an aria-label that says something else | Approve payment run |
| Dialog and sheet | Its DialogTitle, which Base UI links as the accessible name. A dialog without a title is unnamed | Delete supplier? |
| Row checkbox | aria-label naming the row | Include INV-20417 from Northwind Freight |
| Search and command input | A label, even when the placeholder says the same thing | Search suppliers |
| Decorative icon, avatar, illustration | None. aria-hidden="true", never on something focusable | – |
Known gaps
The Dialog close button is icon-only with no tooltip. Command's input has no accessible name of its own and relies on its placeholder. Monogram tile has no label prop, so it is decorative only. Each is recorded on its component page.
Keyboard#
Everything a pointer can do, a keyboard can do. Tab moves between widgets, arrow keys move inside them, Enter and Space act, and Escape backs out.
Focus order and return#
Focus follows the reading order of the page. Overlays move focus in when they open and return it to their trigger when they close.
- Tab order matches the visual order. Fix the DOM rather than reaching for a positive
tabIndex; only0and-1are allowed. - Dialogs and sheets move focus to their first field (or the dialog itself), make the page behind them inert, and return focus to the trigger on close. Base UI does all three.
- When the trigger is gone after the action (a deleted row), move focus to the next row or the list, never to the top of the page.
- Escape closes the innermost overlay only: a select inside a sheet closes the select first.
- Never trap focus outside a modal. A popover or the Copilot panel lets Tab leave.
Roving focus in composite widgets#
Menus, tabs, radio groups, toggle groups, segmented controls and listboxes are one Tab stop. Arrow keys move between their items, and the active item holds tabIndex={0} while the rest hold -1.
J and K in a list
Ticketing and the Contact Center move a cursor through rows with J and K as well as the arrow keys. The cursor is the row's 6% indigo tint, and it moves without animation, because keyboard-driven surfaces don't animate.
Tab into the list, thenJKto move andEnterto open. The cursor moves without animation.
import { Kbd } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function JkList() { const tickets = [ { id: "TCK-1192", title: "Remittance missing for INV-20417", who: "Priya", }, { id: "TCK-1188", title: "W-9 upload fails over 10 MB", who: "Tomás" }, { id: "TCK-1185", title: "Halcyon asks to change bank details", who: "Aisha", }, { id: "TCK-1181", title: "Duplicate payment run for Orchard Street", who: "Wen", }, ]; const rows = React.useRef<(HTMLDivElement | null)[]>([]); const [active, setActive] = React.useState(0); const open = (index: number) => { const ticket = tickets[index]; if (ticket) toast.add({ title: `Opened ${ticket.id}`, description: ticket.title, }); }; const move = (index: number) => { setActive(index); rows.current[index]?.focus(); }; const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const last = tickets.length - 1; const moves: Record<string, number> = { j: Math.min(active + 1, last), ArrowDown: Math.min(active + 1, last), k: Math.max(active - 1, 0), ArrowUp: Math.max(active - 1, 0), Home: 0, End: last, }; const next = moves[event.key]; if (next !== undefined) { event.preventDefault(); move(next); } else if (event.key === "Enter") { event.preventDefault(); open(active); } }; return ( <div className="flex w-full max-w-lg flex-col gap-2 text-left"> <div role="listbox" aria-label="Open tickets" onKeyDown={onKeyDown} className="overflow-hidden rounded-xl bg-card shadow-border" > {tickets.map((ticket, index) => ( // biome-ignore lint/a11y/useKeyWithClickEvents: the listbox handles keys for its options <div key={ticket.id} ref={(node) => { rows.current[index] = node; }} role="option" tabIndex={index === active ? 0 : -1} aria-selected={index === active} onClick={() => move(index)} onDoubleClick={() => open(index)} className={cn( "flex h-9 cursor-default items-center gap-3 border-b border-border px-3 text-13 outline-none last:border-b-0 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", index === active ? "bg-primary/[0.06]" : "hover:bg-surface", )} > <span className="w-16 shrink-0 font-mono text-xs text-muted-foreground"> {ticket.id} </span> <span className="min-w-0 flex-1 truncate text-foreground"> {ticket.title} </span> <span className="text-xs text-muted-foreground"> {ticket.who} </span> </div> ))} </div> <p className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"> Tab into the list, then <Kbd>J</Kbd> <Kbd>K</Kbd> to move and <Kbd>Enter</Kbd> to open. The cursor moves without animation. </p> </div> );}Global keys#
The shell listens for these on every workspace page. Single keys are ignored while focus is in a text field or a dialog is open.
| Keys | Action |
|---|---|
| ⌘K | Open or close the command menu |
| ⌘J | Open or close the Copilot |
| ⌘1 | Switch app: 1 CRM, 2 Agents Platform, 3 Contact Center, 4 Ticketing |
| GH | Go to a page in the current app: G then H for Home, G then I for the inbox. The shortcuts dialog lists the rest |
| ? | Show every shortcut |
| JK | Move the row cursor in lists that support it |
| Esc | Close the innermost overlay |
Single-character shortcuts (?, G, J and K) are skipped while typing, but there is no setting to turn them off or remap them, which WCAG 2.1.4 asks for. Until there is, every single-key action must also be reachable from a visible control or the command menu. See Keyboard.
Visible focus#
Focus Indigo marks every focus stop, in one of two styles. Both show on :focus-visible only, so the keyboard gets a ring and a mouse click doesn't.
A good focus ring
Controls draw an indigo border plus a soft 3px ring, so the field's own edge turns indigo. Links and bare buttons get a 2px solid outline offset by 2px, so it never touches the text.
Control ring
Indigo border plus a 3px ring at 40 to 50%
Outline ring
2px solid outline at a 2px offset, 6px corners
Now press Tab. Each stop shows its ring only for the keyboard, never after a click.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";export function FocusRings() { return ( <div className="grid w-full max-w-2xl gap-4 text-left md:grid-cols-2"> <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex flex-col gap-0.5"> <p className="text-sm font-semibold text-foreground"> Control ring </p> <p className="text-xs text-muted-foreground"> Indigo border plus a 3px ring at 40 to 50% </p> </div> <div className="flex flex-wrap items-center gap-3"> <span aria-hidden="true" className="flex h-8 w-40 items-center rounded-lg border border-ring px-2.5 text-sm text-muted-foreground ring-3 ring-ring/50" > Search suppliers </span> <span aria-hidden="true" className="inline-flex h-8 items-center rounded-lg border border-ring bg-background px-2.5 text-sm font-medium ring-3 ring-ring/40" > Export CSV </span> </div> </div> <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex flex-col gap-0.5"> <p className="text-sm font-semibold text-foreground"> Outline ring </p> <p className="text-xs text-muted-foreground"> 2px solid outline at a 2px offset, 6px corners </p> </div> <div className="flex flex-wrap items-center gap-4"> <span aria-hidden="true" className="rounded-[6px] text-13 font-medium text-foreground underline decoration-border-strong underline-offset-[3px] outline-2 outline-offset-2 outline-ring" > Northwind Freight </span> <span aria-hidden="true" className="rounded-[6px] text-13 text-muted-foreground outline-2 outline-offset-2 outline-ring" > View all invoices </span> </div> </div> <div className="flex flex-col gap-3 rounded-[10px] bg-muted/70 p-4 md:col-span-2"> <p className="text-13 text-muted-foreground"> Now press Tab. Each stop shows its ring only for the keyboard, never after a click. </p> <div className="flex flex-wrap items-center gap-3"> <Input aria-label="Search suppliers" placeholder="Search suppliers" className="w-44 bg-background" /> <Button variant="outline" onClick={() => toast.add({ title: "Exported 128 suppliers" }) } > Export CSV </Button> <a href="#focus" className="text-13 font-medium text-foreground underline decoration-border-strong underline-offset-[3px]" > Northwind Freight </a> </div> </div> </div> );}| Style | Drawn as | Used on |
|---|---|---|
| Control ring | border-ring plus ring-3 ring-ring/40 (buttons) or ring-ring/50 (fields). Destructive buttons use a red ring at 20% | Button, Input, selects, textareas, checkboxes, switches, tabs |
| Outline ring | outline: 2px solid var(--ring) at a 2px offset with 6px corners, from the base layer | Links, bare <button>s, [role=button] and <summary> |
/* packages/canon/src/styles/globals.css: bare links and buttons */:where(a, button, [role="button"], summary):focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 6px;}/* packages/canon/src/components/button.tsx and input.tsx: controls */"outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/40" // Button"outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" // Inputoutline-none with a ring that is at least as visible.outline-none to a custom control and nothing else. Keyboard users lose their place on the page.Where focus drifts
TabsContent sets outline-none although Base UI makes the open panel focusable, so tabbing into a panel shows no ring; add one to panels without controls of their own. The stylesheet also defines a --focus-ring token as the hex #6b97ff that no component reads. DESIGN.md names Focus Indigo (--ring) for every focus indicator, so use --ring.
Contrast and color#
Text needs 4.5:1 against its background. Large text, icons that carry meaning, and the edges and states of controls need 3:1. Color never carries meaning alone.
| Pair | Light | Dark | Minimum | Used for |
|---|---|---|---|---|
| Graphite Ink on White Plane | 17.72:1, passes | 16.55:1, passes | 4.5:1 | All primary text |
| Slate Meta on White Plane | 6.00:1, passes | 7.06:1, passes | 4.5:1 | Meta, column headers, helper text, placeholders |
| Slate Meta on Well Gray | 5.42:1, passes | 6.24:1, passes | 4.5:1 | Meta inside tint wells and on tab tracks |
| Rail Ink on Cool Rail | 11.04:1, passes | 9.72:1, passes | 4.5:1 | Sidebar nav items |
| Indigo Paper on Quiet Indigo | 5.66:1, passes | 4.26:1, below minimum | 4.5:1 | The filled button label |
| Quiet Indigo on White Plane | 5.82:1, passes | 4.30:1, below minimum | 4.5:1 | Link buttons and indigo text |
| Signal Red on White Plane | 5.15:1, passes | 6.03:1, passes | 4.5:1 | Error text and destructive labels |
| Focus Indigo on White Plane | 5.82:1, passes | 5.66:1, passes | 3:1 | The 2px focus outline on links and bare buttons |
| Field Stroke on White Plane | 1.41:1 | 3.14:1 | None | Input borders; the label, not the stroke, identifies the field |
| Faint Slate on White Plane | 3.64:1 | 4.40:1 | None | Empty-cell dashes, header icons, the neutral dot. Never text |
The ratios above are computed from the OKLCH tokens in globals.css on every build, so they move when the tokens do. In Canon's ramp only Display (24px) is large text; treat everything else, Headline included, as normal text at 4.5:1.
Faint Slate is not for text
text-subtle-foreground sits below 4.5:1 in light. It draws empty-cell dashes, column-header icons and the neutral status dot. Meta text is Slate Meta (text-muted-foreground), which passes on the plane and in wells.The Label-Beside-Color Rule
Pairs that fall short
In dark theme, Indigo Paper on Quiet Indigo and Quiet Indigo text on the plane both measure just under 4.5:1. The filled button label is 14px medium, so it needs 4.5:1; until the dark primary darkens, keep indigo text to link buttons and labels that also underline or carry an icon. Field Stroke measures well under 3:1 in light; Canon relies on every field's visible label and its focus ring to identify it, which an expert review should confirm.
Targets and motion#
Every target is big enough to hit, and nothing moves for someone who has asked the system to keep still.
Target size#
A pointer target is at least 24 by 24px. On touch, below 768px, it is at least 32px. Where the visible control stays smaller, extend the hit area with a pseudo-element so it doesn't overlap a neighbor.
| Control | Pointer | Touch (below 768px) |
|---|---|---|
| Buttons and icon buttons | 24, 28, 32 or 36px | 32px or larger |
| Sidebar items and rail buttons | 28px | 36px (max-md:h-9) |
| Grid rows | 32, 36 or 44px | 44px rows or stacked cards |
| Checkbox, radio and switch | The label is the target | The whole row is the target |
| Inline text links | Exempt in running text | Exempt in running text |
- Extend a small control with
relative after:absolute after:-inset-2, as the sidebar's rail toggle does, and drop the extension where neighbors would overlap. - Wrap a checkbox or radio and its text in one
label, so there is no dead zone between them. - Give decorative layers
pointer-events-noneso they never take a click meant for the control beneath.
Reduced motion#
When the system asks for reduced motion, movement stops and only opacity changes remain. Canon handles components globally; hand-written animation follows the same switch.
The Three Springs Rule
spring.fast, spring.moderate and spring.slow, with exits one tier faster that never bounce. No other timings exist except the skeleton reveal, shimmer, the voice orb and meter fills, and keyboard-driven surfaces don't animate.| Surface | Default | Reduced motion |
|---|---|---|
| Popovers, menus, dialogs | Fade and scale from 0.96 to 0.98 | Fade only |
| Sheets | Slide 2.5rem from the edge | Fade only |
| Disclosures and accordions | Height on spring.moderate | Snap open |
| Fluid hover highlight | Glides between items | Fades in place |
| Shimmer and skeleton shimmer | Moving gradient | Static |
| Live status pulse | Ping on the dot | No pulse |
| Command menu, J and K moves | No motion | No motion |
// apps/web/src/components/providers/motion-provider.tsx<MotionConfig reducedMotion="user">{children}</MotionConfig>/* packages/canon/src/styles/globals.css */@media (prefers-reduced-motion: reduce) { *, ::before, ::after { --tw-enter-scale: 1 !important; --tw-enter-translate-y: 0 !important; --tw-enter-blur: 0 !important; scroll-behavior: auto !important; /* …and the matching exit and x values */ } [data-slot="sheet-content"] { transition-property: opacity; } [data-slot="collapsible-content"] { transition: none; } .text-shimmer, .skeleton-shimmer { animation: none; }}Toast runs its own 500ms slide with no motion-reduce variant, so it still moves under reduced motion. Toasts that carry an action or an error must also stay until dismissed.
Forms and announcements#
A field says what it wants before the mistake, and says what went wrong next to where it broke. Changes that happen away from focus are announced, once and politely.
Forms#
Labels on every field, errors tied to the field that failed, focus moved to the first one, and a submit button that stays enabled.
An accessible error
Submit the empty form. Each failing field gets aria-invalid and an error linked through aria-describedby, and focus moves to the first one, so a screen reader reads the label, the invalid state and the fix together.
import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { Spinner } from "@oration/canon/components/spinner";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { CircleAlertIcon } from "lucide-react";import * as React from "react";export function AccessibleError() { const nameId = React.useId(); const emailId = React.useId(); const nameRef = React.useRef<HTMLInputElement>(null); const emailRef = React.useRef<HTMLInputElement>(null); const [errors, setErrors] = React.useState<{ name?: string; email?: string; }>({}); const [pending, setPending] = React.useState(false); const [attempt, setAttempt] = React.useState(0); // After a failed submit renders its errors, focus the first invalid field // so the reader hears its label, its invalid state and the fix together. React.useEffect(() => { if (attempt === 0) return; if (errors.name) nameRef.current?.focus(); else if (errors.email) emailRef.current?.focus(); }, [attempt, errors]); const submit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); const data = new FormData(event.currentTarget); const name = String(data.get("supplier") ?? "").trim(); const email = String(data.get("email") ?? "").trim(); const next: { name?: string; email?: string } = {}; if (!name) next.name = "Enter the supplier's legal name."; if (!/^\S+@\S+\.\S+$/.test(email)) next.email = "Enter an email address like ap@northwindfreight.com."; setErrors(next); if (next.name || next.email) { setAttempt((count) => count + 1); return; } setPending(true); window.setTimeout(() => { setPending(false); toast.add({ type: "success", title: "Supplier added", description: `Remittance advice for ${name} goes to ${email}.`, }); }, 700); }; return ( <form noValidate onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border" > <div className="flex flex-col gap-1.5"> <Label htmlFor={nameId}>Supplier name</Label> <Input ref={nameRef} id={nameId} name="supplier" autoComplete="organization" aria-invalid={errors.name ? true : undefined} aria-describedby={ errors.name ? `${nameId}-error` : undefined } /> {errors.name ? ( <p id={`${nameId}-error`} className="flex items-start gap-1.5 text-xs text-destructive" > <CircleAlertIcon aria-hidden="true" className="mt-px size-3.5" /> {errors.name} </p> ) : null} </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={emailId}>Remittance email</Label> <Input ref={emailRef} id={emailId} name="email" type="email" inputMode="email" autoComplete="email" placeholder="ap@northwindfreight.com" aria-invalid={errors.email ? true : undefined} aria-describedby={`${emailId}-hint${errors.email ? ` ${emailId}-error` : ""}`} /> <p id={`${emailId}-hint`} className="text-xs text-muted-foreground" > Remittance advice goes here after each payment run. </p> {errors.email ? ( <p id={`${emailId}-error`} className="flex items-start gap-1.5 text-xs text-destructive" > <CircleAlertIcon aria-hidden="true" className="mt-px size-3.5" /> {errors.email} </p> ) : null} </div> <div className="flex items-center justify-between gap-3"> <StatusLabel tone={Object.keys(errors).length ? "danger" : "neutral"} className="text-xs text-muted-foreground" > {Object.keys(errors).length ? `${Object.keys(errors).length} ${Object.keys(errors).length === 1 ? "field needs" : "fields need"} attention` : "Draft"} </StatusLabel> <Button type="submit" disabled={pending}> {pending ? <Spinner data-icon="inline-start" /> : null} Add supplier </Button> </div> </form> );}- Every field has a visible label. A placeholder is an example of the format, never the label, because it vanishes on input.
- Set
type,inputModeandautoCompleteso the right keyboard and autofill appear. Never block paste. - Keep submit enabled until the request starts. Validate on submit, focus the first invalid field, then disable the button with a spinner while it saves, keeping its label.
- Errors say what to do: “Enter an email address like ap@northwindfreight.com”, not “Invalid email”. See Writing.
- Use native
disabledfor a control that is truly unavailable. Usearia-disabledwhen it must stay focusable to explain why, and block the action in code.
Field doesn't connect ids for you, so set htmlFor, aria-invalid and aria-describedby by hand. FieldError always renders role="alert", which interrupts the reader; a field error already linked through aria-describedby doesn't need it, so the example above uses a plain paragraph.
Live regions#
Three mechanisms, three jobs. Use the quietest one that works.
| Mechanism | Job | In Canon |
|---|---|---|
aria-describedby | Validation and hints tied to one field | Field errors and helper text |
role="status" (polite) | Updates not tied to a control: results, counts, progress, completion | Toast region, result counts, the save bar's change count, Data state loading, AI loader, Task list progress |
role="alert" (assertive) | Urgent errors not tied to a field, and nothing else | A failed payment run, a dropped call, a lost connection |
- Toasts announce through Base UI's toast region. Write the title so it makes sense heard alone: “Supplier added”, not “Done”.
- Streaming text marks its container
aria-busywhile tokens arrive and announces once when it finishes. Never announce every token. - Counts that tick (credits, results, enriched rows) announce the settled value after a pause, not each step.
- Render the live region empty first and change its text later. A region inserted with its text already in it is often not read.
// A stable, empty region rendered before its text changes.<p role="status" className="sr-only">{announcement}</p>// Streaming text: mark the region busy, then announce once.<div aria-busy={streaming || undefined}>{output}</div><p role="status" className="sr-only">{done ? "Draft ready" : ""}</p>Ship checklist#
Run this before a screen goes in front of a customer. Anything you couldn't check, say so in the review rather than marking it done.
| Check | How to verify | Read more |
|---|---|---|
The skip link is the first Tab stop and lands on main | Load the page, press Tab once, then Enter | Structure |
| One h1, headings in order, landmarks named | List headings and landmarks in the screen reader's rotor or elements list | Structure |
| Every control has a name, a role and its state | Inspect the accessibility tree; tab through with a screen reader on | Names |
| Every flow finishes with the keyboard alone | Unplug the mouse. Open, edit, save and close everything on the screen | Keyboard |
| Focus is visible at every stop and returns after overlays close | Tab through the page; open and close each dialog, sheet and menu | Visible focus |
| Text meets 4.5:1; meaningful icons and control edges meet 3:1 | Use the right tokens; measure custom pairs in both themes | Contrast |
| No status is color alone | View the screen in grayscale | Color |
| Targets are 24px, or 32px on touch | Check icon buttons and row actions at 390px wide | Targets |
| Nothing moves under reduced motion | Turn on Reduce motion in the OS and replay every transition | Reduced motion |
| Errors are tied to fields, focus moves to the first, submit stays enabled | Submit the empty form | Forms |
| Changes away from focus are announced once | Trigger toasts, counts and streaming with a screen reader on | Live regions |
| The page works at 200% zoom and reflows at 320px | Zoom the browser; resize to 320px and look for horizontal scroll | Responsive |