Full-Stack Questions
Accessibility Beyond the Basics
WCAG 2.2 criteria in plain words, forms, focus and dialogs, live regions, ARIA, contrast numbers and testing, one level past Frontend Depth Lesson 6.
On this page
- Words used in this lesson
- WCAG 2.2 in plain words
- Semantic HTML and landmarks, one level deeper
- Forms: labels, hints, errors
- Focus management and route changes in a single-page app
- Modals and dialogs
- Live regions and status messages
- ARIA rules of thumb
- Colour contrast, in numbers
- Motion and animation
- Testing: keyboard walk-through, tools, screen readers
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: accessibility at the 4+ years level is not a list of alt texts. It is knowing what WCAG 2.2 asks for, choosing the right native element, moving focus on purpose, announcing changes without stealing attention, and proving it with a keyboard and a tool, while remembering that tools find only part of the problems.
Most candidates can say "use semantic HTML and add alt text". Senior interviews go further: "How do you handle focus after a route change in a single-page app?", "Why is role="button" on a div a promise you must keep?", "What can axe not see?" This lesson goes one level deeper than Accessibility Essentials (Frontend Depth, Lesson 6). That lesson covers semantic HTML first, focus in modals, ARIA only when needed, and a first keyboard test. I do not repeat it here. If any term below feels new, read Lesson 6 first. For the HTML and CSS basics behind landmarks, see also Full-Stack Q&A, Lesson 01. For testing tool basics (Testing Library, Playwright), see Lesson 21.
What I ran. I ran axe-core 4.14.0, jest-axe 11.0.0, jsdom 29.1.1 and the colour-contrast formula in Node 22. The HTML samples below were checked in jsdom, so every "violations" line is real output. I did not run a real browser or a real screen reader: the sandbox could not download a browser, so the Playwright + axe snippet, the native <dialog> behaviour and all screen reader statements are read from specs and docs, not run. WCAG wording comes from the w3c/wcag repository (WCAG 2.2). Dialog behaviour comes from the WHATWG HTML source. The Next.js route announcer was read in the installed Next.js 16.3.5 package. I could not fetch the "Using ARIA" note itself; for the ARIA rules I read the ARIA Authoring Practices "Read Me First" page and checked the rest against my own tests.
Words used in this lesson
- Accessibility (a11y) means people with disabilities can use your product. That includes people who cannot see (screen reader), cannot use a mouse (keyboard, switch), cannot hear, need larger text, or have a temporary limit like a broken arm. "a11y" is short for "a, 11 letters, y".
- WCAG (Web Content Accessibility Guidelines) is the W3C standard that most laws and contracts point to. It is made of success criteria (SC): testable rules with a number like
1.4.3and a level: A (basic), AA (the usual legal target) and AAA (extra). - An accessibility tree is the list of objects the browser gives to assistive tools: each has a role (button, link, heading), a name (the text a screen reader says) and states (checked, expanded).
- A screen reader is software that reads the screen aloud (or to a braille display). Examples: NVDA and JAWS on Windows, VoiceOver on Apple devices, TalkBack on Android.
- A landmark is a page region that screen reader users can jump to: banner, navigation, main, complementary, contentinfo, search, form, region.
- An accessible name is the text that identifies a control to assistive tools. It comes from a
<label>, the element's own text,aria-labelledbyoraria-label. - ARIA (Accessible Rich Internet Applications) is a set of attributes (
role,aria-*) that change what the accessibility tree says. It changes only the description, never the behaviour. - Focus is the element that receives keyboard input right now. Focus order is the order Tab moves through elements. Focus management means moving focus on purpose, for example into a dialog.
- A live region is an element whose changes a screen reader announces without moving focus. Polite announcements wait for a pause. Assertive ones interrupt.
inertis an HTML attribute that makes an element and its children not clickable, not focusable and hidden from assistive tools.- Contrast ratio is a number from 1:1 (no difference) to 21:1 (black on white) that compares how bright two colours are.
WCAG 2.2 in plain words
WCAG has four principles: Perceivable (users can sense it), Operable (users can use it), Understandable and Robust (works with assistive tools). Version 2.2 adds new criteria and removes one: 4.1.1 Parsing is gone. The w3c/wcag repository lists the new ones. I read each wording from the source:
| SC | Level | What it asks, in plain words |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | When a control gets keyboard focus, it must not be completely hidden by something you built, such as a sticky header or a cookie bar. |
| 2.4.12 Focus Not Obscured (Enhanced) | AAA | The same, but not even partly hidden. |
| 2.4.13 Focus Appearance | AAA | A clear, large enough focus indicator. |
| 2.5.7 Dragging Movements | AA | Anything done by dragging must also work with a single pointer without dragging (for example, buttons to move a card). |
| 2.5.8 Target Size (Minimum) | AA | Click and tap targets are at least 24 by 24 CSS pixels, unless small ones have enough spacing, the action exists elsewhere, the target is inline in text, the browser controls its size, or the size is essential. |
| 3.2.6 Consistent Help | A | Help links (contact, chat, FAQ) appear in the same place across pages. |
| 3.3.7 Redundant Entry | A | Do not make users type again what they already gave in the same process; fill it in or let them pick it. |
| 3.3.8 Accessible Authentication (Minimum) | AA | Logging in must not need a cognitive test (remember a password, solve a puzzle) unless there is an alternative or help. Password managers and paste must work. |
| 3.3.9 Accessible Authentication (Enhanced) | AAA | Stricter version of 3.3.8. |
Older criteria that interviewers still ask about (wording checked in the repository):
| SC | Level | In plain words |
|---|---|---|
| 1.4.3 Contrast (Minimum) | AA | Text has at least 4.5:1 contrast. Large-scale text needs 3:1. |
| 1.4.11 Non-text Contrast | AA | Parts of controls you need to see (a border, a focus ring, an icon) and meaningful graphics have at least 3:1. |
| 1.4.10 Reflow | AA | No two-direction scrolling at a width of 320 CSS pixels (the same as 400% zoom on a 1280 pixel screen). |
| 1.4.12 Text Spacing | AA | Nothing breaks if the user sets line height 1.5, paragraph spacing 2, letter spacing 0.12 and word spacing 0.16 (times the font size). |
| 2.2.2 Pause, Stop, Hide | A | Moving content that starts alone and lasts over 5 seconds needs a way to pause or hide it. |
| 3.3.1 Error Identification | A | If an input error is found, say which field and describe the error in text. |
| 4.1.3 Status Messages | AA | Status messages (saved, 3 results, error) are exposed with a role or property so assistive tools can announce them without moving focus. |
The 24 pixel rule (2.5.8) is the minimum. It is not a design target. Many design systems use 44 pixels for touch, which is the older AAA rule 2.5.5. A 24 pixel icon button with no spacing can pass, but it is still hard to tap.
Semantic HTML and landmarks, one level deeper
Lesson 6 says "use the right element". The deeper rules are about structure that screen reader users navigate by.
- One
<main>, one page-level<h1>, and headings that do not skip levels (noh1thenh3). Headings are used as a table of contents, so style with CSS, not with the heading level. - Landmarks come from elements:
<header>(page level) isbanner,<nav>isnavigation,<main>,<aside>iscomplementary,<footer>(page level) iscontentinfo. A<section>is aregiononly if it has an accessible name. - Name repeated landmarks. Two
<nav>elements needaria-label="Main"andaria-label="Footer", so a landmark list is not "navigation, navigation". - Skip link. The first focusable element is
<a href="#main">Skip to content</a>. Target<main id="main" tabindex="-1">, so focus really moves there. - Lists, tables and buttons are semantics. A real
<ul>tells "list of 5 items". A real<table>with<th scope>lets a reader say the column name for each cell. A<button>gives Enter, Space and the role for free. - Links go places, buttons do things. Do not use
<a href="#" onclick>for an action. - Language.
<html lang="en">picks the right voice. Mark a phrase in another language withlangon that element.
I ran axe on a page full of mistakes. It found these rules (and the number of elements):
button-name (1) heading-order (1) html-has-lang (1) image-alt (1)link-name (1) list (1) region (6)A page with lang, <main>, one <h1> then <h2>, a named <nav>, a labelled input, a submit button and alt text returned no violations.
Look at what axe did not report on the bad page: a <div onclick> pretending to be a button, and an <input> with only a placeholder. The label rule passed for the placeholder-only input, because a placeholder can serve as a name. But a placeholder disappears when you type, usually has low contrast, and is not a label. Use a real <label>. A tool passing does not mean the page is good.
Forms: labels, hints, errors
A form is where most real-world failures live. The rules:
- Every control has a visible
<label for>(or wraps the input). The label text is the accessible name. - Group related controls with
<fieldset>and<legend>(radio buttons, "Shipping address"). The legend is read before each option. - Hints and errors are linked with
aria-describedby. The name is the label; the description is read after it. - Mark invalid fields with
aria-invalid="true"and put the message in text next to the field. Colour alone fails 1.4.1 (use of colour) and 3.3.1. - Use
autocompletetokens (email,given-name,current-password). This helps people who cannot type easily and satisfies 1.3.5 (identify input purpose). Do not block paste in a password field: it breaks 3.3.8. - Use the right
type(email,tel) for the right mobile keyboard. - Do not mark required fields with colour only. Use the
requiredattribute and a visible "(required)" or a legend explaining the star.
<label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" aria-invalid="true" aria-describedby="email-hint email-err" required><p id="email-hint">We only use it for receipts.</p><p id="email-err">Enter an email like name@example.com</p>On submit with errors, the pattern most design systems use is an error summary: move focus to a box at the top that says "There is 1 problem" and lists links that jump to each field. Each link text matches the message. I ran axe on a page with this summary (role="alert", tabindex="-1" so script can focus it), the linked field and a fieldset of radios: no violations. Without a summary, a keyboard user who presses Submit and sees nothing happen has no way to know where the errors are.
required and native validation bubbles. The browser's built-in bubble is not announced the same way in every reader and cannot be styled well. Many teams use novalidate plus their own messages. If you do, you own the whole job: summary, per-field text, aria-invalid, and focus.
Focus management and route changes in a single-page app
When a normal page loads, the browser resets focus and a screen reader announces the new title. In a single-page app (SPA), a link click changes the content but nothing tells the user. Focus can even stay on a link that no longer exists. You must handle three things:
- Announce the new page. Next.js includes a route announcer. I read it in the installed package: it is an element with
role="alert", and it chooses the text fromdocument.titlefirst, then the<h1>, then the URL path. The Next.js accessibility doc says to give every page a unique and descriptive title. It announces the title but does not move focus. - Place focus. A common pattern is to move focus to the new page's
<h1>(give ittabindex="-1") or to the<main>after navigation. Then the next Tab starts from the top of the new content, and the reader says the heading. I ran a small helper in jsdom that focuses theh1anddocument.activeElementbecameH1. (A real browser needstabindex="-1"for a non-interactive element to take focus; jsdom is more forgiving, so I do not rely on that part.) - Restore focus when something closes. After closing a dialog, focus goes back to the control that opened it. After deleting a list item, focus goes to the next item or the list heading, not to
<body>.
// run after the route has rendered (for example in an effect keyed by pathname)function focusPageHeading() { const h1 = document.querySelector<HTMLElement>('main h1') h1?.setAttribute('tabindex', '-1') h1?.focus({ preventScroll: false })}Be careful with two traps. First, do not run it on the first load (the browser already did the work). Second, do not move focus on every re-render or on a filter change the user did not expect: focus moves must follow user actions. Also do not use tabindex greater than 0: it changes the tab order for the whole page and nearly always breaks it.
Modals and dialogs
A modal dialog needs: a name, focus moved inside, focus kept inside, the page behind unreachable, Esc to close, and focus returned on close. Doing this by hand takes a lot of code. The native <dialog> element does most of it:
<button id="open">Delete project</button><dialog id="dlg" aria-labelledby="dlg-title"> <h2 id="dlg-title">Delete this project?</h2> <p>This cannot be undone.</p> <form method="dialog"> <button value="cancel" autofocus>Cancel</button> <button value="delete">Delete</button> </form></dialog><script> const dlg = document.getElementById('dlg') document.getElementById('open').addEventListener('click', () => dlg.showModal()) dlg.addEventListener('close', () => console.log(dlg.returnValue))</script>From the WHATWG HTML source (I read it; I did not run a browser):
showModal()makes the rest of the document inert and records the previously focused element. Esc is a close request and closes it.form method="dialog"closes it and setsreturnValueto the clicked button's value.- When the dialog closes, the browser runs the focusing steps for the previously focused element, so focus goes back to the button that opened it.
- The
autofocusattribute on a descendant picks where focus lands first. Put it on the safest button (Cancel), not on a destructive one. - There is a newer
closedbyattribute (any,closerequest,none). The default for a modal behaves likecloserequest. It is in the spec text; check browser support before you depend on it. dialog.show()(not modal) does not make the page inert. It is for non-modal panels.
For a custom modal (a library or a drawer), you need what <dialog> gives: put inert on the app root while it is open (or use aria-modal="true" and check the readers you target; inert is more reliable than aria-modal alone), move focus in, trap Tab, close on Esc, and restore focus. Use a tested library such as Radix or React Aria instead of your own code. A popover (a small panel, menu, tooltip) is different from a modal: it does not block the page. HTML has a popover attribute for that; do not use role="dialog" for a tooltip.
Do not hide the page behind a modal with only aria-hidden. aria-hidden="true" removes content from the accessibility tree but leaves focusable elements in the tab order, so a keyboard user can still Tab into a hidden control. In my axe test, a focusable button inside aria-hidden was not reported as a violation in jsdom; the aria-hidden-focus rule came back as incomplete (needs a real browser). inert fixes both problems at once.
Live regions and status messages
Criterion 4.1.3 says status messages must be announced without taking focus. You do it with a live region.
| Tool | Behaviour | Use for |
|---|---|---|
role="status" | polite | "Saved", "3 results found", cart count |
role="alert" | assertive, whole message read | Errors that need attention now, session expiry |
aria-live="polite" | wait for a pause | General updates |
aria-live="assertive" | interrupt | Rare: urgent only |
aria-atomic="true" | read the whole region again, not just the changed part | Short messages |
Rules that save you from bugs:
- The region must exist in the page before you change it. If you insert
<div role="status">Saved</div>all at once, many readers say nothing. Render an empty<div role="status"></div>at page load, and later set its text. - Keep it in the DOM and visible to the accessibility tree.
display: noneorhiddenremoves it. Hide it visually with a "visually hidden" CSS class (a 1 pixel clipped box), not withdisplay: none. - Do not announce everything. A live region that talks on every keystroke makes the page unusable. Debounce search results ("12 results") and announce the final count once.
- Same text twice may not announce twice. I observed in jsdom that setting
textContent = 'Saved'two times produced two DOM mutations, but whether a reader says it twice depends on the reader. A known trick is to clear the text, wait a moment, then set it. I could not test this with a real reader, so treat it as something to verify on your target readers. role="alert"on content that is already on the page at load is often announced on load. Use it only for new errors.
ARIA rules of thumb
The ARIA Authoring Practices "Read Me First" page has two principles I use in interviews: "No ARIA is better than bad ARIA" and "a role is a promise". Writing <div role="button">Place Order</div> promises that you also wrote the keyboard code a button has (Enter and Space, focusable, disabled state). ARIA roles add no behaviour and no styling.
The usual short list:
- Use a native element if one exists (
button,a,input,select,details,dialog). - Do not change native meaning (
<h2 role="tab">is wrong; put the role on a wrapper). - Every interactive ARIA control works with the keyboard.
- Never put
aria-hiddenorrole="presentation"on a focusable element. - Every interactive element has an accessible name.
What ARIA is really for: things HTML cannot say yet, such as aria-expanded on a toggle, aria-current="page" on the current link, aria-live, aria-describedby, and widgets like tabs and comboboxes (follow the APG pattern exactly, with its keys).
| Use | Meaning | Common mistake |
|---|---|---|
aria-label | Gives a name when there is no visible text (icon button) | Using it when visible text exists, so the name and the label differ |
aria-labelledby | Name comes from other elements by id | Pointing at an id that does not exist |
aria-describedby | Extra description after the name | Putting the main name there |
aria-expanded | Open or closed state of a toggle | Forgetting to update it in script |
aria-current | Current page, step or item | Using only a CSS class |
aria-hidden="true" | Remove from the accessibility tree | On a focusable element |
I tested a page with four ARIA mistakes: role="link" on a button with aria-pressed, an empty role="menu", an aria-labelledby pointing nowhere, and a button with aria-label="Save" whose visible text was "Cancel". axe reported only aria-allowed-attr (the aria-pressed on a link role). It did not report the other three as violations in this default run. The visible-text mismatch breaks the "label in name" rule (2.5.3), which speech-control users depend on, and a tool did not catch it. Review ARIA by hand.
Test names in an automated test with getByRole('button', { name: 'Delete project' }) (Testing Library, see Lesson 21). If the query works, the role and name exist. Using role queries as your default also pushes the team towards accessible markup.
Colour contrast, in numbers
WCAG 1.4.3 needs 4.5:1 for normal text and 3:1 for large-scale text. 1.4.11 needs 3:1 for control borders, icons and focus rings. The ratio uses the relative luminance of each colour: (lighter + 0.05) / (darker + 0.05). I wrote the formula in 8 lines of Node and calculated:
| Foreground on white | Ratio | Result for body text |
|---|---|---|
#000000 | 21.00 | pass |
#595959 | 7.00 | pass |
#0066cc | 5.57 | pass |
#767676 | 4.54 | pass (the lightest grey that passes) |
#777777 | 4.48 | fail by a hair |
#949494 | 3.03 | fail for text, pass for a 3:1 border |
#aaaaaa | 2.32 | fail |
white text on #e53935 (a common red button) | 4.23 | fail |
Things to remember:
- Placeholder text, disabled-looking links, grey helper text and text on images fail most often. Brand colours often fail on white.
- Do not rely on colour alone (1.4.1). An error needs an icon and text, a link in text needs an underline, a chart needs patterns or labels.
- Dark mode is a second palette. Check both.
- A tool cannot check text over images or gradients. In jsdom, axe's
color-contrastcame back as incomplete, not as a failure, because jsdom does not paint.jest-axesays in its README that colour contrast checks do not work in jsdom, so it turns them off. Check contrast in a real browser (Playwright, DevTools) or in design tokens. - Contrast checks the WCAG 2 formula. Some people think it is inaccurate for dark themes. A newer method (APCA) is a draft idea for a later WCAG; the legal target today is still the ratio above.
Motion and animation
Some people get dizzy or sick from large movement (parallax, zoom, spinning). The operating system has a setting "reduce motion", and CSS can read it:
.hero { animation: slide-in 600ms ease-out; }@media (prefers-reduced-motion: reduce) { .hero { animation: none; } html { scroll-behavior: auto; }}Rules: do not remove all motion (a loading spinner still tells the user something is happening); replace big movement with a fade or no animation. Autoplaying video or a carousel that lasts more than 5 seconds needs a pause button (2.2.2). Never flash more than three times a second (WCAG 2.3.1). In JavaScript read it with window.matchMedia('(prefers-reduced-motion: reduce)').matches. Tailwind has a motion-reduce: variant. WCAG 2.3.3 (Animation from Interactions) is a AAA criterion, so it is a good practice rather than a usual legal need.
Testing: keyboard walk-through, tools, screen readers
Use three layers. They find different things.
1. Keyboard walk-through (5 minutes, no tools)
Put the mouse away. Then check:
- Can I reach every control with Tab and Shift+Tab, in an order that matches the screen?
- Can I see where focus is at all times? (Not hidden by a sticky header: 2.4.11.)
- Enter activates links and buttons, Space activates buttons and checkboxes. Arrow keys move inside radio groups, tabs, menus.
- Esc closes dialogs and menus. Focus returns to the opener.
- No keyboard trap (focus cannot leave a widget). Every drag has a non-drag option.
- After a route change, error or dialog, does focus go somewhere sensible?
- Zoom the browser to 400% and set a 320 pixel window: is anything cut off or needing two-way scroll (1.4.10)?
2. Automated checks with axe-core
axe-core is the engine behind most tools. Its README says it finds on average 57% of WCAG issues automatically and reports others as "incomplete" for manual review. In axe-core 4.14.0 there are 105 rules, and the only rule tagged wcag22aa is target-size. So most new 2.2 criteria are for humans to check.
jest-axe (works with Jest or any test runner and jsdom). I ran its axe() function in the Node test runner on <main><h1>x</h1><img src="a.png"></main> and it returned the violation image-alt. The Jest matcher is expect(await axe(html)).toHaveNoViolations() (I read this in its README; I called axe() but did not run the Jest matcher):
import { axe, toHaveNoViolations } from 'jest-axe'expect.extend(toHaveNoViolations)test('signup form has no detectable violations', async () => { const { container } = render(<SignupForm />) expect(await axe(container)).toHaveNoViolations()})Playwright with axe runs in a real browser, so colour contrast, focus visibility and aria-hidden-focus can be decided. This is from the @axe-core/playwright 4.13.0 README (not run here, no browser):
import AxeBuilder from '@axe-core/playwright'test('home page', async ({ page }) => { await page.goto('/') const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) .exclude('#third-party-chat') .analyze() expect(results.violations).toEqual([])})A good setup: jest-axe for component states (error state, open dialog), Playwright axe for whole pages in CI, and eslint-plugin-jsx-a11y while typing (Next.js includes it by default, per its accessibility doc). Run axe on states too: open the menu, show the error, then scan. A page scan of the closed state tests nothing about the dialog.
Green axe is not accessible. In my runs axe did not flag a clickable div, a placeholder used as a label, an empty role="menu" or a label that differs from the visible text. It reports "incomplete" for contrast and focus checks in jsdom. Treat the tool as a smoke detector, not as proof.
3. Screen reader basics
Test with at least one reader on its common browser pair: NVDA with Firefox or Chrome (Windows), VoiceOver with Safari (Mac and iOS), and TalkBack with Chrome (Android). I did not run any in this lesson. A short first test with VoiceOver on a Mac: turn on with Cmd+F5, open the rotor with VO+U (VO is Control+Option) to list headings, links and landmarks, and move with VO+Right Arrow. Ask: Is every control announced with a name and role? Are headings and landmarks a sensible outline? Are errors and status messages announced? Does a dialog read its title? Different readers say things differently, so judge whether the information is there, not whether the wording matches.
Common mistakes
- Saying "we use ARIA" as proof of accessibility, with
divbuttons that have no keyboard code. - A placeholder as the only label.
- Removing the focus outline (
outline: none) with no replacement. - Hiding a modal's background with
aria-hiddenand leaving it focusable. - SPA route changes with no announcement and no focus move.
- Errors shown only in red, or only in a toast that disappears.
- A live region created at the moment of the message.
- Contrast checked on the design file but not on hover, disabled, dark mode or text over images.
- Trusting a green axe report and never using the keyboard.
Interview questions
What do you check after the basics: semantic HTML, alt text and ARIA? (Must know)
I check four more things. Keyboard: I can reach and use everything, with a visible focus that is not hidden by a sticky header (2.4.11). Forms: each field has a real label, errors are in text, linked with aria-describedby, and there is a summary. Dynamic changes: route changes and status messages are announced, and focus is moved on purpose. And contrast: 4.5:1 for text and 3:1 for borders and focus rings. Then I test with the keyboard and axe, and I know axe finds only part of the problems.
Name some new WCAG 2.2 criteria and what they mean for a developer. (Must know)
2.5.8 Target Size (Minimum), AA: pointer targets at least 24 by 24 CSS pixels, with exceptions for spacing, inline links and similar. 2.4.11 Focus Not Obscured (Minimum), AA: a focused control is not entirely covered by my sticky header or banner. 2.5.7 Dragging Movements, AA: anything done by dragging also works with simple clicks. 3.3.8 Accessible Authentication, AA: do not force a memory or puzzle test; allow password managers and paste. 3.3.7 Redundant Entry and 3.2.6 Consistent Help are level A. 4.1.1 Parsing was removed.
Follow-up: "Which of these can a tool test?" In axe-core 4.14.0 the only wcag22aa rule is target-size. The others need a human.
When do you use aria-label and when a visible <label>? (Must know)
A visible <label for> is the default, because it also gives a bigger click target and helps sighted users. I use aria-label for a control with no visible text, like an icon button. If there is visible text, the accessible name should contain it. Otherwise speech-control users who say "click Cancel" fail (2.5.3). In my test, a button with aria-label="Save" and the text "Cancel" produced no axe violation, so only a human review catches this.
Why is <div role="button"> worse than <button>? (Must know)
The role is only a description. A <button> gives focus, Enter and Space, a disabled state and form behaviour for free. With the div I must add tabindex="0", key handlers for Enter and Space, and the disabled state myself. The ARIA Authoring Practices say "a role is a promise" that the author wrote the matching keyboard code. I use ARIA for things HTML cannot express, not to rebuild native elements.
Placeholder as a label: what is wrong, and does a tool catch it? (Trap)
A placeholder disappears when the user types, so they cannot check what the field was. It also usually has low contrast, is not always a reliable name, and translation tools may skip it. In my axe run the label rule passed for an input with only a placeholder, so a green report hid the problem. I use a visible <label>, put hints in text under it, and link them with aria-describedby.
How do you make form errors accessible? (Must know)
I mark the field aria-invalid="true", write the message as text near the field, and link it with aria-describedby so it is read with the field. I do not use colour alone. On submit I show an error summary at the top, move focus to it, and make each item a link to its field. Then the keyboard user knows how many problems there are and can jump to each. I also set autocomplete tokens and keep paste working. I ran this pattern through axe and got no violations; the focus and reading part I checked by reasoning and by the keyboard walk-through.
In a Next.js (or any SPA) app, what happens for a screen reader user on a client-side navigation, and what do you add? (Senior signal)
By default nothing visible happens, so the user may not know the page changed. Next.js includes a route announcer: an element with role="alert" that reads document.title, then the <h1>, then the URL path. So each page needs a unique title. The announcer does not move focus. I add focus management: after navigation I focus the page <h1> (with tabindex="-1") or <main>, but not on the first load, and not on every re-render. I test it with a screen reader and the keyboard.
What does tabindex do, and which values are safe? (Must know)
tabindex="0" puts an element in the normal tab order. tabindex="-1" makes it focusable by script (el.focus()) but not by Tab. I use -1 for focus targets like the h1, main or an error summary. I never use a value above 0, because it jumps those elements ahead of everything else and breaks a natural order. A native button or a needs no tabindex at all.
How would you build an accessible modal? (Senior signal)
I use the native <dialog> and showModal(). From the HTML spec, that makes the rest of the page inert, treats Esc as a close request, and returns focus to the element that was focused before. I give it a name with aria-labelledby, set autofocus on the safest button, and use form method="dialog" for the buttons. I have not run it in a browser in this lesson, so I would test it in the browsers we support. If I need a custom modal, I put inert on the app root, trap Tab, handle Esc and restore focus, or I use a library that does it (Radix, React Aria).
Follow-up: "Why not just aria-hidden on the background?" It hides content from the accessibility tree but does not stop Tab from reaching it. inert does both.
What is the difference between role="status" and role="alert"? When do you use each? (Must know)
status is a polite live region: it waits for a pause. alert is assertive and read as a whole, so it can interrupt the user. I use status for "Saved", "3 results" and cart updates. I use alert only for errors that need attention now. Too many assertive messages make the page tiresome. This satisfies 4.1.3 Status Messages, which asks for announcements without moving focus.
You add role="status" and set its text, but the screen reader says nothing. Why? (How would you debug)
The most common cause is that the live region was created together with its message. Many readers watch for changes inside a region that already exists. So I render an empty region at page load and then change its text. Next I check it is not display: none or hidden (that removes it; use a visually-hidden class instead), that the text really changes, and that I am not repeating the same text. I test on the readers we support, because behaviour differs.
How do you test accessibility in CI? What can't automation find? (Senior signal)
I use eslint-plugin-jsx-a11y in the editor, jest-axe (or Vitest equivalents) for components in important states, and Playwright with @axe-core/playwright for real pages in a real browser, with tags like wcag2a, wcag2aa and wcag22aa. The axe-core README says it finds on average 57% of WCAG issues, and it marks others as incomplete. My own runs found no violation for a clickable div, a placeholder-only input, and a label that differs from visible text. A person must check keyboard flow, focus order, sensible alt text, reading order, screen reader output and zoom.
Why does jest-axe not report colour contrast, and where do you check it? (Trap)
jsdom does not paint the page, so it cannot know the real colours. The jest-axe README says contrast checks do not work in jsdom, so it turns them off. In my own run, axe returned color-contrast as incomplete, not as a failure. So a green unit test says nothing about contrast. I check it in a real browser with Playwright and axe, in DevTools, and at design time with tokens that are checked against the 4.5:1 and 3:1 numbers.
A designer picks light grey text #999 on white. What do you say? (Senior signal)
I compute the ratio rather than argue. #767676 on white is 4.54:1, the lightest grey that passes for body text; #777777 is 4.48 and fails. #999 is lighter than #949494, which I measured at 3.03:1, so it fails 1.4.3 for normal text. I suggest the nearest passing colour, such as #595959 (7.00:1), and I add the pair as a design token with a contrast check in the design system. Large text only needs 3:1, and borders and icons need 3:1 (1.4.11), so #949494 can be fine for a border.
How do you handle prefers-reduced-motion? (Must know)
I use the media query @media (prefers-reduced-motion: reduce) to remove or shrink large movement such as parallax, zoom and auto-scrolling, and set scroll-behavior: auto. I keep small, useful feedback, like a spinner or a short fade. In JavaScript I read matchMedia('(prefers-reduced-motion: reduce)'). Auto-playing content over 5 seconds needs a pause control (2.2.2) whatever the setting.
What is a keyboard trap, and what is the opposite problem? (Senior signal)
A keyboard trap is a widget where Tab or arrows enter but cannot leave (2.1.2), like a badly built embed or editor. A modal should keep focus inside on purpose, but Esc must leave. The opposite problem is focus that escapes into hidden content behind a modal, or focus lost to <body> after deleting the focused element. I fix the first by testing exits, and the second with inert and by moving focus to the next sensible element.
A sticky header covers the focused field when the user tabs down the page. Which criterion, and how do you fix it? (How would you debug)
That is 2.4.11 Focus Not Obscured (Minimum), AA: the focused control must not be entirely hidden by author-created content. I reproduce it by tabbing through the page at normal size and at 400% zoom. The fix is CSS scroll-padding-top (equal to the header height) on the scroll container, so scrolling to a focused element leaves it below the header. I also check cookie banners and chat widgets that sit on top.
Is a hamburger menu button with only an icon accessible? What do you check? (Must know)
Only if it has an accessible name and state. It must be a real <button> with a name (aria-label="Menu" or visually-hidden text), aria-expanded updated when it opens, and aria-controls pointing to the menu (helpful but not widely announced). The menu opens and closes with Enter, Space and Esc, and focus goes to the first item or stays on the button by a clear rule. The icon needs 3:1 contrast and a target of at least 24 pixels (2.5.8), and I would use more for touch.
Key takeaways
- WCAG 2.2 added 2.4.11 (focus not hidden), 2.5.7 (no drag-only), 2.5.8 (24 pixel targets), 3.2.6, 3.3.7 and 3.3.8 and removed 4.1.1. Know the plain meaning of each.
- Landmarks, one
h1, ordered headings, named repeatednavand a skip link make a page navigable. - Forms need real labels,
fieldset/legend,aria-describedbyfor hints and errors,aria-invalid,autocomplete, and an error summary with focus. - In an SPA, announce the route (Next.js does this with the title, then
h1) and move focus on purpose. Never usetabindexabove 0. - Prefer native
<dialog>withshowModal(), andinertfor custom modals.aria-hiddenalone leaves controls focusable. - Create live regions early, keep them in the accessibility tree, use
statusby default andalertrarely. - ARIA changes description, not behaviour. No ARIA is better than bad ARIA.
- Know the numbers: 4.5:1 for text, 3:1 for large text and UI parts, 24 by 24 pixels for targets, 320 pixel reflow.
#767676on white is the lightest passing grey. - Test with the keyboard, then axe (jest-axe for components, Playwright for pages), then a screen reader. Automation finds only part of the problems.
What's next
Do the 5-minute keyboard walk-through on one of your own pages today and write down what breaks. Then add one axe check for an important state (error form or open dialog) to your tests. For the first layer of this topic, go back to Accessibility Essentials (Frontend Depth, Lesson 6). The next lesson is GraphQL beyond Hasura.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Building an Evaluation Harness for an LLM Feature · AI Engineering
- Documenting APIs: OpenAPI, Contracts and Testing · API Design
- Testing an API: Unit, Integration and Contract · Backend in Depth
Part of the study guide
Related series