Accessibility
Contrast is enforced when colors are generated, not audited afterwards: every text role is measured against the surface it sits on before it becomes a CSS variable. Focus, motion and keyboard behavior are per component, and this page gathers what each one does, including where it falls short.
Contrast targets
One setting, two levels. The level is part of the theme, so changing it changes what the engine generates rather than what a checker reports.
| Level | Text on its surface | Outline on surface |
|---|---|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 3:1 |
The level lives in the theme provider as level, next to the source color and the light or dark theme, and defaults to AA. The source control in the header sets it for the whole site, and so does the switch below. The outline target stays at 3:1 at both levels: WCAG asks 3:1 of non-text contrast and has no enhanced step for it.
Pairings
Each on-color is measured against the fill it is written for. These are the live ratios for the source in the header.
| Role | Against | Target | Light | Dark |
|---|---|---|---|---|
on-danger | danger | 4.5:1 | 9.4:1Pass | 9.4:1Pass |
on-danger-container | danger-container | 4.5:1 | 15.0:1Pass | 10.5:1Pass |
on-warning | warning | 4.5:1 | 9.0:1Pass | 9.5:1Pass |
on-warning-container | warning-container | 4.5:1 | 15.1:1Pass | 10.2:1Pass |
on-success | success | 4.5:1 | 8.3:1Pass | 10.2:1Pass |
on-success-container | success-container | 4.5:1 | 16.1:1Pass | 10.4:1Pass |
on-info | info | 4.5:1 | 8.7:1Pass | 9.7:1Pass |
on-info-container | info-container | 4.5:1 | 15.4:1Pass | 10.2:1Pass |
on-primary | primary | 4.5:1 | 9.2:1Pass | 9.7:1Pass |
on-primary-container | primary-container | 4.5:1 | 15.2:1Pass | 10.7:1Pass |
on-secondary | secondary | 4.5:1 | 8.4:1Pass | 9.9:1Pass |
on-secondary-container | secondary-container | 4.5:1 | 15.8:1Pass | 10.2:1Pass |
on-primary | secondary | 4.5:1 | 8.3:1Pass | 10.3:1Pass |
on-primary | danger | 4.5:1 | 9.4:1Pass | 9.5:1Pass |
on-surface | surface | 4.5:1 | 19.5:1Pass | 13.8:1Pass |
on-surface-variant | surface-variant | 4.5:1 | 10.1:1Pass | 7.3:1Pass |
on-background | background | 4.5:1 | 19.5:1Pass | 13.8:1Pass |
outline | surface | 3:1 (UI) | 5.7:1Pass | 4.7:1Pass |
18 pairings per mode at AA. Auto-fix moved none of them: every pair clears its target as generated.
The ratio is WCAG 2’s relative-luminance formula, computed by contrastRatio in lib/color.js and rounded to one decimal. Status pairs cover all four statuses, base and container, so a Notification or a Tag is measured the same way as a Button.
Verification
The table above is one source. This is every hue, run through the same engine when the page was built.
- Pairs measured
- 6,912
- Below target
- 0
- Repaired by auto-fix
- 0
- Tightest margin
- 3%
96 source hues, evenly spaced round the wheel, times two modes, times two levels, times 18 pairings each. Every source is taken at saturation 0.6 and value 0.7, so this covers the hue wheel, not every possible hex. Failures are counted with auto-fix off, which is what the engine produces unaided. The pair with the least room is on-surface-variant on surface-variant (dark, AAA, source #7DB347) at 7.2:1 against a 7:1 target.
Focus
A shared ring color, generated like every other role. Three components draw it; the rest show focus their own way.
accent 50 · #664DB46:1 on surfaceaccent 70 · #A08AF76.7:1 on surface--graphite-focus is the primary family’s focus state: its own ramp at tone 50 in light and 70 in dark, shown here at the default source. Across the sweep its lowest contrast against surface or background is 5.3:1, so it clears the 3:1 non-text target at every hue tested.
Three components draw it as their focus ring (read from their stylesheets): Accordion, Breadcrumb, Navigation Menu. The rest show focus another way, in their own words:
| Component | Focus |
|---|---|
| Button | The kit’s ring, on :focus-visible: 2px inside the edge in the style’s own focus colour (--graphite-primary-focus, -secondary-focus or -danger-focus), with a 1px line of the page background inside it on the filled styles. A mouse click does not show it; a keyboard does. In forced-colours mode a system ring replaces it. |
| Button Group | Each Button draws its own focus ring. The group draws nothing and clips nothing. |
| Checkbox | A 2px ring in --graphite-primary-focus on the 20px frame, 1.5px clear of the box, on keyboard focus only. In error it sits outside the danger ring rather than replacing it. |
| Checkbox group | Each box takes Checkbox’s own 2px ring. |
| Contained list | A link wrapped round the row draws a 2px primary-focus ring inside it when it takes focus. The trailing control brings its own ring. |
| Data table | The sort button fills its cell, and its ring is a 2px --graphite-primary-focus inside it. |
| Date picker | Days and the month buttons take a 2px --graphite-primary-focus ring inside; the field takes Text input’s. |
| Dropdown | A 2px --graphite-primary-focus ring on the trigger, and inside the row the keyboard is on. |
| File uploader | The drop box takes a 2px --graphite-primary edge; the remove glyph a 2px --graphite-primary-focus ring, 4 out. |
| Link | A 1px --graphite-primary-focus ring outside the label, in --graphite-background on the inverse fill. |
| Menu | Opening the menu moves focus to an item. Every item is tabindex="-1", so the whole menu is one Tab stop and the arrow keys do the rest. A focused item takes a 2px primary-focus ring inside its edge. When the menu closes, focus goes back to the trigger. |
| Menu buttons | Each trigger takes Button’s focus ring; the items take Menu’s. |
| Modal | On open, focus moves to the dialog itself, which takes it without a ring because it is a container, not a control. On close, focus returns to the element that opened it, every time. |
| Number input | The field takes Text input’s ring; each button its own. |
| Pagination | The items take a 2px --graphite-primary-focus ring inside their square; the arrows and pickers bring their own. |
| Password input | The field takes Text input’s ring; the toggle a 2px --graphite-primary-focus ring. |
| Popover | A modal Popover takes focus when it opens and keeps Tab inside it. A non-modal one leaves focus where it was. Either way, focus returns to the trigger when it closes. The content can be controlled from outside: a re-render while it is open leaves focus alone. |
| Radio button group | A 2px ring in --graphite-primary-focus just outside the 20px frame, on keyboard focus only. The hit area reaches 6px past the frame, so it stays 32px. |
| Search | The field shell’s 2px --graphite-primary-focus ring, inside the field, while the input has focus. |
| Select | A 2px ring inside the trigger in primary’s focus colour while the field has focus, replacing the browser outline. In error it stays danger. |
| Slider | The handle grows and turns --graphite-primary, with the fill; a range handle takes a 1px --graphite-primary-focus ring. |
| Tabs | The focus ring is --graphite-primary-focus, inset so it stays inside the tab. The arrow keys move focus and the selection together, skipping disabled tabs, so the ring is always on the selected tab. |
| Tag | The interactive forms draw the kit’s Tag focus: a 2px primary ring 1px outside the pill, and a 1px primary ring inside the close button. |
| Text area | The kit’s 2px ring inside the field on :focus-visible, drawn by the browser’s real focus rather than a prop. |
| Text input | Focus is the browser’s own, drawn as the kit’s 2px ring inside the field through :focus-within. Nothing sets it by hand, so the ring and the real focus cannot disagree. |
| Time picker | Each field takes its own focus ring, Text input’s and Select’s. |
| Toggle | A 2px ring in --graphite-primary-focus around the track, on keyboard focus only. In error it sits outside the danger ring rather than replacing it. |
| Tooltip | It never takes focus and never traps it. The trigger keeps focus the whole time. |
| Tree view | A 2px ring inside the row, through primary’s focus step. The tab stop follows the last node focused, else the selected one, else the first. |
Motion
Short, shared, and switched off per component under prefers-reduced-motion.
| Token | Value |
|---|---|
--graphite-motion-indeterminate | 1400ms |
--graphite-motion-indeterminate-ease | linear |
--graphite-motion-ease | cubic-bezier(0.16, 1, 0.3, 1) |
--graphite-motion-fast | 120ms |
--graphite-motion-base | 240ms |
The tokens do not change under reduced motion. Each component that moves carries its own prefers-reduced-motion block, and so do the page-level effects in globals.scss (the scroll reveal, the hero parallax and the cover fade). Most drop the animation outright; the Progress bar slows its sweep instead, so an indeterminate bar still reads as working.
| Component | Reduced-motion rule | What moves |
|---|---|---|
| Accordion | Yes | The open transition respects prefers-reduced-motion and falls back to an instant change. |
| Button | Yes | Colour transitions and the 1px press displacement both switch off under prefers-reduced-motion. |
| Data table | Yes | — |
| Dropdown | Yes | — |
| File uploader | Yes | — |
| Menu | Yes | The list fades in on the fast motion step and appears at once under prefers-reduced-motion. |
| Menu buttons | Yes | — |
| Modal | Yes | The scrim and panel fade in together on the fast motion step, opacity only. Under prefers-reduced-motion it appears at once. It does not animate out. |
| Notification | No | Nothing animates on entry or exit, so there is nothing for reduced motion to switch off. |
| Popover | Yes | It fades in on the fast motion step and appears at once under prefers-reduced-motion. It does not animate out. |
| Progress bar | Yes | The sweep is a quarter of the track at a constant speed, as the kit runs it. Under prefers-reduced-motion the width stops animating and the sweep slows to 3 seconds rather than stopping, so the bar still reads as working. |
| Tabs | No | Nothing animates. The indicator moves instantly, so there is nothing for reduced motion to switch off. |
| Toast | Yes | — |
| Toggle | Yes | The track fill and the thumb travel run on --graphite-motion-fast and stop animating under prefers-reduced-motion. |
| Tooltip | Yes | It fades in on the fast motion step, opacity only, and appears at once under prefers-reduced-motion. |
Keyboard
The keyboard note from every governed component's page. Each links to that page's full Accessibility section.
| Component | Note | Behavior |
|---|---|---|
| Accordion | Keyboard | Tab moves between triggers. Enter and Space both toggle the panel the focus is on. Nothing traps focus inside a panel. |
| Breadcrumb | Focus | Links and the overflow button draw the kit’s 1px --graphite-focus ring outside the label on keyboard focus. |
| Button | Keyboard | A native button element, so Enter and Space both activate it. The type defaults to button, so it will not submit a form unless you ask it to. |
| Button Group | Keyboard | Each Button stays a separate tab stop in source order. The group adds no arrow-key navigation and traps nothing. |
| Checkbox | Keyboard | It is a native checkbox, so Tab reaches it and Space toggles it. Clicking the label toggles it too. |
| Checkbox group | Keyboard | Tab reaches each box in turn; Space toggles it. Read-only boxes stay reachable and do not change. |
| Contained list | Keyboard | The row itself is never focusable, even when interactive: the prop is visual only. Keyboard access comes from the link that wraps the row or the control in its trailing slot. |
| Data table | Keyboard | Sortable headers are buttons, so Tab reaches them and Enter or Space sorts. Rows are not focusable; put links or buttons in cells when a row should act. |
| Date picker | Keyboard | Arrow Down in the field, or the button, opens the calendar on the selected day. Arrows move by day and week, Home and End to the week’s ends, Page Up and Page Down by month, with Shift by year. Enter picks; Escape closes and returns to the button. |
| Dropdown | Keyboard | Arrow Down, Arrow Up, Enter or Space opens. Arrows move, Home and End go to the ends, Enter or Space chooses, Escape closes. Typing jumps to the next option that starts with what was typed. |
| File uploader | Keyboard | Tab reaches the trigger and each remove button. Enter or Space on the trigger, or on the focused drop box, opens the picker. |
| Link | Keyboard | Tab reaches it and Enter follows it, as any anchor. |
| Menu | Keyboard | Enter, Space or Down Arrow on the trigger opens the menu on its first item, and Up Arrow opens it on its last. Down and Up move between items and wrap at the ends; Home and End jump to the first and last. Enter or Space chooses an item. Escape closes the menu, and so does Tab, which then carries on to whatever follows the trigger. |
| Menu buttons | Keyboard | Enter, Space or Arrow Down opens the menu on its first item; Arrow Up on its last. Arrows move, Escape closes and returns focus to the trigger, Tab leaves. |
| Modal | Keyboard | Tab and Shift+Tab cycle through the dialog’s controls and wrap at either end. Escape closes it unless dismissible is false, and so does the close button, named “Close”. |
| Navigation Menu | Keyboard | Plain links in lists. Tab moves through them in order, nested items included; there is no arrow-key handling to learn. |
| Notification | Keyboard | Without onClose or action, nothing takes focus. The action and the close are each one tab stop; the close is named “Dismiss notification”, with Button’s ghost focus ring. Enter or Space dismisses. Escape does nothing: this is inline content, not an overlay, so there is no focus to trap or return. Once it closes, the button that held focus is gone, so move focus somewhere sensible in onClose. |
| Number input | Keyboard | Arrow Up and Down step and clamp, as the buttons do. Tab reaches the field, then each button. |
| Overlay | Escape | Open overlays stack in the order they opened, and Escape closes only the top one, so each press peels off one layer. A Modal with dismissible off still takes the press, so nothing beneath it closes either. |
| Pagination | Keyboard | Tab reaches the arrows, the items and the pickers in order. The ellipsis is a native select, so the arrow keys move through the pages it hides. |
| Password input | Keyboard | Tab reaches the field, then the toggle; Enter or Space toggles. Focus stays on the toggle. |
| Popover | Keyboard | Enter and Space on the trigger open it. The panel follows the trigger in the document, so Tab moves into it. Escape closes it from anywhere. |
| Progress bar | Roles | role="progressbar" with aria-valuemin 0, aria-valuemax 100 and aria-valuenow set to the current value. |
| Radio button group | Keyboard | Native radios sharing a name: Tab enters the group once, the arrow keys move between options and select as they go, and Tab leaves. |
| Search | Keyboard | Escape clears the value, and on an empty expandable field collapses it. Enter calls onSubmit. The clear is a button, named “Clear search”, and returns focus to the field. |
| Select | Keyboard | Tab reaches the trigger. Arrow Down, Arrow Up, Enter or Space open the list; the arrows, Home and End move; Enter or Space chooses; Escape closes; typing jumps to a matching option. Focus stays on the trigger throughout. |
| Slider | Keyboard | Arrow keys step by step; Page Up and Page Down by larger steps; Home and End go to the ends. The two range handles cannot cross. |
| Tabs | Keyboard | Only the selected tab is in the Tab order. Left and Right (Up and Down when vertical) select the previous or next tab and wrap at the ends. Home and End select the first and last. Selection follows focus, so the panel changes as soon as a tab is reached. |
| Tag | Focus | The interactive forms draw the kit’s Tag focus: a 2px primary ring 1px outside the pill, and a 1px primary ring inside the close button. |
| Text area | Focus | The kit’s 2px ring inside the field on :focus-visible, drawn by the browser’s real focus rather than a prop. |
| Text input | Focus | Focus is the browser’s own, drawn as the kit’s 2px ring inside the field through :focus-within. Nothing sets it by hand, so the ring and the real focus cannot disagree. |
| Time picker | Keyboard | Tab moves through the three. The selects open with the keyboard as any native select does. |
| Toast | Live region | The host is a polite live region named “Notifications”, so each toast is announced as it arrives. An error is an alert and interrupts. |
| Toggle | Keyboard | Tab reaches it and Space flips it, as with any native checkbox. Clicking the label flips it too. |
| Tooltip | Keyboard | It opens when the trigger takes focus, after the same delay as hover, and closes on blur or Escape. |
| Tree view | Keyboard | One tab stop. Arrow Down and Up move between the nodes that are showing; Right opens a branch or steps into it; Left closes it or steps out to the parent; Home and End go to the ends; Enter and Space activate; a typed letter jumps to the next node that starts with it. |
| Typography | Roles | Display and heading-1 render h1, heading-2 to heading-4 render h2 to h4, body renders p and caption renders span. Screen reader users navigate by those headings. |
Where a page has no Keyboard note, the row shows the closest one: Overlay files its keys under Escape, Breadcrumb and the text fields under Focus, and a component with nothing to operate shows its first note. Native elements (Button, Checkbox, Radio button group, Select, Toggle) bring the platform’s keyboard behavior with them, which is why their rows are short.
Color never works alone
Status hue is pinned, so red still reads as danger whatever the source. That same pinning means a source on a status hue collapses the two.
primary #880C06danger #880D06Light theme, with the source set to #880C06 (the danger role the default source produces). This is inherent to pinning the status hue, not a bug. The rule that follows is that status is never carried by color alone: a Notification shows status through its container tone, a status-colored edge and its words, and a Tag’s label has to say what its color only reinforces.
Known gaps
What is still open. The first pass of these pages found more; overlay stacking, Tabs and Menu keyboard support, the Breadcrumb focus ring, form error borders and the font fallback have since been fixed.
- Menu has no sub-menus. The contract names the slot, but nested menus are not built, so a Menu is one level deep. See Menu.
- Breadcrumb’s overflow expands in place. The collapsed crumbs are reachable, but the kit opens them in a menu and the code shows them inline. See Breadcrumb.
- An interactive Contained list row is visual only.
interactiveadds hover and a pointer; the row takes no focus, so the caller supplies the link or button inside it. See Contained list. - The sweep is a sample. It covers the hue wheel at one saturation and value. A source far from that (very pale, very dark) is not in it, which is why the pairings table above measures whatever source is actually set.
Next steps
Where the numbers on this page come from, and what enforces them.
- Theming
How one source color becomes the roles measured above.
- Tokens
Every role and state measured above, as a variable.
- Components
Every governed component, each with its own Accessibility section.
