The four commitments
The four web accessibility commitments, keyboard, contrast, motion, and target size, that you hold while writing every screen, anchored by the rule to reach for semantic HTML before ARIA.
Ask a junior when they do accessibility and the answer is usually “at the end, before launch,” a pass that QA runs once the screens are built. On a team that ships to real customers, the answer is that it never happens as a separate phase. Accessibility done well is not a checkpoint at all. It is 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. Across the last several chapters you wired up button types, label associations, focus rings, landmarks, and reduced-motion variants, each one taught where it mattered. And in the previous lesson you saw that the shadcn primitives inherit Radix’s keyboard, focus, and screen-reader work for free, so the hard parts of a dialog or a menu are already solved before you type a line. What you are missing is the frame that ties those moves into a discipline, which is what this lesson gives you. By the end you can name the four commitments that hold every screen together (keyboard, contrast, motion, target size), state the rule that makes them cheap (semantic HTML first, ARIA second), and run the manual check an experienced engineer does before every merge.
Accessibility is a quality bar, not a feature
Section titled “Accessibility is a quality bar, not a feature”Start from one reframe, because the rest of the lesson builds on it: accessibility is a discipline-level commitment, parallel to security and type-safety. You do not schedule type-safety for the week before launch, and you do not bolt security on once the features work. You hold them continuously, because the cost of holding them is near zero while the code is being written and steep once it has to be retrofitted. Accessibility behaves the same way. Held from the first screen it costs almost nothing: 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.
Treat this as a quality bar rather than charity, because the constraint is real and it costs money. AA-level conformance is what most jurisdictions and most procurement contracts now demand. In the US the ADA is enforced for the web through Department of Justice guidance, the European Accessibility Act has been in force since June 2025, and the UK Equality Act and Canada’s Accessible Canada Act sit alongside them. 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 precise version of the guidelines. The engineering floor this course holds to is WCAG 2.2 AA, the current Web Content Accessibility Guidelines at their middle conformance level. Level A is too weak to satisfy anyone, and level AAA is aspirational rather than something expected of a whole product. AA is the level that clears both the legal and the commercial bar, so AA is what you build to.
Keep that last point in mind before the commitments themselves: this lesson is the baseline, not the exhaustive catalogue, and the depth of each technique lives at its call site. What makes the four commitments hold up over a growing codebase is that each one 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. That shape recurs in all four commitments, so watch for it: 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”The first commitment is the most testable, so start here. Every interactive control on the screen 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 that 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 for this commitment 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 exactly the same place. It is the cheapest accessibility check in existence and it catches an enormous share of real bugs.
Users already expect specific keys to do specific things, by control type. Your job is not to invent these mappings; it is to avoid breaking them. The following figure walks one of those expectations end to end, opening a menu, moving through it, and closing it, so you can watch focus move and, on Esc, watch it return to the control that opened the menu. That return is the part people forget, and it is exactly the part Radix handles for you.
The keyboard contract. Focus moves on Tab, activates on Enter, and on Esc returns to the trigger. Scrub the sequence and watch the focus ring move, and on step 4 watch it land back on the button that opened the menu.
Different control types answer to different keys, and the set is small enough to hold in your head. The following table is the contract users already carry, so treat it as the spec you are not allowed to violate.
| Control | Keys |
| --- | --- |
| Button | Enter and Space activate |
| Link | Enter activates |
| Dropdown / Select | Arrow keys move, Enter selects, Esc closes |
| Dialog | Esc closes, Tab cycles within, Shift+Tab reverses |
| Disclosure (<details> / accordion) | Enter and Space toggle |
One rule sits underneath the whole contract: tab order follows DOM order. Focus visits elements in the order they appear in the document, top to bottom. This matters because CSS can place elements anywhere on screen regardless of their order in the markup: you can paint a sidebar on the right and the main content on the left while the DOM has them the other way around, and focus will follow the DOM, not the paint. When the two disagree, the fix is to reorder the DOM rather than the CSS, and the lesson on focus later in this chapter covers that fix. For now, the rule to hold is that Tab follows the DOM.
That brings up tabindex, the attribute that lets you reach into tab order and the one juniors most often misuse. Three values are worth knowing. tabindex="0" makes a normally non-interactive element focusable in document order; you reach for it on the rare 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; focus-management code uses it to move focus to a heading on a route change, as the focus lesson covers.
The third case is every positive value, and positive tabindex values are an anti-pattern. A tabindex="3" does not nudge one element by a single step. It moves that element to a global position ahead of the natural order, which reshuffles tab order across the entire page and breaks the one expectation users rely on. So if you find yourself typing a positive tabindex, stop and 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, and 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 rather than hanging an onClick on a <div>. The two things you still own here you have already met: choose <button> over <div onClick> so the control is keyboard-native in the first place, and never strip the :focus-visible ring, since it is the keyboard user’s only cue to where they are.
Commitment 2: contrast is a property of the theme
Section titled “Commitment 2: contrast is a property of the theme”The second commitment is the one teams get wrong most often, and the one the semantic-token model from the previous lesson makes almost free. The bar is WCAG 2.2 AA contrast: 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.
The reframe that makes this cheap is that you audit the palette, not the call site. Think about how the theme is built. It is not a pile of one-off colors; it is 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 the pair is defined once, in the token file. So contrast stops being a property of the page and becomes a property of the theme. A --primary that fails against --background does not fail on one button; it fails on every button, every link, and every primary surface in the app, because they all resolve to the same two values. The flip side is the payoff: fix the pair once at the token, and every screen that uses it passes at once. This is the semantic-token model you already learned, now paying an accessibility dividend.
Contrast is also something you have to feel to calibrate, because the threshold is not where most people guess. The following playground is a token contrast lab. Slide the lightness of the foreground token and watch two things move together: 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 not to memorize a number. It is to see that contrast is a continuous property of two token values, and to find the exact lightness where AA breaks.
Monthly revenue
Updated 2 minutes ago
Two more points are worth holding. First, the thresholds split by text size: 4.5:1 for normal text, 3:1 for large. Large text has more pixels carrying each shape, so it stays legible at a lower ratio, which is why a faint heading can pass while the same color on body copy fails. Second, there is a common trap, described below.
The tools make this a desk check rather than a guess. Chrome DevTools shows the contrast ratio inline on any element you inspect, in both the color picker and the Accessibility pane. The axe DevTools extension flags failures in bulk. The theme generator the previous lesson pointed at (tweakcn) and design tools like Figma have contrast checkers built in. But the approach that scales is the one the token model implies: 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: motion respects the user’s preference
Section titled “Commitment 3: motion respects the user’s preference”The third commitment has a clean platform hook and one subtle exception worth getting right. The commitment is to honor prefers-reduced-motion. Some users have told their operating system that 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 animation is not a delight; it is a trigger for real nausea or dizziness. The browser hands you that preference, so respect it.
You have already met both mechanisms for respecting it, so this is recognition, not new syntax. 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 in your stylesheet sets app-wide defaults. The rule to actually hold, the one the code conventions enforce, is simple: put motion-reduce: on every animation noticeable enough to matter, with no exceptions. Anything that moves above the fold gets the variant.
<div className="transition-transform duration-300 motion-reduce:transition-none" />There is a default-direction trap worth naming, because it is the version of this bug that actually ships. You write the full animation first, it looks great, and you never come back to handle the reduce case, so the people who most need calm get the full show. The discipline is to treat the reduced case as a first-class state and write it deliberately, on top of full motion, every time. The framing in the code conventions is to invert the default in the global stylesheet, so motion becomes something the user opts down from rather than something they have to remember to suppress.
Then there is the exception, which is what makes this commitment a matter of judgment rather than a single switch. Reduced motion means replace, not delete. Some motion is not decoration; it carries information. A toast that slides in from the corner is how a sighted user knows it appeared, so if you remove the slide outright a reduced-motion user may never register that anything happened. The right move is to swap the motion for a non-motion equivalent: let the toast appear instantly, or flash a brief background change, so it still announces itself without the vestibular cost. The principle is to convey the same information by another means, not to strip the information away along with the motion.
The following demo makes the difference concrete. Toggle reduced motion on and off and watch the same card arrive two ways: a slide-and-fade when motion is allowed, and an instant appearance when it is not. That instant appearance is the “replace, don’t delete” principle in action: the card still shows up, it just shows up without moving.
The shadcn dividend applies here too, with one boundary to keep in mind. The tw-animate-css engine behind the dialog, sheet, and accordion animations from the previous lesson is built to respect 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. The primitive protects its own motion; the motion you add is yours to handle.
Commitment 4: targets are big enough to hit
Section titled “Commitment 4: targets are big enough to hit”The fourth commitment is the one juniors miss most, for a structural reason: they design and test with a mouse, and a mouse pointer is precise in a way a thumb is not. The commitment is that interactive targets must be at least 24×24 CSS pixels with spacing between them, which is the WCAG 2.2 AA minimum (success criterion 2.5.8, Target Size). In practice 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, and 44 is the size that feels comfortable under a thumb.
In Tailwind you reach that size with min-h-11 min-w-11, which is 44 pixels on the spacing scale, on touch-primary buttons. The part that fixes the underlying misconception is how you get there. You grow the hit area with padding, not by scaling the icon. A 16-pixel icon centered inside a 44-pixel padded button is correct: the glyph stays visually small and crisp while the tappable region is thumb-sized. A 44-pixel icon is wrong, because now the glyph itself is oversized and you have conflated two separate things, the visual size of the icon and the size of the area you can tap. The figure below pulls them apart.
The habit to build is to size for the coarsest pointer that will use the screen. You already have the tool for tuning that split: the (hover: hover) and (pointer: coarse) media queries you met earlier. A dense desktop table driven by a fine mouse pointer can use smaller controls, while the same interface on a phone needs the larger target. So a desktop-only admin grid is allowed to be tight, but the moment a surface is touch-primary, the 44-pixel floor applies.
The shadcn dividend here comes with a caveat worth checking. shadcn’s default Button sizes are designed 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 on those surfaces, verify them and bump with min-h-11 min-w-11 or a wrapping hit area where needed. The primitive gets you close, and the touch context is the part you confirm.
Semantic HTML first, ARIA second
Section titled “Semantic HTML first, ARIA second”Everything so far has assumed you started from good markup, and that assumption is itself a rule, the one that makes the four commitments cheap. It is sometimes called the first rule of accessibility: before you reach for any ARIA attribute or build any custom widget, ask whether a native HTML element already does this. 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 already attached. That is exactly why the keyboard commitment, the focus rules, and the rest cost so little when your markup is semantic from the start: you inherit the platform’s work instead of rebuilding it.
This is the experienced engineer’s default, for a precise reason. Semantic-first means the platform does the keyboard, focus, and role work for you. ARIA , the attribute set you reach for second, only changes what assistive technology announces. It does not add 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 itself stays keyboard-dead. The lesson on ARIA later in this chapter returns to this in depth, under the banner “no ARIA is better than bad ARIA.” For now, fix the ordering in your head: native element first, ARIA only when there is genuinely no semantic equivalent for what you are building.
Pull this together with the previous lesson and you arrive at the core idea of the whole chapter. Using Radix primitives through shadcn means the keyboard, focus, and ARIA work is already done for the components they cover. So the question worth asking is not “what is accessibility” but “what is still mine once the primitive does its part?” That list is short and concrete, and every item is something you have already learned where to do:
- Page landmarks, the
<header>,<nav>,<main>,<footer>skeleton (taught in “Landmarks and the heading outline”, Chapter 17). - Heading hierarchy, one
<h1>per page with no skipped levels (same lesson). - Form labels, every input associated to a
<label htmlFor>(“Forms as a contract with the server”, 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.
That list is where your attention goes on every screen. The primitive owns its surface; you own the rest of the page.
The exercise below drills exactly that boundary. For each item, decide whether you reach for a native element (and remember that the Radix primitives count as native here, since they bring the platform’s semantics with them) or whether it is something you still own 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.
<header> / <main> / <nav> / <footer>)<h1> per page with no skipped heading levelsWhat automated tools catch, and what they miss
Section titled “What automated tools catch, and what they miss”One more discipline closes the loop: auditing. An experienced engineer runs three things, in rising order of effort. Lighthouse, the accessibility audit built into Chrome DevTools, is the daily smoke test you run without thinking. The axe DevTools extension gives deeper rule coverage when something looks off. And keyboard-only navigation, the unplug-your-mouse test from Commitment 1, is a manual pass before every merge. That last one is not a one-off check but a standing gate: it runs every time, the same way you would not merge code you never ran.
There is an honest limit to all of it: automated tools catch only a minority of accessibility issues. The exact figure depends on how you count. Roughly a third of the WCAG AA success criteria can be meaningfully tested by automation, and by issue volume the axe-core team’s analysis puts it nearer 57% of detectable problems. Either framing lands in the same place: the majority 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 actually makes sense as an outline, whether link text describes where it goes (“Read the 2025 report”) instead of saying “click here”, whether alt text conveys the meaning of an image rather than its filename, or whether focus order matches the reading order a person follows. Every one of those is a judgment about meaning, and meaning is exactly what an automated rule cannot evaluate.
So hold a precise stance here: a clean tool report is necessary but not sufficient. “Lighthouse 100” is a prompt to look closer, not a certificate that the page is accessible.
Moving this into CI, with axe running inside Playwright and Lighthouse enforced as a pipeline gate, is a later chapter’s job, near the end of the course where the pipeline is built. 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 ever reaches the pipeline.
The round below consolidates the four commitments, the semantic-first rule, and the tooling limit into recall. Run it once to check the frame landed.
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.
tabindex. Only 0 and -1 belong in normal code.Dark mode needs its own contrast audit, separate from light mode.
.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.
Under reduced motion, the right move is to remove communicative motion entirely.
Adding role='button' to a <div> does not make it respond to the Enter key.
<button>, not the role.Contrast is better audited at the design token than at each individual element.
Reveal card-by-card review
External resources
Section titled “External resources”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.
The W3C source of 'no ARIA is better than bad ARIA' and the reference the ARIA lesson builds on.
The canonical browser tool for checking a foreground/background pair against WCAG AA and AAA.
The official filterable reference for every success criterion behind the AA bar — by level, tag, and technology.
The platform reference for the motion commitment, with the media-query patterns and the replace-don't-delete principle.