Accessible Web Components

React Angular · 11 patterns

Accessible Web Components

Practical patterns for building accessible interfaces. Each example shows a common mistake, its impact, and a concise implementation in React and Angular.

Before using the examples

These excerpts intentionally show only the code relevant to each accessibility pattern. They are not complete standalone application files or copy-and-paste applications. Unrelated state management, translations, styling, data models, and business logic may be omitted. Framework and library APIs, and non-obvious project utilities, are identified below; reusable supporting code appears at the end of the guide.

  • Native web platform Semantic HTML, ARIA, and browser or JavaScript APIs; no package required.
  • Framework / library React and Angular APIs, plus their explicitly named libraries.
  • Project-local Museum-specific hooks, translation accessors, state, handlers, data helpers, and the recurring visually-hidden CSS utility.

React

  • react for JSX, hooks, and refs.
  • react-router-dom where routing examples use NavLink, Routes, Route, or useLocation.
  • No accessibility package was added during the case-study remediation.
  • useModalFocus is a project-local hook, not a library.

Angular

  • Angular core for components, Signals, effects, and template binding.
  • @angular/router for routing examples.
  • @angular/forms for ngModel and ngSubmit.
  • @angular/cdk/a11y for modal focus management.

Angular CDK is Angular’s officially maintained Component Dev Kit; it is not Angular core. CDK was already installed in the thesis case-study, so remediation added no npm dependency. A different Angular project must have compatible Angular CDK A11y support available to use the CDK modal example.

Browse the 11 components

Language selector and document language

Expose the selected language and keep the root lang value synchronized with the content.

Styling is the only indication of selection

The IT/EN buttons change visible strings, but their active state remains visual-only. A fixed html lang="en" value also becomes incorrect when the content starts in Italian.

Common mistake · header
<button className={lang === "it" ? "active" : ""}>IT</button>
<button className={lang === "en" ? "active" : ""}>EN</button>

<!-- Static shell does not match the default Italian content -->
<html lang="en">

User impact

  • Selection depends on color or styling that non-visual users cannot perceive.
  • A screen reader may apply English pronunciation rules to Italian content.

Use the existing language state as the single source of truth

Group the native buttons, bind aria-pressed, keep the visible IT/EN token in each accessible name, and update document.documentElement.lang without moving focus.

Implementation

React Context + effect

Header.tsx + LanguageContext.tsx
const { currentLang, t, setLang } = useLanguage();

<div
  className="lang-switcher lang-switcher--mobile"
  role="group"
  aria-label={t().a11y.languageSelector}
>
  <button
    type="button"
    aria-label={`IT — ${t().a11y.italianLanguage}`}
    aria-pressed={currentLang === "it"}
    onClick={() => setLang("it")}
  >IT</button>
</div>

const [currentLang, setCurrentLang] = useState<Lang>("it");
const t = () => TRANSLATIONS[currentLang];
const setLang = (lang: Lang) => setCurrentLang(lang);

useEffect(() => {
  document.documentElement.lang = currentLang;
}, [currentLang]);

Project-local useLanguage, the callable t accessor, and TRANSLATIONS belong to the application’s local Context. The effect is a React hook; document is a native browser API.

Angular Signal service + effect

header.html + language.service.ts
<div
  class="lang-switcher lang-switcher--mobile"
  role="group"
  [attr.aria-label]="lang.t().langSelect"
>
  <button
    type="button"
    [attr.aria-label]="lang.t().langItalian"
    [attr.aria-pressed]="lang.currentLang() === 'it'"
    (click)="lang.setLang('it')"
  >IT</button>
</div>

export type Lang = 'it' | 'en';

export class LanguageService {
  private readonly document = inject(DOCUMENT);
  currentLang = signal<Lang>('it');
  t = computed(() => TRANSLATIONS[this.currentLang()]);

  constructor() {
    effect(() => {
      this.document.documentElement.lang = this.currentLang();
    });
  }

  setLang(lang: Lang): void {
    this.currentLang.set(lang);
  }
}

Project-local LanguageService and TRANSLATIONS are application code. inject, signal, computed, and effect come from Angular core; DOCUMENT is injected from @angular/common and still represents the native document.

React vs Angular

Same semantic result Context state and a Signal service are different containers for the same rule: selection state and document metadata must follow one value.

Accessibility checks

  • Screen reader
  • State
  • Document language
  • WCAG 4.1.2
  • WCAG 3.1.1

SPA route orientation and page headings

After client-side navigation, update the title and move focus to the new view’s meaningful H1.

The URL and content change, but focus stays in the old context

Client-side routing can replace the view without updating the document title or moving focus. Without a meaningful H1, heading navigation may also begin at a later H2.

Common mistake · App.tsx
<Routes>
  <Route path="/collection" element={<Collection />} />
  <Route path="/tickets" element={<Tickets />} />
</Routes>

// The view changes, but there is no title update or focus target.

User impact

  • Keyboard focus remains on a link in the navigation that initiated the old context.
  • A screen reader may provide no immediate indication that a new view rendered.
  • Missing principal headings weaken page structure and the focus destination.

Orient once, after a genuine route change

Give every route one visible H1, update the localized document title, then focus that H1 with tabindex="-1". Exclude initial load and language-only updates so focus is not stolen.

Implementation

React Location + refs/effects

App.tsx + page component
const location = useLocation();
const { currentLang, t } = useLanguage();
const previousPathname = useRef(location.pathname);

