Accessibility
The components own roles, keyboard paths, focus rings, and announcements, so a screen built only from tenet-ui components and semantic tokens starts accessible. App code keeps it that way by naming every control, keeping the DOM order equal to the visual order, and not adding motion or colour outside the system. These are the checks an audit applies.
- Layer
- Guideline
- Rules
- 15 numbered
- Examples
- 11 do and don’t
- Updated
- Source
- accessibility.md
The components own
- Roles
- Keyboard paths
- Focus rings
- Announcements
App code keeps it that way
- Name every control
- DOM order equals visual order
- No motion outside the system
- No colour outside the system
Keys the components handle
Conformance target
Section titled “Conformance target”tenet-ui aims for WCAG 2.2 level AA. This is a design target, not a conformance claim: there has been no third-party audit, and conformance belongs to a finished page, which also depends on the app code around the components. What the components guarantee is listed in the rules below; what app code must do is in the same rules and in Content and Color.
- Every interactive element has an accessible name: visible text children on
ButtonandLink;aria-labelonIconButton;FormControl.Label(oraria-label) on every field;labelonCheckbox,Switch,Radio,RadioGroup,Spinner;aria-labelonTabs.List,SegmentedControl,Menu,Popover,Calendar,ProgressBar, and any secondBreadcrumbsorPaginationon a page. A placeholder is not a name. - Focus order equals visual order. Layout is done with
StackandGridin reading order;order,flex-direction: row-reverse, or positivetabIndexare never used to rearrange what is on screen. - Focus is visible. Every component ships a
:focus-visiblering: a--borderWidth-thickoutline in--borderColor-accent-emphasison buttons, links, tabs, and choice controls, and--borderColor-accent-emphasisplus--shadow-focuson fields. App code never setsoutline: noneor overrides the ring. - Text colour pairs come from the AA table in color.md. Text on the ochre solid is
--fgColor-onAttention;--fgColor-onEmphasisis never placed on--bgColor-emphasisor--bgColor-attention-emphasis. - Colour is never the only carrier of meaning. Status is a word (
Tag), a message (FormControl.Validation,Banner), or an icon withlabelalongside text. - Icons are decorative (
aria-hidden) unless they are the only carrier of meaning, in which case they takelabel(role="img"). AnIconButton’s icon is always decorative because the button hasaria-label. See iconography.md. - Motion honours
prefers-reduced-motion:Spinner,Skeleton,ProgressBar,Toast,Tooltip, andAccordionreduce or drop their animation under it. App code adds noanimationortransitionof its own, and any motion it must add is wrapped in@media (prefers-reduced-motion: no-preference). Dialogis the only modal. It traps Tab and Shift+Tab, moves focus in on open, returns focus to the trigger on close, locks body scroll, closes on Escape and backdrop, and setsaria-hiddenon the rest of the page (except theToastlayer). Itstitleis required because it is the dialog’s name;descriptionis wired asaria-describedby. App code never builds a fixed-position overlay.Popover,Menu,Select,DatePicker, andTooltipare non-modal and portal todocument.body. Theirtriggermust be a focusable element (aButtonorIconButton), never a<div>or<span>.- Tables are real tables.
Table.Headerrenders<th scope="col">; a sortable header setssortDirectionso the sort control is a real<button>andaria-sortreflectsascendingordescending(omitted whennone). Rows are never rebuilt from<div>s. - Forms are wired through
FormControl: it links the label withhtmlFor, the caption and validation witharia-describedby, and setsaria-invalidandrequired. App code never assigns these attributes by hand, and never clobbers a component’saria-describedby(components merge a caller’s value). - Announcements:
Toastrenders into a labelledrole="region"live area; a danger toast orBanner variant="danger"isrole="alert", the other variants arerole="status".Spinnerisrole="status"with a hiddenlabel.Skeletonisaria-hidden, so the surrounding container carriesaria-busy="true"and a hidden status text. Roles are never overridden to make a message louder. - Headings form an outline: one
Heading level={1}per page, ranks in order,asused to keep rank honest when size differs. Landmarks are not duplicated without distinct labels. Linknavigates andButtonacts; aLinknever has anonClickthat performs an action, and aButtonnever navigates.Link externaladdsrel,target, and a hidden “(opens in new tab)”.- Touch and pointer targets come from
size:medium(32px) is the default control height,small(28px) is for dense rows only, andlarge(40px) is for primary actions on touch-first layouts. Controls are never shrunk belowsmall.
Keyboard contracts
Section titled “Keyboard contracts”| Family | Components | Keyboard the component provides |
|---|---|---|
| Action | Button, IconButton, Link | Tab; Enter (and Space for buttons) activates |
| Field | TextInput, Textarea, NumberInput, DatePicker | Native editing; NumberInput arrows step within min/max; DatePicker opens on Enter/Space, Escape closes and refocuses |
| Choice | Checkbox, Switch, RadioGroup, SegmentedControl | Space toggles; arrows move within a radio group or segmented control (selection follows focus), Home/End jump |
| Listbox | Select | Up/Down (skip disabled, wrap), Home/End, typeahead, Enter/Space select, Escape and Tab close |
| Menu | Menu | Up/Down, Home/End, Enter/Space invoke, Escape closes and returns focus to the trigger, Tab closes |
| Tabs | Tabs | Left/Right move and select, Home/End jump, Tab enters the panel |
| Disclosure | Accordion | Enter/Space toggle, Up/Down/Home/End move between headers |
| Overlay | Dialog, Popover, Tooltip | Escape closes; Dialog traps Tab; Popover closes when focus leaves; Tooltip shows on focus |
| Grid | Calendar | Arrows move by day, Home/End by week edge, PageUp/PageDown by month, Enter/Space select |
| Navigation | Breadcrumbs, Pagination, Table.Header (sortable) | Real links and buttons; Tab and Enter |
How it is tested
Section titled “How it is tested”| Check | What it covers | Where |
|---|---|---|
| axe-core over every Storybook story, in both themes | Names, roles, ARIA, contrast as rendered | scripts/check-a11y.mjs, run in CI |
| Contrast pairs verified at build time | Every documented text and background pair, both themes, 4.5:1 or 3:1 | scripts/build-tokens.mjs; the build fails on a regression |
| Storybook play functions | Clicks and keyboard paths (arrows, Enter, Escape) on 20 interactive components | *.stories.tsx |
| Per-component review flag | A hand-kept a11yReviewed flag, shown on every component page; all 41 components are reviewed | <Name>.meta.json |
There is no recorded screen-reader test matrix (which screen readers and browsers were tried, and when).
Known limitations
Section titled “Known limitations”- Forced-colors mode (Windows High Contrast) is not supported or tested. Fields draw focus with
border-colorandbox-shadow: var(--shadow-focus), which forced-colors mode overrides, so the field focus indicator is likely to disappear there. Buttons, links, tabs and choice controls useoutline, which forced-colors mode keeps (in a system colour). - Right-to-left languages are not supported. Component CSS uses physical
leftandright, not logical properties. - Sizes are in px. Browser zoom scales everything, which meets SC 1.4.4 (Resize text), but text does not follow a change to the browser’s default font size.
Switchstill slides its thumb underprefers-reduced-motion: reduce. See Motion.- Target size: the smallest control (
size="small", 28px) meets SC 2.5.8 (Target Size, Minimum: 24px). It does not meet the 44px AAA target (SC 2.5.5).
Do / Don’t
Section titled “Do / Don’t”Do
<IconButton icon={<TrashIcon />} aria-label="Delete story" /><FormControl required><FormControl.Label>Email</FormControl.Label><TextInput type="email" /><FormControl.Validation variant="error">Enter a valid email address.</FormControl.Validation></FormControl>Name, required, and error all wired by the control
<Tabs.List aria-label="Story sections">And <Menu aria-label="Story actions" trigger={<IconButton icon={<MoreHorizontalIcon />} aria-label="Story actions" />} items={items} />
<Table.Header align="end" sortDirection={sort.dir} onClick={toggleSort}>Views</Table.Header>A real sort button with aria-sort
<div aria-busy="true"><span className="sr-only">Loading stories</span><Skeleton variant="text" /></div>The container announces; sr-only is the app’s visually hidden utility
Don’t
<div onClick={open} className="button">Open</div>No role, no keyboard, no name
<Button style={{ outline: 'none' }}>Focus ring removed
<Stack direction="horizontal" style={{ flexDirection: 'row-reverse' }}>Visual order no longer matches focus order
<Tooltip content="Delete"><span>🗑</span></Tooltip>A non-focusable trigger; keyboard users never see the tooltip
<div style={{ position: 'fixed', inset: 0 }}>…</div>As a modal (no trap, no scroll lock, no Escape, no restore; use Dialog)
<div className="card" style={{ transition: 'transform .3s' }} />With no reduced-motion guard (motion that ignores the user’s setting)