Senior Frontend Depth

Accessibility Essentials Interviewers Check

Semantic HTML first, focus in modals, ARIA only when needed, and testing with the keyboard.

Lesson 0612 min read#accessibility#html#aria

On this page

  1. Key terms first
  2. The first rule: use semantic HTML
  3. Names for everything interactive
  4. Keyboard support
  5. Focus management (the modal case)
  6. ARIA: when and when not
  7. Forms and errors
  8. Colour, contrast and motion
  9. Custom widgets: follow established patterns
  10. Testing accessibility
  11. Common mistakes
  12. Interview questions
  13. Key takeaways
  14. What's next

In one line: accessibility is mostly using the right HTML and not breaking the keyboard. Native elements (the ones built into HTML) come with roles, focus and keyboard behaviour for free. ARIA only fills the gaps that HTML cannot cover. Bad ARIA is worse than no ARIA.

Accessibility (written "a11y") means making your site usable by everyone, including people who use a screen reader or only a keyboard. In many places it is a legal rule. Interviewers also use it to judge quality. It also makes your product better for everyone: people with only a keyboard, a small screen, bright sunlight or a bad day. This lesson covers what senior frontend interviews check and how to test it.

Key terms first

  • A screen reader is software that reads the screen aloud (or sends it to a braille display) for people who cannot see well. Examples: VoiceOver (Apple), NVDA and JAWS (Windows), TalkBack (Android).
  • Assistive technology is any tool that helps a person use a computer, such as a screen reader, a switch, or voice control.
  • The accessibility tree is the version of the page that the browser builds for assistive technology. It lists each element with its role (what kind of thing it is, such as button or heading), its name and its state (such as checked or expanded).
  • The DOM (Document Object Model) is the live tree of elements that the browser builds from your HTML.
  • WCAG (Web Content Accessibility Guidelines) is the official standard for web accessibility. It has levels A, AA and AAA. Most laws and contracts ask for AA.
  • A single-page app (SPA) is a site that changes the screen with JavaScript instead of loading a new page for each link.
  • tabIndex is an attribute that controls keyboard focus. tabIndex={0} puts an element in the normal Tab order. tabIndex={-1} makes it focusable by code but skips it when you press Tab.

The first rule: use semantic HTML

Semantic HTML means using the HTML element that matches the meaning of the content (a <button> for an action, <nav> for navigation, <h2> for a heading). The browser already knows how to show a <button> to assistive technology (tools like screen readers that help people use a page). A button can get focus (it is the element that receives keyboard input). It works with Enter and Space. It has the role "button". It announces its label. A <div onClick> has none of this.

JavaScript
// ❌ Looks like a button, behaves like nothing for keyboard and screen-reader users<div className="btn" onClick={save}>Save</div>// ✅ One native element does everything<button type="button" onClick={save}>Save</button>// If you truly cannot use <button> (rare), you must re-implement everything:<div role="button" tabIndex={0} onClick={save}     onKeyDown={(e) => {       if (e.key === "Enter" || e.key === " ") { e.preventDefault(); save(); }   // preventDefault stops Space from scrolling the page     }}>Save</div>
NeedUseNot
An action<button><div onClick> or <a onClick>
Navigation to a URL<a href><button onClick={navigate}>
Text input<input>/<textarea> with a <label>A contenteditable div
Choose one option<select>, radio group (<fieldset>/<legend>)A styled div list (unless you build a full pattern)
Page structure<header> <nav> <main> <footer> <section> <h1…h6>A tower of <div>s
Lists<ul>/<ol>/<li>Line-broken text
Tabular data<table> with <th scope>, <caption>Div grids faking a table
Modal (a popup that blocks the page behind it)<dialog> with showModal()A positioned div

The structure of the page matters too:

  • Use one <h1> per page.
  • Put headings in order and do not skip levels. Screen-reader users jump around by headings.
  • Use landmarks such as main and nav. Landmarks are named areas of the page that users can jump to. (<header>, <nav>, <main> and <footer> create them for free.)
  • Make a skip link ("Skip to main content") the first focusable element. It lets keyboard users jump past the menu.

Names for everything interactive