useEffect(() => {
  const pageTitles = t().a11y.pageTitles;
  const pageTitle =
    location.pathname === "/collezione"
      ? pageTitles.collezione
      : location.pathname === "/biglietti"
        ? pageTitles.biglietti
        : location.pathname === "/contatti"
          ? pageTitles.contatti
          : pageTitles.home;
  document.title = `${pageTitle} | ${t().a11y.siteName}`;
}, [currentLang, location.pathname, t]);

useEffect(() => {
  if (previousPathname.current === location.pathname) return;
  previousPathname.current = location.pathname;

  const frame = window.requestAnimationFrame(() => {
    document.getElementById("page-title")?.focus();
  });

  return () => window.cancelAnimationFrame(frame);
}, [location.pathname]);

<h1 id="page-title" tabIndex={-1}>{t().collezione.titolo}</h1>

Framework / library useLocation (and the route-shell Routes/Route components) come from react-router-dom. useEffect and useRef come from React. requestAnimationFrame and document are native browser APIs; useLanguage is project-local.

Angular Router activation + Title

app.html + app.ts
<router-outlet (activate)="onRouteActivate()"></router-outlet>

effect(() => {
  this.documentTitle.setTitle(
    `${this.pageLabel()} | ${this.lang.t().shell.siteName}`
  );
});

onRouteActivate(): void {
  if (!this.focusHeadingAfterActivation) return;

  this.focusHeadingAfterActivation = false;
  queueMicrotask(() => {
    const heading = this.document.querySelector<HTMLElement>(
      "#contenuto-principale h1"
    );
    if (!heading) return;

    heading.tabIndex = -1;
    heading.focus();
  });
}

Framework / library RouterOutlet, Router, and navigation events come from @angular/router; Title comes from @angular/platform-browser, and effect from Angular core. queueMicrotask and document are native web APIs.

React vs Angular

Mechanism differs Angular waits for RouterOutlet.activate and uses its Title service. React watches location state and schedules focus after render. Both guard against initial-load focus movement.

Accessibility checks

  • Focus
  • Screen reader
  • Heading structure
  • WCAG 2.4.2

Filters and settled result feedback

Associate visible prompts, expose chip state, and announce only the result count that survives rapid input.

Placeholder text and visual chip styling stand in for semantics

The search and range controls lacked associated labels, selected decades were styling-only, and filtered results changed silently.

Common mistake · Collezione.tsx
<input type="search" placeholder={t.searchPlaceholder} />

<button className={selected ? "chip active" : "chip"}>
  1960s
</button>

<span>{t.productionYear}</span>
<input type="range" min="1940" max="1990" />

User impact

  • Placeholders disappear during input and are not dependable labels.
  • Pressed filter state and the number of results are unavailable non-visually.
  • Announcing on every keystroke would create a different problem: excessive speech.

Filter immediately; debounce only the announcement

Use real labels, a named non-form group, and aria-pressed. Keep visual filtering responsive, but cancel superseded timers and publish one result count after 350 ms. Suppress the initial count because no user action occurred.

Implementation

React Labels + effect cleanup

Collezione.tsx
useEffect(() => {
  if (filterRevision === 0) return;
  const timer = window.setTimeout(() => {
    setAnnouncedResultCount(modelliVisibili.length);
  }, 350);
  return () => window.clearTimeout(timer);
}, [filterRevision, modelliVisibili.length]);

<label className="visually-hidden" htmlFor="collection-search">
  {t().collezione.searchPlaceholder}
</label>
<input
  id="collection-search"
  type="search"
  value={ricerca}
  onChange={(e) => {
    setRicerca(e.target.value);
    setFilterRevision((revision) => revision + 1);
  }}
/>

<div role="group" aria-label={t().collezione.decadeGroupLabel}>
  {DECADI.map((d) => (
    <button
      key={d}
      type="button"
      onClick={() => toggleDecade(d)}
      aria-label={t().collezione.decadeLabel(d)}
      aria-pressed={filtroDecade === d}
    >{d}s</button>
  ))}
</div>

<p className="visually-hidden" role="status" aria-live="polite">
  {announcedResultCount === null
    ? ""
    : t().collezione.resultsCount(announcedResultCount)}
</p>

Angular Labels + Signal effect cleanup

collezione.html + collezione.ts
<label class="visually-hidden" for="collection-search">
  {{ lang.t().collezione.searchPlaceholder }}
</label>
<input id="collection-search" type="search" (input)="onRicerca($event)" />

<div role="group" aria-labelledby="collection-decade-filter-label">
  <span id="collection-decade-filter-label" class="visually-hidden">
    {{ lang.t().collezione.decadeFilterLabel }}
  </span>
  @for (d of decadi; track d) {
    <button
      type="button"
      [attr.aria-pressed]="filtroDecade() === d"
      (click)="toggleDecade(d)"
    >{{ d }}s</button>
  }
</div>

effect((onCleanup) => {
  const filtersUsed = this.filtriInteragiti();
  const count = this.modelliVisibili().length;
  const copy = this.lang.t().collezione;
  this.risultatiAnnuncio.set('');
  if (!filtersUsed) return;

  const template = count === 1 ? copy.resultsCountOne : copy.resultsCountMany;
  const timer = setTimeout(() => {
    this.risultatiAnnuncio.set(template.replace('{count}', String(count)));
  }, 350);
  onCleanup(() => clearTimeout(timer));
});

React vs Angular

Same interaction policy React uses effect cleanup; Angular uses Signal effect cleanup. The Angular version uses a named role="group" because these chips are filters, not a submitted form fieldset.

Accessibility checks

  • Labels
  • State
  • Live region
  • Focus retention
  • WCAG 1.3.1
  • WCAG 4.1.2
  • WCAG 4.1.3

