DEV_NET_CORE
GET_STARTED
ReactTesting, accessibility, and frontend debugging

Keyboard accessibility, semantic HTML, ARIA, and labels/IDs

Overview

Keyboard accessibility, semantic HTML, ARIA, and labels/IDs are the foundation for building React interfaces that work for keyboard users, screen reader users, voice-control users, switch-device users, and people who rely on browser accessibility features. The goal is not to add accessibility as decoration after the UI is built. The goal is to choose the right HTML elements, connect names and descriptions correctly, preserve keyboard behavior, and use ARIA only where native HTML cannot express the interaction.

This matters because React makes it easy to compose custom components, but custom components can accidentally remove browser behavior that native elements provide for free. A clickable <div> does not behave like a <button>. An unlabeled input is hard to understand. An icon-only button without an accessible name is ambiguous. A modal that does not manage focus can trap or lose keyboard users.

In interviews, this topic tests whether a candidate understands practical accessibility, not just slogans. A strong candidate can explain why semantic HTML should come first, how keyboard focus should move, how labels and IDs connect controls to text, when ARIA is appropriate, and how to test the result with keyboard navigation and user-centric tests.

In real React applications, these skills show up in forms, dialogs, menus, comboboxes, tabs, validation messages, icon buttons, data grids, route changes, and design-system components.

Core Concepts

Accessibility Starts With Semantics

Semantic HTML means using elements according to their meaning and behavior. The browser, accessibility tree, keyboard model, form model, and assistive technologies all benefit from the correct element.

Good:

Code
function SaveButton({ onSave }: { onSave: () => void }) {
  return (
    <button type="button" onClick={onSave}>
      Save changes
    </button>
  );
}

Bad:

Code
function SaveButton({ onSave }: { onSave: () => void }) {
  return (
    <div className="button" onClick={onSave}>
      Save changes
    </div>
  );
}

The native <button> supports focus, keyboard activation, disabled behavior, accessible role, accessible name, and form behavior. The <div> supports none of those unless you rebuild them manually. In interviews, the pragmatic answer is: use the native element unless there is a strong reason not to.

Native Elements Provide Behavior

Native HTML gives useful behavior by default:

  • <button> is focusable and activates with keyboard.
  • <a href> is focusable and works as a link.
  • <input>, <select>, and <textarea> participate in forms.
  • <label> gives form controls an accessible name and larger click target.
  • <form> supports submit with Enter and browser validation hooks.
  • <fieldset> and <legend> group related controls.
  • <main>, <nav>, <header>, <footer>, and <section> communicate page structure.
  • Headings create a navigable document outline.

React components should preserve these behaviors rather than replacing them with generic elements.

Use a button for an action. Use a link for navigation.

Code
// Action: changes UI state or submits an operation.
<button type="button" onClick={openDialog}>
  Delete account
</button>

// Navigation: goes somewhere.
<a href="/billing/invoices">View invoices</a>

Common mistake:

Code
<a onClick={save}>Save</a>

This looks like a link but behaves like a button. It may not be keyboard-accessible, may confuse assistive technology, and may create broken browser behavior because there is no real destination.

Keyboard Accessibility

Keyboard accessibility means every meaningful interactive control can be reached, understood, and operated without a mouse.

Core expectations:

  • Tab and Shift+Tab move between interactive controls.
  • Enter activates links and buttons.
  • Space activates buttons and toggles checkboxes.
  • Arrow keys move within composite widgets such as radio groups, tab lists, menus, listboxes, grids, and sliders.
  • Escape commonly closes popovers, menus, and dialogs.
  • Focus is visible.
  • Focus does not disappear when UI changes.
  • Focus order follows the visual and logical reading order.

The easiest way to satisfy many of these rules is to use native controls. The harder cases are custom widgets.

Focus Visibility

Keyboard users need to know where focus is. Do not remove focus outlines without replacing them with an equally visible focus style.

Bad:

Code
button:focus {
  outline: none;
}

Better:

Code
button:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}

Use :focus-visible when you want focus styling that appears primarily for keyboard-like navigation while avoiding noisy focus rings for mouse users.

Tab Order

The default tab order follows DOM order for naturally focusable elements. Keep DOM order aligned with visual order whenever possible.

Avoid positive tabIndex values:

Code
// Avoid this.
<button tabIndex={3}>Third</button>
<button tabIndex={1}>First</button>
<button tabIndex={2}>Second</button>

Positive tab order is hard to maintain and can make focus movement unpredictable. Prefer:

  • Natural DOM order.
  • tabIndex={0} only when a custom element must join the tab order.
  • tabIndex={-1} when an element should be programmatically focusable but not part of normal tab navigation.

Custom Controls Are Expensive

