Skip to content
Chapter 27Lesson 2

The four commitments

The four accessibility commitments, keyboard, contrast, motion, and target size, you hold while writing every screen, anchored by semantic HTML before ARIA.

Ask a junior when they do accessibility and the answer is usually “at the end, before launch.” On a team that ships to real customers, it never happens as a separate phase. Accessibility done well is not a checkpoint but a small set of commitments you hold while you write every screen, the same way you hold security or type-safety.

You already have most of the moves. Over the last several chapters you wired up button types, label associations, focus rings, landmarks, and reduced-motion variants, and you saw that the shadcn primitives inherit Radix’s keyboard, focus, and screen-reader work for free. This lesson ties those moves into a discipline: the four commitments that hold every screen together (keyboard, contrast, motion, target size), the rule that makes them cheap (semantic HTML first, ARIA second), and the manual check to run before every merge.

Accessibility as a discipline-level commitment

Section titled “Accessibility as a discipline-level commitment”

Accessibility is a discipline-level commitment, parallel to security and type-safety: you hold it continuously rather than scheduling it for the week before launch. The reason is cost. Held from the first screen, accessibility is nearly free, a class here, a token pair there, an element chosen well. Bolted on at QA, it means re-auditing every page, re-theming the palette for contrast, and rewiring focus through code that never expected to manage it.

This is a quality bar, not charity, and the constraint costs money. AA-level conformance is what most jurisdictions and procurement contracts now demand, from the ADA in the US to the European Accessibility Act in force since June 2025. Enterprise buyers ask for a conformance statement before they sign, and a product that fails the bar loses the deal or invites a lawsuit.

The laws name a level, AA, rather than a version of the guidelines. The floor this course holds to is WCAG 2.2 AA, the current Web Content Accessibility Guidelines at their middle level. Level A is too weak to satisfy anyone; level AAA is aspirational, not expected of a whole product. AA clears both the legal and the commercial bar, so AA is what you build to.

What makes the four commitments hold up across a growing codebase is that each is a property of a central artifact, a design token, the global stylesheet, or the order of the DOM, rather than a chore you repeat at every element. Watch for that shape: a commitment you can audit in one place is one a team actually keeps.

Commitment 1: every control works from the keyboard

Section titled “Commitment 1: every control works from the keyboard”

Start here, because this commitment is the most testable. Every interactive control must be reachable by Tab, activatable by Enter or Space according to its role, and, where it makes sense, dismissable by Esc. That is the whole contract. A control the mouse can reach but the keyboard cannot is broken for everyone who navigates without a pointer: keyboard-only users, screen-reader users, people with motor conditions, and the power users on your own team.

The test is one sentence: unplug your mouse and use your app for five minutes. If you get stuck on a button you cannot reach, a menu you cannot close, or a focus ring you lose track of, a keyboard user is stuck in the same place. It is the cheapest accessibility check there is, and it catches a large share of real bugs.

Users already expect specific keys to do specific things by control type; your job is not to invent these mappings but to avoid breaking them. The figure below walks one expectation end to end, opening a menu, moving through it, and closing it. Scrub the sequence and watch the focus ring move, and on step 4 watch it land back on the button that opened the menu. That return is the part people forget, and the part Radix handles for you.

Reports
no key pressed page loaded
The page loads. Nothing is focused yet, so the focus ring has no home.
Reports
Tab move focus
Tab moves focus to the first control, following DOM order. The ring lands on “Filters.”
Reports
Active only
Archived
All items
Enter activate
Enter activates the control. The menu opens and focus moves inside it, onto the first option.
Reports
Esc close + restore
Esc closes the menu and returns focus to the button that opened it, not to the top of the page.
Reports
Tab move focus
Focus resumes from where it was. Tab continues through the toolbar in DOM order, on to “Sort.”

Different control types answer to different keys, and the set is small enough to hold in your head. This table is the contract users already carry, so treat it as a spec you cannot violate.

ControlKeys
ButtonEnter and Space activate
LinkEnter activates
Dropdown / SelectArrow keys move, Enter selects, Esc closes
DialogEsc closes, Tab cycles within, Shift+Tab reverses
Disclosure (<details> / accordion)Enter and Space toggle