Expandable cards with stable relationships

Identify each repeated toggle, expose its state, and keep its controlled panel addressable.

A generic switch reveals a conditionally created panel

Repeated toggles did not identify a model, expose expanded state, or point to the revealed content. Repeated “Wikipedia” links also lacked model context.

Common mistake · Collezione.tsx
<span>More information</span>
<button className={open ? "toggle on" : "toggle"}
        onClick={() => toggleCard(model.id)}>…</button>

{open && <div className="details">…<a href={model.wiki}>Wikipedia</a></div>}

User impact

  • “Button” or “More information” is ambiguous across many cards.
  • Expanded/collapsed state and the controlled region are not announced.
  • Repeated links do not reveal which model they describe.

Compose a contextual name and retain a stable panel

Use a native button, include the model in its accessible name, bind aria-expanded and aria-controls, and keep the target in the DOM with hidden. Ensure component CSS does not override the hidden state.

Implementation

React Name composition in the DOM

Collezione.tsx
<p id={`model-title-${m.id}`}>{m.nome}</p>
<span id={`model-toggle-label-${m.id}`}>
  {isAperta(m.id) ? t().collezione.lessInfo : t().collezione.moreInfo}
</span>
<button
  type="button"
  aria-labelledby={
    `model-toggle-label-${m.id} model-title-${m.id}`
  }
  aria-expanded={isAperta(m.id)}
  aria-controls={`model-details-${m.id}`}
  onClick={() => toggleCard(m.id)}
/>
<div id={`model-details-${m.id}`} hidden={!isAperta(m.id)}>
  …
</div>

Existing visible text supplies the name through aria-labelledby.

Angular State-derived label binding

collezione.html
<button
  type="button"
  [attr.aria-label]="toggleCardLabel(modello)"
  [attr.aria-expanded]="isAperta(modello.id)"
  [attr.aria-controls]="dettagliId(modello.id)"
  (click)="toggleCard(modello.id)"
>…</button>

<div
  [id]="dettagliId(modello.id)"
  [hidden]="!isAperta(modello.id)"
>…</div>

The helper builds a visible-label-first, model-specific string.

Project-local isAperta, toggleCard, dettagliId, and toggleCardLabel are methods on the local Collezione component; no disclosure package is involved.

React vs Angular

Same disclosure contract React composes existing visible nodes; Angular binds a generated string. A native details/summary may be simpler when the bespoke card layout is not required.

Accessibility checks

  • Accessible name
  • Expanded state
  • Stable relationship
  • WCAG 4.1.2

Keyboard-operable image actions

If an image performs an action, put that action on a native interactive element.

A click handler turns an image into a pointer-only control

A click handler can open a lightbox from a plain image, but ordinary Tab navigation still cannot reach it.

Common mistake · Collezione.tsx
<img
  className="col-card-img col-card-img--zoom"
  src={m.img}
  alt={m.nome}
  loading="lazy"
  onClick={() => setLightboxModello(m)}
/>

User impact

  • Pointer users can zoom; keyboard users cannot reach the same action.
  • An image role does not communicate that activation is available.

Wrap the visual in a reset-styled native button

A button enters the Tab order and supplies Enter/Space activation without custom key handlers. Give the action a model-specific name. Do not repair a clickable image with only tabindex="0".

Implementation

React Native JSX button

Collezione.tsx
<button
  type="button"
  className="col-card-image-button"
  aria-label={t().collezione.zoomImage(m.nome)}
  onClick={(event) => {
    lightboxTriggerRef.current = event.currentTarget;
    setLightboxModello(m);
  }}
>
  <img src={m.img} alt="" loading="lazy" />
</button>

The child image is decorative because the button name already contains the model and action.

Project-local lightboxTriggerRef is returned by the local useModalFocus hook documented with the modal pattern. The surrounding model loop and translation data are intentionally omitted.

Angular Native template button

collezione.html
<button
  type="button"
  class="col-card-img-btn"
  [attr.aria-label]="zoomLabel(modello.nome)"
  (click)="apriLightbox(modello)"
>
  <img
    class="col-card-img col-card-img--zoom"
    [src]="modello.img"
    [alt]="modello.nome"
    loading="lazy"
  />
</button>

The Angular version retains the image alternative; the explicit button label still defines the action name.

Project-local zoomLabel and apriLightbox are methods on the local Collezione component; no image-action library is involved.

React vs Angular

HTML is the fix The meaningful framework difference begins later: React records the trigger for its modal hook; Angular’s CDK captures prior focus when the dialog opens.

Accessibility checks

  • Keyboard
  • Native control
  • Accessible name
  • Focus order
  • WCAG 2.1.1

Genuine modal dialog behavior

A blocking overlay needs naming, entry, containment, Escape, background isolation, and exact focus restoration.

CSS makes a layer look modal while the document remains interactive

A full-screen overlay may look modal while its focus behavior and dialog contract remain incomplete.

Common mistake · Collezione.tsx
<div className="overlay" onClick={close}>
  <div className="panel" onClick={(event) => event.stopPropagation()}>
    <button onClick={close}>×</button>
    …
  </div>
</div>

User impact

  • Focus can stay behind the overlay or escape into background controls.
  • The surface may have no announced dialog name or context.
  • Closing may return focus somewhere unexpected—or lose it entirely.

Treat modal behavior as a lifecycle, not a role

Name and describe the dialog, move focus inside, keep Tab and Shift+Tab contained, dismiss with Escape, isolate the background, then restore focus to the exact initiating control. Adding role="dialog" alone does none of the focus work.

