EduFlow

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.

  1. 1The state matrix
  2. 2Focus that people can see
  3. 3Disabled, two ways
  4. 4Toggles, targets and patterns

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.

CSS
: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:

JavaScript
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:

CSS
.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-visible and 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

A team ships .nw-button:focus { outline: none; } because the ring “looks ugly on click”. What’s the right fix in the system?

Question 2 of 4

The Save button is unavailable until a driver is selected. Product wants a tooltip explaining why. Which approach works?

Question 3 of 4

Northwind needs a “Show only late shipments” toggle button. Which markup communicates its state correctly?

Question 4 of 4

Why does Northwind draw focus rings with outline rather than box-shadow?

Practice

Code playground

Build focus, toggle and disabled states in

This toolbar hides keyboard focus and ignores its own states. Fix it in CSS and JS:

  1. Remove the :focus { outline: none } rule and add a :focus-visible ring: a solid outline using --nw-focus-ring-width, --nw-focus-ring-color and an outline-offset of --nw-focus-ring-offset.
  2. Make Show only late a real toggle: each click flips aria-pressed between "true" and "false". When pressed, its background is --nw-color-action-bg-subtle and its border --nw-color-action-bg.
  3. Save route has aria-disabled="true": clicking it must not save, its text uses --nw-color-text-disabled, and its cursor is not-allowed.
<div class="toolbar">
  <button class="nw-button" type="button" id="assign">Assign driver</button>
  <button class="nw-button" type="button" id="late" aria-pressed="false">Show only late</button>
  <button class="nw-button" type="button" id="save" aria-disabled="true" aria-describedby="save-why">Save route</button>
  <p id="save-why">Select a driver before saving.</p>
</div>
<p id="log">Saved 0 times</p>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • Focus styling uses :focus-visible, and no :focus rule removes the outline
  • Keyboard focus shows a 2px solid ring in the focus color with a 2px offset
  • Clicking Show only late toggles aria-pressed and its pressed styling
  • Clicking the aria-disabled Save button doesn’t save
  • Save uses the disabled text color and a not-allowed cursor