Skip to content

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.

What Canon gives you and what stays with you
AreaCanon does itYou do it
StructureThe shell renders the skip link, main and a labelled sidebar nav.One h1 per page, headings in outline order, landmarks for any extra regions.
NamesBase 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.
KeyboardMenus, 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.
FocusEvery component draws a visible ring on :focus-visible.Never remove a ring without a replacement.
ColorTokens 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.
MotionMotionConfig 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.

How the shell renders it
// 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>
Landmarks in the shell
RegionElementName
Skip link<a href="#main">, first in the DOMSkip to content
Sidebar<nav> inside the sidebar{app} navigation, such as CRM navigation, so two navs never share a name
ContentSidebarInset as main, with id="main" and tabIndex={-1}Implicit. There is exactly one main
Copilot<aside> panelCopilot
Page sections<section aria-labelledby> pointing at the section's headingThe visible heading
  • One h1 per 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.

A labelled icon button

Each icon-only button carries an aria-label that names the action and the object, and a tooltip with the same words so sighted pointer and keyboard users can read it too. Hover or Tab to see the name a screen reader hears.

Halcyon PackagingINV-20431

Accessible nameHover or Tab to a button

import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArchiveIcon, CopyIcon, DownloadIcon } from "lucide-react";import * as React from "react";export function LabelledIconButton() {    const [announced, setAnnounced] = React.useState("");    const actions = [        {            label: "Download invoice PDF",            icon: DownloadIcon,            done: "Downloaded INV-20431.pdf",        },        { label: "Copy invoice ID", icon: CopyIcon, done: "Copied INV-20431" },        {            label: "Archive invoice",            icon: ArchiveIcon,            done: "Archived INV-20431",        },    ];    return (        <div className="flex w-full max-w-md flex-col gap-3">            <div className="flex items-center gap-3 rounded-xl bg-card px-4 py-3 shadow-border">                <span className="flex min-w-0 flex-1 flex-col">                    <span className="truncate text-13 font-medium text-foreground">                        Halcyon Packaging                    </span>                    <span className="font-mono text-xs text-muted-foreground">                        INV-20431                    </span>                </span>                <div className="flex items-center gap-1">                    {actions.map((action) => (                        <Tooltip key={action.label}>                            <TooltipTrigger                                render={                                    <Button                                        type="button"                                        variant="ghost"                                        size="icon"                                        aria-label={action.label}                                        onFocus={() =>                                            setAnnounced(action.label)                                        }                                        onMouseEnter={() =>                                            setAnnounced(action.label)                                        }                                        onClick={() =>                                            toast.add({ title: action.done })                                        }                                    />                                }                            >                                <action.icon aria-hidden="true" />                            </TooltipTrigger>                            <TooltipContent>{action.label}</TooltipContent>                        </Tooltip>                    ))}                </div>            </div>            <p className="flex items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13 text-muted-foreground">                <span>Accessible name</span>                <span className="font-medium text-foreground">                    {announced                        ? `“${announced}”, button`                        : "Hover or Tab to a button"}                </span>            </p>        </div>    );}
Do. Name the action and the object: aria-label="Archive invoice", matched by the tooltip. The icon is aria-hidden.
button
Don't. Leave an icon-only button unnamed, or name it after the picture. A screen reader announces “button” and nothing else.
Where each control's name comes from
ControlName sourceExample
Text field, select, textareaA visible Label with htmlFor, or sr-only when the context labels itRemittance email
Icon-only buttonaria-label plus a Tooltip with the same wordsDownload invoice PDF
Button with textIts text. Don't add an aria-label that says something elseApprove payment run
Dialog and sheetIts DialogTitle, which Base UI links as the accessible name. A dialog without a title is unnamedDelete supplier?
Row checkboxaria-label naming the rowInclude INV-20417 from Northwind Freight
Search and command inputA label, even when the placeholder says the same thingSearch suppliers
Decorative icon, avatar, illustrationNone. 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; only 0 and -1 are 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.

TCK-1192Remittance missing for INV-20417Priya
TCK-1188W-9 upload fails over 10 MBTomás
TCK-1185Halcyon asks to change bank detailsAisha
TCK-1181Duplicate payment run for Orchard StreetWen

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.