Implementation

React Shared local focus hook

Collezione.tsx
const closeLightbox = () => setLightboxModello(null);
const { dialogRef: lightboxRef, triggerRef: lightboxTriggerRef } = useModalFocus({
  isOpen: lightboxModello !== null,
  onClose: closeLightbox,
  initialFocusRef: lightboxCloseRef,
});

{lightboxModello && (
<div
  ref={lightboxRef}
  role="dialog"
  aria-modal="true"
  aria-labelledby="collection-lightbox-title"
  aria-describedby={`lightbox-description-${lightboxModello.id}`}
  tabIndex={-1}
>
  <h2 id="collection-lightbox-title" className="visually-hidden">
    {t().collezione.lightboxTitle(lightboxModello.nome)}
  </h2>
  <button ref={lightboxCloseRef} onClick={closeLightbox}>…</button>
</div>
)}

The caller records the initiating control in lightboxTriggerRef. useModalFocus then preserves that return target, inerts outside branches, locks body scrolling, schedules initial focus, contains Tab and Shift+Tab, handles Escape, cleans up, restores prior inert/overflow values, and returns focus exactly.

Project-local useModalFocus is imported from ../../hooks/useModalFocus. It was written for the accessible React application and is not supplied by React or any third-party accessibility library. Its complete implementation and caller import are in Supporting code & dependencies.

Angular Native dialog + CDK

collezione.html + collezione.ts
<dialog
  #lightboxDialog
  aria-modal="true"
  aria-labelledby="collection-lightbox-title"
  (cancel)="onLightboxCancel($event)"
  (pointerup)="onLightboxBackdrop($event)"
>
  <div class="col-lb-box" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
    <h2 id="collection-lightbox-title" class="visually-hidden">…</h2>
    <button cdkFocusInitial (click)="chiudiLightbox()">…</button>
  </div>
</dialog>

effect(() => {
  const dialog = this.lightboxDialog()?.nativeElement;
  if (!dialog || dialog.hasAttribute('open')) return;

  if (typeof dialog.showModal === 'function') dialog.showModal();
  else dialog.setAttribute('open', '');
});

showModal() supplies the browser top layer and background inertness; Angular CDK supplies focus containment and restoration.

Official library cdkTrapFocus, cdkTrapFocusAutoCapture, and cdkFocusInitial are Angular CDK A11y utilities enabled by CdkTrapFocus from @angular/cdk/a11y. Angular CDK is the officially maintained Component Dev Kit, separate from Angular core. It was already installed in the thesis application, so remediation introduced no npm dependency or lockfile change; another project must have compatible CDK A11y support available. The exact standalone import and setup are in Supporting code & dependencies.

React vs Angular

Clearest framework difference Angular combines native modal behavior with a first-party CDK utility. The React version centralizes the same lifecycle in a reusable local hook using refs, effects, and inert.

Accessibility checks

  • Keyboard
  • Focus entry
  • Focus containment
  • Escape
  • Focus restoration
  • Screen reader
  • WCAG 2.1.2
  • WCAG 2.4.3
  • WCAG 4.1.2

Localized availability calendar

Use complete names and native button states—and do not claim a grid keyboard model you did not build.

Visible fragments concatenate into ambiguous speech

Unnamed month arrows and day fragments produced output such as “6Closed” and “922 places.” Selection and month changes were visual-only.

Common mistake · Biglietti.tsx
<button onClick={previousMonth}>‹</button>

<button className={selected ? "day selected" : "day"}>
  <span>{day.number}</span>
  <span>{day.spots} places</span>
</button>

User impact

  • Month controls have no direction or target in their accessible names.
  • Date, state, and count run together without enough context.
  • Selected dates and calendar changes are not exposed programmatically.

Match semantics to the keyboard model actually implemented

Keep each date a native button, create a localized name containing the visible day/status before the full date and availability, use native disabled and aria-pressed, and announce month or selection changes politely. This example deliberately avoided role="grid" because it did not implement grid arrow-key behavior.

Implementation

React Derived data + Intl

Biglietti.tsx
const formatFullDate = (dateValue: string) => {
  const [year, month, day] = dateValue.split("-").map(Number);
  return new Intl.DateTimeFormat(locale, {
    weekday: "long",
    day: "numeric",
    month: "long",
    year: "numeric",
  }).format(new Date(year, month - 1, day, 12));
};

const dayLabel = (giorno: GiornoCalendario) => {
  const fullDate = formatFullDate(giorno.data);
  if (giorno.stato === "passato") return tb.datePast(fullDate);
  if (giorno.stato === "chiuso") return tb.dateClosed(fullDate);
  if (giorno.stato === "esaurito") return tb.dateSoldOut(fullDate);
  if (giorno.stato === "quasi") return tb.dateAlmost(fullDate, giorno.posti);
  return tb.dateAvailable(fullDate, giorno.posti);
};

<button
  type="button"
  aria-label={dayLabel(giorno)}
  aria-pressed={giorno.data === dataVisita}
  disabled={
    giorno.stato === "passato" ||
    giorno.stato === "chiuso" ||
    giorno.stato === "esaurito"
  }
  onClick={() => selezionaGiorno(giorno.data)}
>
  <span className="big-cal-content" aria-hidden="true">
    <span className="big-cal-num">{giorno.numero}</span>
    {giorno.stato === "chiuso" && (
      <span className="big-cal-badge">{tb.calChiuso}</span>
    )}
    {giorno.stato === "esaurito" && (
      <span className="big-cal-badge">{tb.calEsaurito}</span>
    )}
    {(giorno.stato === "ok" || giorno.stato === "quasi") && (
      <span className="big-cal-badge">
        {giorno.posti} {tb.calPosti}
      </span>
    )}
  </span>