If you create a custom control, you inherit responsibilities the browser normally handles:

  • Role.
  • Accessible name.
  • Keyboard behavior.
  • Focus visibility.
  • Disabled behavior.
  • Pointer behavior.
  • State attributes such as aria-expanded, aria-selected, or aria-checked.
  • Relationship attributes such as aria-controls, aria-labelledby, or aria-describedby.

Example of a custom disclosure button that still uses a native button:

Code
function FilterDisclosure() {
  const [open, setOpen] = useState(false);
  const panelId = useId();

  return (
    <>
      <button
        type="button"
        aria-expanded={open}
        aria-controls={panelId}
        onClick={() => setOpen((value) => !value)}
      >
        Filters
      </button>

      {open && (
        <section id={panelId} aria-label="Filters">
          <label>
            Search
            <input name="search" />
          </label>
        </section>
      )}
    </>
  );
}

This uses ARIA for state and relationship, but the interactive control is still a native button.

ARIA Basics

ARIA can add accessibility semantics when native HTML cannot express the component. It can define:

  • Roles, such as dialog, tablist, tab, progressbar, or switch.
  • States, such as aria-expanded, aria-selected, aria-checked, aria-invalid, or aria-disabled.
  • Properties, such as aria-label, aria-labelledby, aria-describedby, aria-controls, or aria-live.

ARIA does not add behavior. If you write:

Code
<div role="button">Save</div>

you have told assistive technology that the element is a button, but you still have to implement focus, keyboard activation, disabled behavior, and visible focus. Usually this should just be:

Code
<button type="button">Save</button>

Use Native HTML Before ARIA

Prefer native elements and attributes first:

Code
// Prefer this.
<progress value={75} max={100}>
  75%
</progress>

Instead of rebuilding it:

Code
// More work and more risk.
<div
  role="progressbar"
  aria-valuemin={0}
  aria-valuemax={100}
  aria-valuenow={75}
>
  75%
</div>

ARIA is powerful, but it should be used to fill semantic gaps, not to override good HTML.

Accessible Names

An accessible name is the name assistive technologies expose for an element. It may come from visible text, a <label>, aria-label, aria-labelledby, alt, or other naming rules.

Good:

Code
<button type="button">Close</button>

Good for icon-only buttons:

Code
<button type="button" aria-label="Close dialog">
  <CloseIcon aria-hidden="true" />
</button>

If visible label text exists, prefer using that visible text as the accessible name. Invisible names can drift from the visible UI during redesigns or translation work.

aria-label vs aria-labelledby

Use aria-label when there is no visible text to reference:

Code
<button type="button" aria-label="Remove item">
  <TrashIcon aria-hidden="true" />
</button>

Use aria-labelledby when visible text already exists in the DOM:

Code
<h2 id="billing-title">Billing address</h2>

<section aria-labelledby="billing-title">
  <AddressForm />
</section>

Avoid using both on the same element. If both are present, aria-labelledby takes precedence in accessible name calculation.

Labels and IDs

Form controls need labels. In React, use htmlFor instead of for.

Code
function EmailField() {
  const emailId = useId();

  return (
    <div>
      <label htmlFor={emailId}>Email address</label>
      <input id={emailId} name="email" type="email" autoComplete="email" />
    </div>
  );
}

Benefits:

  • Screen readers can announce the field label.
  • Clicking the label focuses or activates the field.
  • Testing Library can find the input with getByLabelText.
  • The input has a clearer programmatic contract.

Wrapping labels also work:

Code
<label>
  Email address
  <input name="email" type="email" />
</label>

Explicit IDs are often easier when layout separates label and input.

useId

useId generates unique IDs that are useful for accessibility relationships.

Code
function PasswordField() {
  const passwordId = useId();
  const hintId = useId();

  return (
    <div>
      <label htmlFor={passwordId}>Password</label>
      <input
        id={passwordId}
        type="password"
        aria-describedby={hintId}
        autoComplete="new-password"
      />
      <p id={hintId}>Use at least 12 characters.</p>
    </div>
  );
}

Use useId for accessibility attributes such as id, htmlFor, aria-describedby, and aria-labelledby.

Do not use useId for list keys:

Code
// Bad.
items.map((item) => <Row key={useId()} item={item} />);

Keys should come from stable data.

Error Messages and Descriptions

Validation errors should be visible and programmatically connected to the field.