One rule sits under the whole contract: tab order follows DOM order. Focus visits elements in the order they appear in the document, top to bottom, not the order CSS paints them on screen. You can paint a sidebar on the right while the DOM has it first, and focus will still follow the DOM. When the two disagree, reorder the DOM, not the CSS; the focus lesson later in this chapter covers that fix.

That brings up tabindex, the attribute that reaches into tab order and the one juniors most often misuse. Three values are worth knowing. tabindex="0" makes a non-interactive element focusable in document order; you need it only for a custom interactive element where no native control fits, which in a shadcn codebase is almost never, because you reach for <button> instead. tabindex="-1" makes an element focusable by script but skipped by Tab, which focus-management code uses to move focus to a heading on a route change.

The third case is every positive value, and positive tabindex is an anti-pattern. A tabindex="3" does not nudge an element one step; it moves that element to a global position ahead of the natural order, reshuffling tab order across the whole page and breaking the one expectation users rely on. If you find yourself typing a positive tabindex, fix the DOM instead.

This is where the previous lesson pays off. Because the shadcn primitives wrap Radix, the entire keyboard contract above arrives already implemented. A DropdownMenu ships roving tabindex so the menu is a single tab stop and the arrows move within it; a Dialog cycles Tab inside its bounds with a focus trap and restores focus on close. You get all of it by using the primitive instead of hanging an onClick on a <div>. The two things you still own you have already met: choose <button> over <div onClick> so the control is keyboard-native, and never strip the :focus-visible ring, the keyboard user’s only cue to where they are.

Commitment 2: hold AA contrast at the token pair

Section titled “Commitment 2: hold AA contrast at the token pair”

Teams get this one wrong most often, and the semantic-token model from the previous lesson makes it nearly free. The bar is WCAG 2.2 AA: a contrast ratio of at least 4.5:1 for normal text, and 3:1 for large text (18pt and up, or 14pt and up if bold) and for the visual boundaries of UI components.

What makes it cheap: you audit the palette, not the call site. The theme is not a pile of one-off colors but a set of semantic token pairs designed to sit on each other: --foreground on --background, --primary-foreground on --primary, --muted-foreground on --muted. Contrast is a relationship between the two colors in a pair, and each pair is defined once in the token file. So a --primary that fails against --background fails on every button, link, and primary surface at once, because they all resolve to the same two values. Fix the pair once and every screen that uses it passes. The semantic-token model now pays an accessibility dividend.

Contrast is also something you have to feel, because the threshold is not where most people guess. Slide the lightness of the foreground token below and watch two things move with it: the live contrast ratio against the background, and a chip that flips between pass and fail the instant you cross 4.5:1. The aim is to see contrast as a continuous property of two token values, and to find the exact lightness where AA breaks.

Slide the foreground lightness. The ratio and the AA verdict move with it.

The split by size has a reason: large text carries each shape across more pixels, so it stays legible at 3:1 where body copy needs 4.5:1. That is why a faint heading can pass while the same color on body text fails.

The tools make this a desk check. Chrome DevTools shows the contrast ratio inline on any element you inspect, in the color picker and the Accessibility pane; the axe DevTools extension flags failures in bulk; tweakcn and design tools like Figma have contrast checkers built in. But the approach that scales is to open the token file and audit the pairs, not the rendered page element by element. The palette is where contrast lives, so the palette is where you fix it.

Commitment 3: honor prefers-reduced-motion

Section titled “Commitment 3: honor prefers-reduced-motion”

Some users have told their operating system they want less motion on screen, and they mean it. For people with a vestibular disorder , a parallax hero, a big sliding transform, or a looping background is not a delight but a trigger for real nausea or dizziness. The browser hands you that preference, so respect it.

You have already met both mechanisms. Tailwind’s motion-reduce: variant turns an animation off for users who asked for less, with classes like motion-reduce:transition-none and motion-reduce:animate-none, and the global @media (prefers-reduced-motion: reduce) block sets app-wide defaults. The rule to hold is simple: put motion-reduce: on every animation noticeable enough to matter. Anything that moves above the fold gets the variant.

<div className="transition-transform duration-300 motion-reduce:transition-none" />

The bug that actually ships comes from a default-direction trap: you write the full animation, it looks great, and you never come back for the reduce case, so the people who most need calm get the full show. Treat the reduced case as a first-class state and write it deliberately every time. Better still, invert the default in the global stylesheet, so motion becomes something the user opts down from rather than something you must remember to suppress.