</button>
<p className="visually-hidden" role="status" aria-live="polite">
  {calendarAnnouncementText}
</p>

Native web platform Intl.DateTimeFormat is a built-in JavaScript internationalization API, not a package. formatFullDate, dayLabel, calendar state, and translated copy are project-local.

Angular Computed data + Intl

biglietti.html + biglietti.ts
<button
  type="button"
  [attr.aria-label]="etichettaGiorno(giorno)"
  [attr.aria-pressed]="giorno.data === dataVisita()"
  [disabled]="
    giorno.stato === 'passato' ||
    giorno.stato === 'chiuso' ||
    giorno.stato === 'esaurito'
  "
  (click)="selezionaGiorno(giorno.data)"
>
  <span class="big-cal-num">{{ giorno.numero }}</span>
  @if (statoVisibileGiorno(giorno); as statoVisibile) {
    <span class="big-cal-badge">{{ statoVisibile }}</span>
  }
</button>
<p class="visually-hidden" role="status">{{ calendarStatus() }}</p>

etichettaGiorno(giorno: GiornoCalendario): string {
  if (!giorno.data) return '';
  const date = this.formattaDataCompleta(giorno.data);
  const t = this.lang.t().biglietti;
  const statoVisibile = this.statoVisibileGiorno(giorno);
  const testoVisibile =
    [String(giorno.numero), statoVisibile].filter(Boolean).join(' ');
  let dettaglio: string;

  if (giorno.stato === 'passato') dettaglio = t.calDayPast.replace('{date}', date);
  else if (giorno.stato === 'chiuso') dettaglio = t.calDayClosed.replace('{date}', date);
  else if (giorno.stato === 'esaurito') dettaglio = t.calDaySoldOut.replace('{date}', date);
  else {
    const template = giorno.stato === 'quasi' ? t.calDayAlmost : t.calDayAvailable;
    dettaglio = template
      .replace('{date}', date)
      .replace('{count}', String(giorno.posti));
  }

  return `${testoVisibile} — ${dettaglio}`;
}

Native web platform The local formattaDataCompleta method wraps native Intl.DateTimeFormat. etichettaGiorno, statoVisibileGiorno, and calendar Signals are project-local/Angular-core code; no calendar package is used.

React vs Angular

Same deliberate model Signals/computed values and React state/derived data both feed native buttons. Neither framework generates correct calendar semantics automatically, and neither implementation pretends to be an ARIA grid.

Accessibility checks

  • Keyboard
  • Localized name
  • Selected state
  • Disabled state
  • Live region
  • WCAG 4.1.2
  • WCAG 4.1.3

Form labels, native groups, and totals

Turn visible prompts into real relationships and let native form structure do most of the work.

Layout text looks like a label but names nothing

Visible spans sat near fields, ticket and visitor choices lacked named groups, quantities were unlabelled, and total changes were visual-only.

Common mistake · Biglietti.tsx
<span className="label">Email</span>
<input type="email" value={email} />

<div className="ticket-options">
  <input type="checkbox" />
  <span>Full price — €12</span>
</div>

<div className="total">Total: €{total}</div>

User impact

  • Control purpose depends on visual proximity rather than a programmatic label.
  • A set of choices has no announced group purpose or required instruction.
  • Changing quantities updates the price without non-visual feedback.

Use native form structure before reaching for ARIA

Associate every control with label, use fieldset/legend for actual form option groups, retain native checkboxes and inputs, and make the calculated paid total a polite status. Put required group wording in the legend; aria-required is not supported on a fieldset.

Implementation

React Controlled native JSX

Biglietti.tsx
<form
  className="big-form"
  aria-labelledby="booking-form-title"
  noValidate
  onSubmit={prenota}
>
  <fieldset
    id="booking-tickets"
    tabIndex={-1}
    aria-describedby={
      errori.includes("tickets") ? "booking-tickets-error" : undefined
    }
  >
    <legend>
      {tb.labelTipi} <span className="visually-hidden">{tb.required}</span>
    </legend>
    <input
      id="cb-intero"
      type="checkbox"
      checked={interoAttivo}
      onChange={(event) => {
        setInteroAttivo(event.target.checked);
        if (event.target.checked) clearError("tickets");
      }}
    />
    <label htmlFor="cb-intero">{tb.interoTitle} — {tb.interoPrice}</label>
  </fieldset>

  {(interoAttivo || ridottoAttivo) && (
    <div role="status" aria-live="polite" aria-atomic="true">
      {tb.totale}: <strong>&euro; {totale},00</strong>
    </div>
  )}

  <label htmlFor="booking-email">{tb.labelEmail}</label>
  <input
    id="booking-email"
    type="email"
    inputMode="email"
    autoComplete="email"
    value={emailUtente}
    required
    onChange={(event) => {
      setEmailUtente(event.target.value);
      if (emailValida(event.target.value.trim())) clearError("email");
    }}
  />
</form>

Framework This uses React state with controlled native form elements; no React form or accessibility package is involved. prenota, validation state, translations, and field handlers are project-local.

Angular Native template + Forms binding