Every control needs an accessible name. This is the text a screen reader says out loud for that control. Here is how to give one:

  • Use visible label text: <label htmlFor="email">Email</label><input id="email" />. You can also wrap the input inside the label.
  • Buttons use their text content. Icon-only buttons need a name: <button aria-label="Close dialog"><XIcon aria-hidden="true" /></button>.
  • Images need useful alt text that tells the purpose of the image. For decorative images, use alt="" so screen readers skip them.
  • Links need text that makes sense on its own. Write "Read the pricing guide", not "click here".
  • Groups use a fieldset (an HTML element that groups related fields): <fieldset><legend>Shipping method</legend>…</fieldset>. The <legend> is the group's name.
  • For extra help text, use aria-describedby, an attribute that points at the id of the element that describes this one (a hint or an error).
  • Difference in one line: aria-label and aria-labelledby give the name ("what is it?"), and aria-describedby gives extra description ("more details").

A placeholder is not a label. It disappears when you type, and it often has low contrast.

Keyboard support

Everything a mouse can do, a keyboard must also do.

  • Every interactive element must be reachable with Tab, in a logical order. The DOM order should match the visual order. Avoid positive tabindex values (like tabindex="3"), because they override the natural order and confuse people.
  • Show a visible focus indicator (the outline around the focused element). Never write outline: none without a replacement. :focus-visible is a CSS selector that matches an element only when the browser decides focus should be shown, which is mostly for keyboard users.
  • No keyboard traps. A keyboard trap is a place where focus gets stuck. Users must be able to leave every component. The one exception is a modal on purpose, and there Escape closes it.
  • Use the standard keys. Enter and Space activate buttons. Enter follows links. Escape closes popups. Arrow keys move inside composite widgets (widgets made of several parts, such as tabs, menus, listboxes and radio groups). Home and End jump to the first and last item.
  • Test it yourself. Unplug your mouse and use the app for five minutes.

Roving tabindex (composite widgets)

A tab list or toolbar should be one Tab stop (one place that Tab lands on). The arrow keys then move inside it. This technique is called "roving tabindex": only one item at a time can be reached with Tab, and that item changes as you move.

JavaScript
// Only the active tab is tabbable; arrows move focus and selection<div role="tablist" aria-label="Account">  {tabs.map((t, i) => (    <button key={t.id} role="tab" id={`tab-${t.id}`} aria-selected={i === active} aria-controls={`panel-${t.id}`}            tabIndex={i === active ? 0 : -1} onKeyDown={(e) => onArrow(e, i)} onClick={() => setActive(i)}>      {t.label}    </button>  ))}</div><div role="tabpanel" id={`panel-${tabs[active].id}`} aria-labelledby={`tab-${tabs[active].id}`}>…</div>

Focus management (the modal case)

Focus is the element that gets the keyboard input right now. When content appears or disappears, you must decide where focus goes.

A modal dialog must do these things:

  1. Move focus into the dialog when it opens. Go to the first focusable element or to the dialog heading.
  2. Keep focus inside while it is open. This is called a focus trap: a rule that keeps focus inside one area on purpose. Tab and Shift+Tab loop around inside the dialog.
  3. Make the rest of the page inert. Inert means it cannot be used. Put inert (an HTML attribute that makes an element and its children unusable and invisible to assistive technology) or aria-hidden (an attribute that hides an element from assistive technology only) on the background, so assistive technology and clicks cannot reach it.
  4. Close on Escape, and also with a visible close button.
  5. Return focus to the element that opened the dialog when it closes.
  6. Have an accessible name (aria-labelledby pointing at the title) and aria-modal="true". (A native <dialog> opened with showModal() already behaves as modal.)
JavaScript
// The native element gives you most of this: focus moves in, Escape closes, the background is inert,// and focus returns to the trigger on close.function Confirm({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {  const ref = useRef<HTMLDialogElement>(null);  useEffect(() => {    const d = ref.current!;    if (open && !d.open) d.showModal();    if (!open && d.open) d.close();  }, [open]);  return (    <dialog ref={ref} aria-labelledby="confirm-title" onClose={onClose}>      <h2 id="confirm-title">Delete this project?</h2>      {children}      <button onClick={onClose}>Cancel</button>    </dialog>  );}

