Section 3 · Components
States and Accessibility, Built In
22 min3 min readCode playground
Lesson at a glance
Build every interaction state and accessibility behavior into components by default, including visible focus with :focus-visible, disabled and pressed states, and ARIA patterns that follow the WAI-ARIA Authoring Practices.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Northwind’s 2021 Button looked perfect in every screenshot, because screenshots only show the default state. In production it had no visible keyboard focus, its disabled style failed contrast on the Tidewater brand, and its “loading” state made the button shrink and the layout jump. None of that was in the Figma file. Components fail in their states, so the system has to design, build and test all of them.
The state matrix
Every interactive component gets a state matrix: one row per variant, one column per state. For Button:
| State | Trigger | Built with |
|---|---|---|
| Hover | Pointer over it | :hover |
| Focus visible | Keyboard focus | :focus-visible |
| Active | Being pressed | :active |
| Disabled | Not available | disabled or aria-disabled="true" |
| Loading | Action in progress | aria-busy, stable width |
| Pressed | Toggle is on | aria-pressed="true" |
Northwind designs every cell in Figma (as component variants) before anyone writes code, and the visual tests in Section 4 render every cell in every theme. When a contribution arrives with only default and hover designed, the review sends it back.
Focus that people can see
Keyboard users need to see where focus is; WCAG 2.2 requires it at level AA (2.4.7, Focus Visible). Mouse users usually don’t want a ring after clicking. :focus-visible resolves this: browsers apply it when focus should be visible, typically after keyboard navigation, and not after most mouse clicks.
:root {
--nw-focus-ring-color: var(--nw-blue-600);
--nw-focus-ring-width: 2px;
--nw-focus-ring-offset: 2px;
}
.nw-button:focus-visible {
outline: var(--nw-focus-ring-width) solid var(--nw-focus-ring-color);
outline-offset: var(--nw-focus-ring-offset);
}The ring is built from tokens, so dark mode and Tidewater can adjust it, and every component uses the same ring. It is an outline, not a box-shadow, because forced-colors modes like Windows contrast themes remove box shadows but keep outlines.
Disabled, two ways
The disabled attribute removes a button from the tab order and from activation. That’s right for most cases, but it creates a problem: keyboard and screen reader users can’t reach the button to learn why it is disabled.
When you need an explanation, use aria-disabled="true" instead. The button stays focusable and is announced as unavailable; you connect the reason with aria-describedby; and because the browser won’t block the click for you, the component must:
button.addEventListener('click', (event) => {
if (button.getAttribute('aria-disabled') === 'true') {
event.preventDefault();
event.stopImmediatePropagation();
}
});The system’s Button does this internally, so teams set one prop and get the behavior. That’s the whole point of building accessibility in: forty teams won’t each remember stopImmediatePropagation, but one component can.
Toggles, targets and patterns
A button that switches something on and off is a toggle button. Mark its state with aria-pressed="true" or "false", and style that attribute so the visual and announced states can’t drift apart:
.nw-button[aria-pressed="true"] {
--nw-button-bg: var(--nw-color-action-bg-subtle);
--nw-button-border: var(--nw-color-action-bg);
}Size matters too: WCAG 2.2 success criterion 2.5.8 asks for pointer targets of at least 24 by 24 CSS pixels at level AA, with some exceptions. Northwind’s smallest control height is 32px, enforced by the sm size tokens.
For anything beyond a button, use native elements first: <button>, <dialog>, <details>, <select>. When you build a custom widget such as tabs, a menu button or a combobox, follow the matching pattern in the WAI-ARIA Authoring Practices Guide: its roles, states and keyboard interactions. Users of assistive technology expect those behaviors, and the guide documents them precisely.
In the exercise, you fix a button set that hides keyboard focus and ignores its own disabled state. Next, you write the documentation that tells teams how to use all of this.
Key takeaways
- Design and build a full state matrix for every interactive component, including focus, disabled, loading and pressed, not just default and hover.
- Style keyboard focus with
:focus-visibleand an outline from a token; never remove the outline without a visible replacement. - Use native elements first and follow the WAI-ARIA Authoring Practices for custom widgets like tabs, menus and comboboxes.
aria-disabled="true"keeps a control focusable so you can explain why it is unavailable, but you must block its action yourself.- When accessibility lives in the component, 40 teams get it for free; when it lives in the docs, most of them miss it.