biglietti.html
<form class="big-form" novalidate (ngSubmit)="prenota()">
  <fieldset
    id="booking-tickets"
    tabindex="-1"
    [attr.aria-invalid]="haErrore('qta') ? 'true' : null"
    [attr.aria-describedby]="ticketDescrittiDa()"
  >
    <legend>
      {{ lang.t().biglietti.labelTipi }}
      <span class="visually-hidden">
        — {{ lang.t().biglietti.ticketSelectionRequired }}
      </span>
    </legend>
    <input
      id="cb-intero"
      name="fullPriceTickets"
      type="checkbox"
      [ngModel]="interoAttivo()"
      (ngModelChange)="onTicketChange('intero', $event)"
    />
    <label for="cb-intero">
      {{ lang.t().biglietti.interoTitle }} — {{ lang.t().biglietti.interoPrice }}
    </label>
  </fieldset>

  @if (interoAttivo() || ridottoAttivo()) {
    <div role="status" aria-live="polite" aria-atomic="true">
      {{ lang.t().biglietti.totalLabel }}:
      <strong>&euro; {{ totale }},00</strong>
    </div>
  }

  <label for="booking-email">{{ lang.t().biglietti.labelEmail }}</label>
  <input
    id="booking-email"
    name="bookingEmail"
    type="email"
    autocomplete="email"
    [ngModel]="emailUtente()"
    (ngModelChange)="onEmailChange($event)"
  />
</form>

Framework / library ngModel, ngModelChange, and ngSubmit are supplied by Angular Forms. The final standalone component imports FormsModule from @angular/forms; the exact component-level setup is shown in Supporting code & dependencies.

React vs Angular

Native HTML dominates Angular uses ngModel and requires stable name values inside its form. React uses controlled props and handlers. The accessible structure is otherwise the same.

Accessibility checks

  • Labels
  • Semantic HTML
  • Native keyboard behavior
  • Group name
  • Live region
  • WCAG 1.3.1
  • WCAG 4.1.3

Validation errors and recovery

Model errors with stable keys, orient the user once, and connect each message to its field.

A generic list appears, but the invalid fields know nothing about it

Translated strings rendered as loose paragraphs do not move focus or expose invalid state and field descriptions.

Common mistake · Biglietti.tsx
const errors: string[] = [];
if (!emailIsValid(email)) errors.push(t.invalidEmail);

{errors.length > 0 && (
  <div className="errors">
    {errors.map((message) => <p>{message}</p>)}
  </div>
)}

User impact

  • A keyboard or screen-reader user may not discover that errors appeared above the form.
  • The field exposes neither invalid state nor its specific error description.
  • Recovery requires searching the page and guessing which control each message belongs to.

One focused summary, one instance of each message, stable field relationships

Validate to typed keys, derive stable summary and field IDs, focus a titled summary after every invalid submit, and make its links move to the relevant control or group. Bind aria-invalid and aria-describedby only while the error applies. Do not duplicate the same messages in an assertive alert.

Implementation

React Typed state + ref/effect

Biglietti.tsx
type BookingErrorKey = "date" | "time" | "tickets" | "email";

useEffect(() => {
  if (validationAttempt === 0 || errori.length === 0) return;
  const frame = window.requestAnimationFrame(
    () => errorSummaryRef.current?.focus()
  );
  return () => window.cancelAnimationFrame(frame);
}, [validationAttempt]);

<div
  ref={errorSummaryRef}
  tabIndex={-1}
  aria-labelledby="booking-error-summary-title"
>
  <h3 id="booking-error-summary-title">{tb.errorSummary}</h3>
  <ul>{errori.map((error) =>
    <li key={error}>
      <a id={errorId(error)} href={`#${errorTarget(error)}`}>
        {errorMessage(error)}
      </a>
    </li>
  )}</ul>
</div>

<input id="booking-email"
  aria-invalid={errori.includes("email")}
  aria-describedby={
    errori.includes("email") ? "booking-email-error" : undefined
  }
/>

A validation-attempt counter allows an unchanged summary to receive focus again after a repeated invalid submit.

Project-local errorId, errorTarget, and errorMessage are typed mappings in Biglietti.tsx, not package APIs. Their exact definitions are shown in Supporting code & dependencies. requestAnimationFrame is a native browser API.

Angular Typed keys + view query

biglietti.html + biglietti.ts
<div #errorSummary tabindex="-1" aria-labelledby="booking-errors-title">
  <h3 id="booking-errors-title">
    {{ lang.t().biglietti.errorSummaryTitle }}
  </h3>
  <ul> @for (e of errori(); track e) {
    <li><a
      [id]="erroreId(e)"
      [href]="'#' + erroreTargetId(e)"
      (click)="focusErrore($event, e)"
    >{{ erroreMessaggio(e) }}</a></li>
  } </ul>
</div>

<input id="booking-email"
  [attr.aria-invalid]="haErrore('email') ? 'true' : null"
  [attr.aria-describedby]="haErrore('email') ? erroreId('email') : null" />

focusErrore(event: Event, key: BookingErrorKey): void {
  event.preventDefault();
  const ownerDocument =
    (event.currentTarget as HTMLElement | null)?.ownerDocument;
  ownerDocument?.getElementById(this.erroreTargetId(key))?.focus();
}

The local preventDefault() is essential here: a bare fragment resolved against Angular’s root base URL and could navigate to Home.

Project-local erroreId, erroreTargetId, erroreMessaggio, and focusErrore are methods in the standalone Biglietti component. The typed mappings—including the required preventDefault()—are shown in Supporting code & dependencies.

React vs Angular

Lifecycle syntax differs Refs/effects and Signal view queries solve the render-then-focus step differently. Stable error keys, IDs, relationships, and the summary-to-field path still require explicit application design in both.