Use the native <dialog> or a well-tested library (Radix UI, React Aria, Headless UI). Do not build your own focus trap.

Here are other focus rules:

  • After a route change in a single-page app, move focus to the new page's heading, or announce the change. The browser does not reload the page, so nothing else tells the user.
  • After you delete an item, move focus to a sensible nearby element.
  • After a form error on submit, move focus to the error summary or to the first invalid field.

ARIA: when and when not

ARIA (Accessible Rich Internet Applications) is a set of HTML attributes (role, aria-*) that tell assistive technology what an element is and what state it is in. They add meaning that HTML cannot express. There are five rules:

  1. Do not use ARIA if a native HTML element or attribute can do the job.
  2. Do not change native meaning. For example, do not write <h2 role="button">. Use the right element.
  3. Every interactive ARIA control must work with the keyboard.
  4. Do not use role="presentation" or aria-hidden="true" on focusable elements.
  5. Every interactive element must have an accessible name.

A well-known saying from the ARIA Authoring Practices says: no ARIA is better than bad ARIA. ARIA changes what is announced. It does not change what the element does. For example, role="button" on a div does not add focus or key handling.

When ARIA is harmful

  • role="button" on a div without tabIndex and key handlers. It is announced as a button, but nobody can use it.
  • An aria-label that says something different from the visible text. Voice-control users say what they see, so the command fails.
  • aria-hidden="true" on a focusable element, or on content users need. Focus then lands on something silent.
  • An aria-expanded value that is not updated when the state changes. The announcements are wrong.
  • Extra roles that repeat the native one, such as <button role="button"> and <nav role="navigation">. They only add noise.
  • Wrong or made-up roles. Also roles that are missing their required parts, such as a listbox without options.

ARIA that is genuinely useful

AttributeUse
aria-label / aria-labelledbyName controls with no visible text, or label regions
aria-describedbyLink a control to hint or error text
aria-expanded, aria-controlsState of disclosure buttons, menus, accordions
aria-current="page"The current item in navigation
aria-invalid="true" + aria-describedbyForm field errors
aria-live, role="status", role="alert"Announce dynamic changes (toasts, results count, form errors)
aria-busyA region is loading
aria-hidden="true"Hide purely decorative content (icons) from assistive technology
aria-selected, aria-checked, aria-pressedState in custom widgets

Live regions are parts of the page that a screen reader watches for changes. Use role="status" (polite: it waits until the user is idle) for updates that are not urgent, like "12 results". Use role="alert" (assertive: it interrupts) only sometimes, for errors that need attention right away. For aria-live and role="status", the region must be in the DOM before its content changes. A role="alert" element is usually announced even when it is added to the page with its text already inside, but an alert that already exists is the most reliable.

Forms and errors

JavaScript
<label htmlFor="email">Email</label><input id="email" type="email" autoComplete="email" required       aria-invalid={!!error} aria-describedby={error ? "email-error" : "email-hint"} /><p id="email-hint">We'll never share it.</p>{error && <p id="email-error" role="alert">{error}</p>}
  • Every input has a visible label. Use the correct type and autoComplete.
  • Errors: say in text what is wrong and how to fix it. A red border alone is not enough. Link the error to its field. After a failed submit, move focus to the first error or to an error summary.
  • Mark required fields with text, not with colour alone.
  • Do not rely only on a disabled submit button. Explain what is missing.

Colour, contrast and motion

  • Contrast (WCAG AA). Contrast ratio is a number from 1:1 (no difference) to 21:1 (black on white) that measures the difference in brightness between text and its background. Text needs at least 4.5:1 for normal size. It needs 3:1 for large text (about 24 px, or 19 px bold). It also needs 3:1 for UI components and meaningful graphics, such as input borders, icons and focus rings.
  • Do not use colour as the only signal for error states, charts or status. Add text, icons or patterns.
  • Respect user settings. Support prefers-reduced-motion and prefers-color-scheme. These are CSS media queries that tell you if the user asked for less animation, or for a light or dark theme. Allow text zoom up to 200%. Let the layout reflow at 320 px width with no sideways scrolling. Use relative units (rem, which is relative to the root font size), not fixed pixel text.
  • Target size. A target is the area a user can click or tap. Make touch targets big enough. WCAG 2.2 (level AA) sets a minimum of 24 by 24 CSS px, with some exceptions. Around 44 px is a common comfortable size.
  • Time limits and moving content (such as carousels) need pause controls.