Code
function EmailField({ error }: { error?: string }) {
  const emailId = useId();
  const errorId = useId();

  return (
    <div>
      <label htmlFor={emailId}>Email</label>
      <input
        id={emailId}
        name="email"
        type="email"
        aria-invalid={error ? "true" : "false"}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <p id={errorId} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

aria-invalid communicates invalid state. aria-describedby connects the error text to the field. role="alert" can announce newly rendered error text, but do not overuse alerts for every small UI change.

Live Regions

Use live regions when dynamic content changes without moving focus and users need to be informed.

Code
<p aria-live="polite">{statusMessage}</p>

Use cases:

  • Search result counts.
  • Save status.
  • Background sync state.
  • Form-level validation summary.

Avoid noisy live regions for frequent updates such as every keystroke. Prefer aria-live="polite" for most status messages and reserve assertive announcements for urgent messages.

Dialog Accessibility

A modal dialog needs more than position: fixed.

Important behavior:

  • The dialog has an accessible name.
  • Focus moves inside the dialog when it opens.
  • Tab and Shift+Tab stay inside the dialog.
  • Escape closes the dialog when appropriate.
  • Focus returns to the invoking control when the dialog closes.
  • Content outside the modal is inert or otherwise unavailable.
  • There is a visible close or cancel control.

Example:

Code
function DeleteDialog({
  open,
  onCancel,
  onConfirm,
}: {
  open: boolean;
  onCancel: () => void;
  onConfirm: () => void;
}) {
  const titleId = useId();

  if (!open) {
    return null;
  }

  return (
    <div role="dialog" aria-modal="true" aria-labelledby={titleId}>
      <h2 id={titleId}>Delete account?</h2>
      <p>This action cannot be undone.</p>
      <button type="button" onClick={onCancel}>
        Cancel
      </button>
      <button type="button" onClick={onConfirm}>
        Delete account
      </button>
    </div>
  );
}

In production, use a well-tested dialog primitive or implement focus trapping, inert background behavior, and focus restoration carefully.

Composite Widgets

Composite widgets are controls that contain multiple focusable or selectable items, such as tabs, menus, listboxes, trees, and grids.

Common patterns:

  • Only one item is in the normal tab sequence.
  • Arrow keys move within the widget.
  • Home and End often move to first and last items.
  • Enter or Space activates or selects.
  • aria-selected, aria-expanded, or aria-activedescendant may communicate state.
  • Focus and selection may be different concepts.

Do not invent keyboard behavior for common components. Follow established patterns so users do not have to relearn the interface.

React Component API Design

Reusable components should make accessible usage easy.

Good component API:

Code
type TextFieldProps = {
  id?: string;
  label: string;
  error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;

function TextField({ id, label, error, ...inputProps }: TextFieldProps) {
  const generatedId = useId();
  const inputId = id ?? generatedId;
  const errorId = `${inputId}-error`;

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        {...inputProps}
        id={inputId}
        aria-invalid={error ? "true" : undefined}
        aria-describedby={error ? errorId : inputProps["aria-describedby"]}
      />
      {error && <p id={errorId}>{error}</p>}
    </div>
  );
}

The component requires a label, wires IDs correctly, preserves native input props, and still lets callers pass a stable ID when needed.

Testing Keyboard and Semantics

Use tests that reflect user behavior:

Code
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("opens and closes the menu with the keyboard", async () => {
  const user = userEvent.setup();

  render(<AccountMenu />);

  await user.tab();
  expect(screen.getByRole("button", { name: /account/i })).toHaveFocus();

  await user.keyboard("{Enter}");
  expect(screen.getByRole("menu")).toBeVisible();

  await user.keyboard("{Escape}");
  expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});

Manual checks still matter:

  • Navigate with only keyboard.
  • Check focus order.
  • Check visible focus.
  • Test forms with labels.
  • Try screen reader basics for complex widgets.
  • Inspect the accessibility tree in browser devtools.

Common Mistakes

Common mistakes include:

  • Building buttons from <div> or <span>.
  • Removing focus outlines.
  • Using positive tabIndex.
  • Adding ARIA roles that conflict with native elements.
  • Adding ARIA without keyboard behavior.
  • Using aria-label when visible text should be used.
  • Leaving icon-only buttons unnamed.
  • Failing to connect labels, hints, and errors to inputs.
  • Treating placeholder as a label.
  • Moving focus unpredictably after state changes.
  • Creating modal overlays without focus management.

Best Practices

Best practices include:

  • Use semantic HTML first.
  • Use ARIA to supplement, not replace, native behavior.
  • Give every interactive element an accessible name.
  • Keep focus visible.
  • Keep DOM order and visual order aligned.
  • Prefer button for actions and a href for navigation.
  • Use <label> with htmlFor or a wrapping label for inputs.
  • Use useId for component-scoped accessibility IDs.
  • Connect helper and error text with aria-describedby.
  • Follow established ARIA patterns for custom widgets.
  • Test with keyboard and semantic queries.

Interview Practice

PreviousDebugging rendering, hydration, and interaction issuesNext UpQuery strategy and avoiding implementation-detail assertions