Accessibility checks

  • Focus
  • Error identification
  • Error suggestion
  • Field description
  • Keyboard recovery
  • WCAG 3.3.1
  • WCAG 3.3.3

Supporting code & dependencies

These are the non-obvious imports and local implementations used by the final accessible applications. They complement the concise patterns above; they do not turn each excerpt into a standalone application. Native APIs such as document, requestAnimationFrame, queueMicrotask, Intl.DateTimeFormat, HTMLDialogElement.showModal(), and inert are built into the web platform and require no third-party package.

React supporting code

The case-study React application depends on React and react-router-dom. Modal focus behavior is authored locally; no accessibility package was added.

React Router and project-local hook imports

These are the actual imports used by the final application. React Router itself is not reproduced here.

src/components/Header/Header.tsx
import { NavLink, useNavigate } from "react-router-dom";
src/App.tsx
import {
  BrowserRouter,
  Routes,
  Route,
  Navigate,
  useLocation,
  Link,
} from "react-router-dom";
src/pages/Collezione/Collezione.tsx
import { useModalFocus } from "../../hooks/useModalFocus";
src/pages/Collezione/Collezione.tsx · hook use
const closeLightbox = () => setLightboxModello(null);
const { dialogRef: lightboxRef, triggerRef: lightboxTriggerRef } = useModalFocus({
  isOpen: lightboxModello !== null,
  onClose: closeLightbox,
  initialFocusRef: lightboxCloseRef,
});
src/pages/Collezione/Collezione.tsx · initiating control
onClick={(event) => {
  lightboxTriggerRef.current = event.currentTarget;
  setLightboxModello(m);
}}
Full project-local useModalFocus implementation

The caller assigns the initiating element to the returned triggerRef. On open, the hook preserves that exact return target (falling back to the active element), saves and locks body overflow, and preserves each outside branch’s prior inert value. It schedules initial focus, contains Tab and Shift+Tab, closes on Escape, removes listeners and restores prior state during cleanup, then restores focus if the target is still connected. It does not come from a package.

src/hooks/useModalFocus.ts
import { useEffect, useRef, type RefObject } from "react";

const FOCUSABLE_SELECTOR = [
  "a[href]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  '[tabindex]:not([tabindex="-1"])',
].join(",");

interface ModalFocusOptions {
  isOpen: boolean;
  onClose: () => void;
  initialFocusRef?: RefObject<HTMLElement | null>;
}

export function useModalFocus({
  isOpen,
  onClose,
  initialFocusRef,
}: ModalFocusOptions) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null);
  const onCloseRef = useRef(onClose);
  onCloseRef.current = onClose;

  useEffect(() => {
    if (!isOpen || !dialogRef.current) return;

    const dialog = dialogRef.current;
    const returnTarget = triggerRef.current ??
      (document.activeElement instanceof HTMLElement ? document.activeElement : null);
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    // Keep every branch outside the open dialog unavailable to pointer,
    // keyboard and accessibility-tree navigation while the modal is active.
    const inertTargets = new Map<HTMLElement, boolean>();
    let activeBranch: HTMLElement = dialog;
    while (activeBranch.parentElement) {
      const parent = activeBranch.parentElement;
      Array.from(parent.children).forEach((sibling) => {
        if (sibling === activeBranch || !(sibling instanceof HTMLElement)) return;
        if (!inertTargets.has(sibling)) inertTargets.set(sibling, sibling.inert);
        sibling.inert = true;
      });
      if (parent === document.body) break;
      activeBranch = parent;
    }

    const focusableElements = () =>
      Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
        (element) => element.getAttribute("aria-hidden") !== "true",
      );

    const frame = window.requestAnimationFrame(() => {
      (initialFocusRef?.current ?? focusableElements()[0] ?? dialog).focus();
    });

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        event.preventDefault();
        onCloseRef.current();
        return;
      }

      if (event.key !== "Tab") return;
      const focusable = focusableElements();
      if (focusable.length === 0) {
        event.preventDefault();
        dialog.focus();
        return;
      }

      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (!dialog.contains(document.activeElement) || document.activeElement === dialog) {
        event.preventDefault();
        (event.shiftKey ? last : first).focus();
      } else if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => {
      window.cancelAnimationFrame(frame);
      document.removeEventListener("keydown", handleKeyDown);
      document.body.style.overflow = previousOverflow;
      inertTargets.forEach((wasInert, element) => {
        element.inert = wasInert;
      });
      window.requestAnimationFrame(() => {
        if (returnTarget?.isConnected) returnTarget.focus();
      });
    };
  }, [initialFocusRef, isOpen]);

  return { dialogRef, triggerRef };
}
Typed React validation mappings referenced by the error summary

These project-local mappings keep translated messages separate from stable DOM targets and error IDs.

src/pages/Biglietti/Biglietti.tsx
type BookingErrorKey = "date" | "time" | "tickets" | "email";

const errorMessage = (key: BookingErrorKey) => ({
  date: tb.erroreData,
  time: tb.erroreOrario,
  tickets: tb.erroreQta,
  email: tb.erroreEmail,
})[key];

const errorTarget = (key: BookingErrorKey) => ({
  date: "booking-date",
  time: "booking-time",
  tickets: "booking-tickets",
  email: "booking-email",
})[key];

const errorId = (key: BookingErrorKey) => ({
  date: "booking-date-error",
  time: "booking-time-error",
  tickets: "booking-tickets-error",
  email: "booking-email-error",
})[key];

Angular supporting code

The final application uses standalone components. Router directives, Forms, and CDK A11y are imported where each component needs them; there is no NgModule-based setup.