CSS
@media (prefers-reduced-motion: reduce) {  * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}:focus-visible { outline: 3px solid currentColor; outline-offset: 2px; }

Custom widgets: follow established patterns

Dropdowns and comboboxes, menus, tabs, accordions, date pickers, tree views and data grids are hard to make accessible. Here are your options, from best to worst:

  1. Use the native element (<select>, <details>/<summary> for show and hide sections, <input type="date">, <dialog>) when its look and behaviour are good enough.
  2. Use an accessible headless or UI library (Radix UI, React Aria, Headless UI and Ariakit are examples). These libraries already implement the keyboard and ARIA patterns correctly. ("Headless" means the library gives behaviour but no styles.)
  3. Follow the WAI-ARIA Authoring Practices pattern (a W3C guide that shows the correct roles, states and keys for each kind of widget) exactly (roles, states, keys). Do not make up your own.

Scenario: a custom dropdown doesn't work with screen readers. Fix it.

  1. Find the problem. Try it with the keyboard only. Then try a screen reader (VoiceOver or NVDA). Typical findings: the trigger is a div (it cannot get focus and has no role). The options are unlabeled divs. The arrow keys do nothing. The open state is not announced. The selection is not announced. Escape does not close it. Focus is lost.
  2. Decide. Can a native <select> do the job? If yes, use it, and style it lightly. If you need custom UI (search, rich options), use a library's Select or Combobox. Or build the combobox/listbox pattern yourself.
  3. Build the pattern. A combobox is a control that combines a text field or button with a popup list. A listbox is that popup list. Use an <input> (or, for a select-only control, a focusable div with tabIndex={0}) with role="combobox", aria-expanded, aria-controls and aria-activedescendant (an attribute that names which list item is the current one, while real focus stays on the trigger). Add aria-haspopup="listbox" for older assistive technology. Focus stays on the trigger. The popup has role="listbox" and holds role="option" items with aria-selected. Support these keys: Enter, Space and ArrowDown open the list. Arrows move. Home and End jump. Typing letters jumps to a match (type-ahead). Enter selects. Escape closes and returns focus. Announce the selected value.
  4. Check it. Test with the keyboard, a screen reader and automated checks. Add regression tests (tests that stop an old bug from coming back) (Lesson 05). The autocomplete lesson shows this pattern in code (Practical Design Problems, Lesson 06).

Testing accessibility

  • Static (checked without running the app): eslint-plugin-jsx-a11y is a lint plugin (a tool that reads code and warns about mistakes). It finds missing alt text, invalid ARIA and click handlers on divs.
  • Automated: use axe-core (an open-source engine that scans a page for accessibility problems) through jest-axe, @axe-core/playwright or the axe DevTools extension. You can also use Lighthouse (a Chrome tool that audits a page), which includes an accessibility audit. These tools find only part of the issues (often quoted as roughly a third to a half), such as contrast, missing labels and invalid roles. They do not check keyboard flow or the screen-reader experience.
  • Manual: do a keyboard-only pass (Tab order, focus visibility, traps, Escape). Zoom to 200%. Try a screen reader on the key flows. Try high-contrast mode and the reduced-motion setting. (High-contrast mode is an operating-system setting that forces strong colours.)
  • Query by role in tests. If getByRole("button", { name: "Save" }) cannot find the element, assistive technology probably cannot find it either.
  • In CI (the automatic checks that run on a server), run axe on the main pages. Fail the build on serious or critical violations. Track regressions.