The exception is what makes this a matter of judgment rather than a single switch. Reduced motion means replace, not delete. Some motion carries information rather than decoration. A toast that slides in from the corner is how a sighted user knows it appeared; remove the slide outright and a reduced-motion user may never register that anything happened. Swap the motion for a non-motion equivalent instead: let the toast appear instantly, or flash a brief background change, so it still announces itself without the vestibular cost. Convey the same information by another means rather than stripping it away with the motion.

The demo below makes the difference concrete. Toggle reduced motion and watch the same card arrive two ways: a slide-and-fade when motion is allowed, an instant appearance when it is not.

full motion
Toggle the switch, then replay.
Mode full motion
Entrance
With reduced motion the card still appears; it just stops sliding.

The shadcn dividend applies here too, with one boundary. The tw-animate-css engine behind the dialog, sheet, and accordion animations respects reduced motion when configured, so the primitives are covered. But the moment you write your own keyframe animation for a custom flourish, the dividend stops and the motion-reduce: discipline is back in your hands.

Commitment 4: size targets for a thumb, not a cursor

Section titled “Commitment 4: size targets for a thumb, not a cursor”

Juniors miss this one most, and for a structural reason: they design and test with a mouse, and a pointer is precise in a way a thumb is not. The commitment is that interactive targets are at least 24×24 CSS pixels with spacing between them, the WCAG 2.2 AA minimum (success criterion 2.5.8, Target Size). Most teams hold a more generous 44×44 default for any touch-primary action, matching Apple’s and Google’s platform guidelines: 24 is the floor, 44 is comfortable under a thumb.

In Tailwind you reach 44 pixels with min-h-11 min-w-11 on touch-primary buttons. What fixes the underlying misconception is how you get there: grow the hit area with padding, not by scaling the icon. A 16-pixel icon centered in a 44-pixel padded button is correct, the glyph stays small and crisp while the tappable region is thumb-sized. A 44-pixel icon is wrong, because it conflates the visual size of the icon with the size of the area you can tap.

hit area: 44px the padding
icon: 16px what you see
Comfortable on touch
24px bare WCAG minimum
Awkward on touch
The same fingertip lands comfortably inside the 44px hit area but overshoots the 24px one.

Size for the coarsest pointer that will use the screen. The (hover: hover) and (pointer: coarse) media queries tune that split: a dense desktop grid driven by a fine mouse can use tight controls, but the moment a surface is touch-primary, the 44-pixel floor applies.

shadcn’s default Button sizes are built against this baseline, but the size="icon" and size="sm" variants can land below 44 pixels. They are fine on a desktop toolbar and too small on a touch-primary surface, so there, verify them and bump with min-h-11 min-w-11 or a wrapping hit area.

The four commitments stay cheap only because they assume good markup, and that assumption is itself the first rule of accessibility: before reaching for any ARIA attribute or building a custom widget, ask whether a native HTML element already does the job. A button is <button>, not a <div onClick>. A navigation link is <a href>, not a <span onClick>. A show/hide section is <details>, not a hand-built accordion. A dialog is the Radix Dialog (or native <dialog>), not a <div role="dialog">. Native elements arrive with keyboard behavior, focus handling, and screen-reader semantics attached, so you inherit the platform’s work instead of rebuilding it.

ARIA , the attribute set you reach for second, only changes what assistive technology announces; it adds no behavior. Putting role="button" on a <div> does not make that div respond to Enter: it tells a screen reader “this is a button” while the element stays keyboard-dead. The ARIA lesson later in this chapter returns to this under “no ARIA is better than bad ARIA.” For now, fix the ordering: native element first, ARIA only when nothing semantic exists for what you are building.

So once the Radix primitive handles the keyboard, focus, and ARIA work for the components it covers, the question worth asking is what is still yours. The list is short and concrete, and you already know where each one lives:

  • Page landmarks, the <header>, <nav>, <main>, <footer> skeleton (taught in “Landmarks and headings”, Chapter 17).
  • Heading hierarchy, one <h1> per page with no skipped levels (same lesson).
  • Form labels, every input associated to a <label htmlFor> (“Forms and the FormData contract”, Chapter 17).
  • Token contrast, Commitment 2 above, audited in the palette.
  • Reduced motion in your own animations, Commitment 3 above.
  • Focus on route changes, the focus lesson, later in this chapter.
  • Live regions for async state changes, the ARIA lesson, later in this chapter.