Angular Router and Forms standalone setup

RouterLink, RouterLinkActive, and RouterOutlet come from @angular/router. provideRouter(routes) configures routing at application bootstrap. FormsModule from @angular/forms supplies ngModel, ngModelChange, and ngSubmit.

src/app/shared/header/header.ts
import { Component, signal, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { LanguageService, Lang } from '../language.service';

@Component({
  selector: 'app-header',
  imports: [RouterLink, RouterLinkActive],
  templateUrl: './header.html',
  styleUrl: './header.scss',
})
src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes)
  ]
};
src/app/pages/biglietti/biglietti.ts · imports
import { CdkTrapFocus } from '@angular/cdk/a11y';
import { Component, computed, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LanguageService } from '../../shared/language.service';
src/app/pages/biglietti/biglietti.ts · component metadata
@Component({
  selector: 'app-biglietti',
  // FormsModule: necessario per [(ngModel)] su input, select, checkbox
  imports: [FormsModule, CdkTrapFocus],
  templateUrl: './biglietti.html',
  styleUrl: './biglietti.scss',
})
Native dialog plus Angular CDK A11y modal setup

The Collection lightbox combines native <dialog> and showModal() with Angular CDK A11y focus management. The one standalone CdkTrapFocus import enables cdkTrapFocus, its cdkTrapFocusAutoCapture input, and the cdkFocusInitial marker; there is no separate CdkFocusInitial import in this application.

src/app/pages/collezione/collezione.ts · setup
import { CdkTrapFocus } from '@angular/cdk/a11y';
import { Component, computed, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LanguageService } from '../../shared/language.service';

@Component({
  selector: 'app-collezione',
  imports: [FormsModule, CdkTrapFocus],
  templateUrl: './collezione.html',
  styleUrl: './collezione.scss',
})
src/app/pages/collezione/collezione.html
@if (lightboxModello()) {
  <dialog
    #lightboxDialog
    class="col-lb-overlay"
    aria-modal="true"
    aria-labelledby="collection-lightbox-title"
    (cancel)="onLightboxCancel($event)"
    (pointerup)="onLightboxBackdrop($event)"
  >
    <div class="col-lb-box" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
      <h2 id="collection-lightbox-title" class="visually-hidden">
        {{ lightboxDialogLabel(lightboxModello()!.nome) }}
      </h2>
      <button
        type="button"
        class="col-lb-chiudi"
        cdkFocusInitial
        [attr.aria-label]="lang.t().collezione.lightboxClose"
        (click)="chiudiLightbox()"
      >
        <span aria-hidden="true">&#10005;</span>
      </button>
      <img
        class="col-lb-img"
        [src]="lightboxModello()!.img"
        [alt]="lightboxModello()!.nome"
      />
    </div>
  </dialog>
}
src/app/pages/collezione/collezione.ts · dialog lifecycle
private readonly lightboxDialog =
  viewChild<ElementRef<HTMLDialogElement>>('lightboxDialog');

constructor() {
  // The unrelated filter-announcement effect is omitted from this excerpt.
  effect(() => {
    const dialog = this.lightboxDialog()?.nativeElement;
    if (!dialog || dialog.hasAttribute('open')) return;

    if (typeof dialog.showModal === 'function') dialog.showModal();
    else dialog.setAttribute('open', '');
  });
}

chiudiLightbox() {
  const dialog = this.lightboxDialog()?.nativeElement;
  if (dialog && typeof dialog.close === 'function') dialog.close();
  this.lightboxModello.set(null);
}

onLightboxCancel(event: Event): void {
  event.preventDefault();
  this.chiudiLightbox();
}

onLightboxBackdrop(event: PointerEvent): void {
  if (event.target === this.lightboxDialog()?.nativeElement) this.chiudiLightbox();
}

lightboxDialogLabel(nome: string): string {
  return this.lang.t().collezione.lightboxDialog.replace('{model}', nome);
}

Angular CDK was already a production dependency in the baseline case-study application. The remediation reused it and changed neither package.json nor the lockfile. In another Angular project, a compatible @angular/cdk installation is required. The CDK source itself is intentionally not reproduced.

Typed Angular validation mappings and fragment-safe focus

The final Booking component keeps stable IDs in typed local methods. Its click handler deliberately prevents default fragment navigation before focusing the mapped target, because the application’s <base href="/"> could otherwise resolve the fragment against the root route.

src/app/pages/biglietti/biglietti.ts
type BookingErrorKey = 'data' | 'orario' | 'qta' | 'email';

haErrore(key: BookingErrorKey): boolean {
  return this.errori().includes(key);
}

ticketDescrittiDa(): string | null {
  return this.haErrore('qta') ? this.erroreId('qta') : null;
}

erroreMessaggio(key: BookingErrorKey): string {
  const t = this.lang.t().biglietti;
  if (key === 'data') return t.erroreData;
  if (key === 'orario') return t.erroreOrario;
  if (key === 'qta') return t.erroreQta;
  return t.erroreEmail;
}

erroreId(key: BookingErrorKey): string {
  return `booking-${key}-error`;
}

erroreTargetId(key: BookingErrorKey): string {
  if (key === 'data') return 'booking-date';
  if (key === 'orario') return 'booking-time';
  if (key === 'qta') return 'booking-tickets';
  return 'booking-email';
}

focusErrore(event: Event, key: BookingErrorKey): void {
  event.preventDefault();
  const ownerDocument = (event.currentTarget as HTMLElement | null)?.ownerDocument;
  ownerDocument?.getElementById(this.erroreTargetId(key))?.focus();
}