Common mistakes

  1. Clickable divs and spans instead of buttons and links.
  2. Removing focus outlines with no replacement.
  3. Missing labels (inputs with only a placeholder, icon buttons with no name).
  4. ARIA used to cover up bad HTML (and wrong or repeated ARIA).
  5. Modals without focus management (no trap, no restore, background still reachable).
  6. Colour as the only signal, and low contrast.
  7. No focus management after route changes or dynamic updates.
  8. Relying only on automated tools and skipping keyboard and screen-reader testing.

Interview questions

When is ARIA harmful?

It is harmful when it describes the element wrongly, or adds meaning without behaviour. Examples are role="button" on a div with no keyboard support, an aria-label that conflicts with the visible text, aria-hidden on focusable content, state attributes that are not updated such as aria-expanded, and repeated roles. ARIA changes what is announced, not what the element does. So I use native HTML first. No ARIA is better than bad ARIA.

How do you trap focus inside a modal?

I prefer the native <dialog> opened with showModal(). It moves focus in, makes the background inert, closes on Escape and restores focus. If I build it by hand, I move focus to the dialog on open. I make Tab and Shift+Tab loop inside its focusable elements. I set aria-modal and an accessible name. I mark the background inert or aria-hidden. I close on Escape and return focus to the trigger.

Scenario: a custom dropdown doesn't work with screen readers. Fix it.

First I check the keyboard and screen-reader behaviour. Then I replace it with a native <select> if that fits. If not, I use a proven accessible component library, or I build the WAI-ARIA combobox/listbox pattern. That means the right roles and states (aria-expanded, aria-controls, aria-activedescendant, aria-selected), full keyboard support, focus handling and announcements. I check it with a screen reader and add automated and regression tests.

What are the contrast requirements?

WCAG AA asks for 4.5:1 for normal text and 3:1 for large text. It also asks for 3:1 for UI components and graphics, such as input borders, icons and focus indicators. I also avoid showing information with colour alone.

Why use a <button> instead of a clickable div?

A native button gets focus, works with Enter and Space, and tells assistive technology its role and name. It also supports the disabled state and form behaviour, and it works with browser features. All of this is the default. A div needs role, tabIndex, key handlers and styling to come close to this, and it usually gets something wrong.

How do you handle focus after a route change in a single-page app?

There is no page load to reset focus or announce the change. So I move focus to the new page's main heading, or to a wrapper with tabIndex={-1}. Or I announce the title in a live region. I also update document.title. Frameworks like Next.js give you a route announcer, but I still test it.

How do you announce dynamic content to screen readers?

I use a live region that already exists before the update. I use role="status" or aria-live="polite" for messages that are not urgent, such as result counts and "saved" messages. I use role="alert" for urgent errors. I keep messages short and I do not announce too much.

What can automated accessibility testing not catch?

It cannot check keyboard flow and focus order. It cannot judge if labels make sense or if alt text is good. It cannot check how a screen reader announces dynamic updates. It cannot check if the heading structure is logical, or if custom widgets are easy to use. Automation finds part of the issues, such as missing names, contrast and invalid ARIA. The rest needs manual keyboard and assistive-technology testing.

Scenario: keyboard users can still tab into the page behind an open modal. How do you fix it?

The background is not inert and focus is not trapped. I use the native <dialog> with showModal(), which makes the rest of the page inert. Or I mark the rest of the page inert or aria-hidden. I move focus into the dialog on open and make Tab loop inside it. I close on Escape and return focus to the trigger. I test with the keyboard and a screen reader.

Key takeaways

  • Semantic HTML first. Native elements give roles, focus and keyboard behaviour for free.
  • Everything interactive needs an accessible name, keyboard support and a visible focus indicator.
  • Manage focus for modals, route changes, deletions and errors. Prefer the native <dialog> or proven libraries.
  • ARIA fills gaps. Wrong ARIA does harm. Know the useful attributes and the five rules.
  • Meet contrast (4.5:1 for text, 3:1 for large text and UI), do not rely on colour, and respect reduced motion and zoom.
  • Test with lint, axe, the keyboard and a screen reader. Use getByRole in tests.

What's next

That completes the series track. Go back to the Browser Contract, Backend in Depth, Scenario Debugging and Practical Design Problems for revision. Or continue with the DSA series for algorithm practice.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series