The primitive owns its surface; you own the rest of the page. The exercise below drills that boundary: for each item, decide whether a native element gives it to you or whether you still own it with shadcn in the mix.

Sort each concern. “Reach for a native element” means a native HTML element or a Radix primitive already gives it to you. “You still own this” means it is your job on every screen, even with shadcn. Drag each item into the bucket it belongs to, then press Check.

Reach for a native element The platform (or a Radix primitive) gives it to you
You still own this Your job on every screen, even with shadcn
An element a user clicks to perform an action
Navigation to another page
A show/hide section
Tab order and Esc-to-close inside a dropdown menu
The page’s landmark structure (<header> / <main> / <nav> / <footer>)
One <h1> per page with no skipped heading levels
The accessible label on an icon-only button
Contrast between your foreground and background tokens

What automated tools catch, and what they miss

Section titled “What automated tools catch, and what they miss”

Auditing closes the loop, and an experienced engineer runs three checks in rising order of effort. Lighthouse, the accessibility audit built into Chrome DevTools, is the daily smoke test. The axe DevTools extension gives deeper rule coverage when something looks off. Keyboard-only navigation, the unplug-your-mouse test from Commitment 1, is a manual pass before every merge, a standing gate you run the same way you would not merge code you never ran.

All of it has an honest limit: automated tools catch only a minority of accessibility issues. The figure depends on how you count. Roughly a third of the WCAG AA success criteria can be tested by automation; by issue volume, the axe-core team puts it nearer 57% of detectable problems. Either way, most of conformance still needs a human, and the part machines cannot judge is the part that matters most. A tool cannot tell you whether your heading hierarchy makes sense as an outline, whether link text describes where it goes (“Read the 2025 report”) instead of “click here”, whether alt text conveys an image’s meaning rather than its filename, or whether focus order matches reading order. Each is a judgment about meaning, and meaning is what an automated rule cannot evaluate.

So a clean tool report is necessary but not sufficient. “Lighthouse 100” is a prompt to look closer, not a certificate.

Moving this into CI, with axe in Playwright and Lighthouse as a pipeline gate, is a later chapter’s job. What you take from here is the local habit: the daily Lighthouse glance and the pre-merge keyboard walk, held by you before the code reaches the pipeline.

Each claim is about one of the four commitments, the semantic-first rule, or the limits of tooling. Mark each statement True or False.

A positive tabindex value (like tabindex='3') is a sound way to control the order in which controls receive focus.

Positive values yank an element to a global position ahead of the natural order, scrambling focus across the whole page and breaking the DOM-order expectation users rely on. Tab follows DOM order — fix the DOM, not the tabindex. Only 0 and -1 belong in normal code.

Dark mode needs its own contrast audit, separate from light mode.

Dark mode is a second, complete palette — every .dark token pairing must clear AA on its own. The “softer = lower contrast” instinct is the canonical dark-mode trap: it reads as polish on a calibrated monitor and fails AA in daylight.

A passing Lighthouse accessibility score guarantees the page is accessible.

Automated tools test only a minority of the WCAG criteria. Whether the heading outline makes sense, link text describes its destination, alt text conveys meaning, and focus order matches reading order all need a human. A clean report is necessary, not sufficient.

Under reduced motion, the right move is to remove communicative motion entirely.

Replace, don’t remove. A toast’s slide is how a sighted user knows it appeared — swap it for an instant appearance or a brief flash so the information survives without the vestibular cost.

Adding role='button' to a <div> does not make it respond to the Enter key.

ARIA changes what assistive technology announces, never behavior. The div stays keyboard-dead. The fix is a native <button>, not the role.

Contrast is better audited at the design token than at each individual element.

A token pair that fails contrast fails everywhere it is used; fix it once at the token and every screen that resolves to it passes at once. That is the semantic-token model paying an accessibility dividend.

A short, durable shelf of references: the ARIA rule the next lesson builds on, the contrast tool the second commitment points at, the filterable WCAG 2.2 reference behind the whole AA bar, and the platform reference for reduced motion.