Keyboard interactions
KeysAction
⌘KOpen or close the command menu
⌘JOpen or close the Copilot
⌘1Switch app: 1 CRM, 2 Agents Platform, 3 Contact Center, 4 Ticketing
GHGo 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
JKMove the row cursor in lists that support it
EscClose 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>    );}
The two focus styles
StyleDrawn asUsed on
Control ringborder-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 ringoutline: 2px solid var(--ring) at a 2px offset with 6px corners, from the base layerLinks, bare <button>s, [role=button] and <summary>
Where the rings come from
/* 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" // Input
Do. Keep the component's ring, or replace outline-none with a ring that is at least as visible.
Don't. Add 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.

Contrast of Canon's color pairs, measured from globals.css
PairLightDarkMinimumUsed for
Graphite Ink on White Plane17.72:1, passes16.55:1, passes4.5:1All primary text
Slate Meta on White Plane6.00:1, passes7.06:1, passes4.5:1Meta, column headers, helper text, placeholders
Slate Meta on Well Gray5.42:1, passes6.24:1, passes4.5:1Meta inside tint wells and on tab tracks
Rail Ink on Cool Rail11.04:1, passes9.72:1, passes4.5:1Sidebar nav items
Indigo Paper on Quiet Indigo5.66:1, passes4.26:1, below minimum4.5:1The filled button label
Quiet Indigo on White Plane5.82:1, passes4.30:1, below minimum4.5:1Link buttons and indigo text
Signal Red on White Plane5.15:1, passes6.03:1, passes4.5:1Error text and destructive labels
Focus Indigo on White Plane5.82:1, passes5.66:1, passes3:1The 2px focus outline on links and bare buttons
Field Stroke on White Plane1.41:13.14:1NoneInput borders; the label, not the stroke, identifies the field
Faint Slate on White Plane3.64:14.40:1NoneEmpty-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

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.

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.

MatchedMissing W-9Payment failed
Do. Pair every status color with a label, so the state reads in grayscale and to a screen reader.
INV-20417INV-20431INV-20438
Don't. Show status as a colored dot alone. Red and green are hard to tell apart for about one in twelve men, and a dot has no name.

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.

24pxExtra-small icon button, the desktop floor
32pxDefault control, and the touch floor
Net 30
16px box, 40px hitA checkbox whose label is the target
16pxA bare icon with no padding or extension
Sizes that meet the floor
ControlPointerTouch (below 768px)
Buttons and icon buttons24, 28, 32 or 36px32px or larger
Sidebar items and rail buttons28px36px (max-md:h-9)
Grid rows32, 36 or 44px44px rows or stacked cards
Checkbox, radio and switchThe label is the targetThe whole row is the target
Inline text linksExempt in running textExempt 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-none so 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

Motion uses 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.
What changes under reduced motion
SurfaceDefaultReduced motion
Popovers, menus, dialogsFade and scale from 0.96 to 0.98Fade only
SheetsSlide 2.5rem from the edgeFade only
Disclosures and accordionsHeight on spring.moderateSnap open
Fluid hover highlightGlides between itemsFades in place
Shimmer and skeleton shimmerMoving gradientStatic
Live status pulsePing on the dotNo pulse
Command menu, J and K movesNo motionNo motion
The global switch
// 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.

Remittance advice goes here after each payment run.

Draft
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, inputMode and autoComplete so 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 disabled for a control that is truly unavailable. Use aria-disabled when 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.

Which mechanism announces what
MechanismJobIn Canon
aria-describedbyValidation and hints tied to one fieldField errors and helper text
role="status" (polite)Updates not tied to a control: results, counts, progress, completionToast 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 elseA 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-busy while 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 status region
// 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.

Accessibility checks before shipping
CheckHow to verifyRead more
The skip link is the first Tab stop and lands on mainLoad the page, press Tab once, then EnterStructure
One h1, headings in order, landmarks namedList headings and landmarks in the screen reader's rotor or elements listStructure
Every control has a name, a role and its stateInspect the accessibility tree; tab through with a screen reader onNames
Every flow finishes with the keyboard aloneUnplug the mouse. Open, edit, save and close everything on the screenKeyboard
Focus is visible at every stop and returns after overlays closeTab through the page; open and close each dialog, sheet and menuVisible focus
Text meets 4.5:1; meaningful icons and control edges meet 3:1Use the right tokens; measure custom pairs in both themesContrast
No status is color aloneView the screen in grayscaleColor
Targets are 24px, or 32px on touchCheck icon buttons and row actions at 390px wideTargets
Nothing moves under reduced motionTurn on Reduce motion in the OS and replay every transitionReduced motion
Errors are tied to fields, focus moves to the first, submit stays enabledSubmit the empty formForms
Changes away from focus are announced onceTrigger toasts, counts and streaming with a screen reader onLive regions
The page works at 200% zoom and reflows at 320pxZoom the browser; resize to 320px and look for horizontal scrollResponsive