← Orieb AlzyuotHire me
By Orieb Alzyuot · 12 October 2026 · 13 min read

Accessibility That Ships: A Senior Front-End Checklist

Accessibility isn’t a final audit. It’s a set of habits in every component you merge. This is the checklist I use on government, fintech and enterprise products, in English and Arabic.

Cover: Accessibility that ships, with a custom select showing a visible focus ring, an open listbox and keyboard keys

Most accessibility bugs I fix aren’t exotic. They’re the same dozen mistakes: a div with a click handler, a focus ring someone removed, a custom dropdown that traps the keyboard, a toast nobody hears, an Arabic page whose screen reader speaks English. Each one is cheap to prevent and expensive to find later.

The target in this article is WCAG 2.2 Level AA. It’s the current W3C recommendation, and it’s what most procurement rules and accessibility laws point to, including the European Accessibility Act, which has applied since June 2025. Check your own legal obligations with your legal team. The engineering work is the same either way.

TL;DR

1. Semantic structure: the free 60%

Before any ARIA, get the HTML right. Screen reader users navigate by landmarks and headings, so give them a page that has both.

<body>
  <a class="skip-link" href="#main">Skip to content</a>
  <header>…<nav aria-label="Main">…</nav></header>
  <main id="main" tabindex="-1">
    <h1>Applications</h1>
    <section aria-labelledby="pending-h">
      <h2 id="pending-h">Pending review</h2>
      <ul>…</ul>
    </section>
  </main>
  <footer>…</footer>
</body>

A framework-specific trap I see often: an icon-only button component receives aria-label on its host element, but the real <button> inside never gets it. Expose an ariaLabel input and bind it to the inner element.

2. Focus management

Keyboard and switch users can only act on what has focus, and they need to see it.

:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
}

/* WCAG 2.2 · 2.4.11 Focus Not Obscured: keep focused elements clear of the sticky header */
html {
  scroll-padding-block-start: var(--header-height, 72px);
}

3. Keyboard patterns you will build

Don’t invent keyboard behaviour. The WAI-ARIA Authoring Practices Guide documents the patterns users already know: https://www.w3.org/WAI/ARIA/apg/

Keyboard maps for three patterns: select-only combobox, modal dialog and tabs
Three patterns cover most custom widgets in business apps.

Dialogs: use the platform. The native <dialog> with showModal() gives you a top layer, a backdrop, Esc to close, and an inert background. You don’t need a focus-trap library:

const dialog = document.querySelector<HTMLDialogElement>('#confirm')!;
let opener: HTMLElement | null = null;

export function openConfirm(from: HTMLElement) {
  opener = from;
  dialog.showModal();                    // background becomes inert
  dialog.querySelector<HTMLElement>('[autofocus], button')?.focus();
}

dialog.addEventListener('close', () => opener?.focus()); // return focus

Tabs: roving tabindex. Only the active tab is in the Tab order (tabindex="0"). The others have tabindex="-1", and arrow keys move between them. In RTL, the left and right arrow keys swap meaning, just as the layout does.

4. Never style a native <select> beyond its limits

This is the one I argue about most. Designers want the dropdown list to match the brand. The native <select> popup is drawn by the browser and the OS, and it has historically been unstylable across browsers. Teams respond in one of two bad ways:

  1. They hide the <select> and paint a fake one on top. Screen readers and the keyboard still talk to the hidden control, while sighted users see a different state. It breaks in every edge case.
  2. They build a div dropdown with mouse handlers only. There’s no keyboard support, no roles, and no announcement.

What you can style safely on a native select: font, colour, padding, border, radius, background, and the arrow, using appearance: none plus a background image. Leave the option list alone.

select {
  appearance: none;
  font: inherit;
  min-block-size: 44px;
  padding-block: 8px;
  padding-inline: 12px 36px;
  border: 2px solid var(--color-border);
  border-radius: 10px;
  background: var(--color-surface) url('data:image/svg+xml,…') no-repeat right 12px center / 12px;
}
select:dir(rtl) { background-position: left 12px center; padding-inline: 36px 12px; }

Customizable select is coming. Chromium now supports appearance: base-select, ::picker(select) and <selectedcontent>, which let you style the popup of a real <select>. Treat it as progressive enhancement: browsers that don’t support it show the normal select, which is fine.

@supports (appearance: base-select) {
  select, ::picker(select) { appearance: base-select; }
}

When to build a custom one: when you need options with avatars or descriptions, type-to-filter, or multi-select with chips. Then build the APG combobox pattern, or use a headless library that already has, such as Angular CDK’s listbox, React Aria or Radix. Here’s what “properly” means:

The core of the keyboard handler:

combo.addEventListener('keydown', (e) => {
  if (!open) {
    if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { setOpen(true); e.preventDefault(); }
    else if (e.key.length === 1) { setOpen(true); typeahead(e.key); }
    return;
  }
  switch (e.key) {
    case 'ArrowDown': move(active + 1); break;
    case 'ArrowUp':   e.altKey ? choose(active) : move(active - 1); break;
    case 'Home':      move(0); break;
    case 'End':       move(options.length - 1); break;
    case 'Enter':
    case ' ':         choose(active); break;
    case 'Escape':    setOpen(false); break;          // value unchanged
    case 'Tab':       choose(active); return;          // let focus move on
    default:          if (e.key.length === 1) typeahead(e.key); else return;
  }
  e.preventDefault();
});

function move(i: number) {
  active = Math.max(0, Math.min(options.length - 1, i));
  combo.setAttribute('aria-activedescendant', options[active].id);
  options[active].scrollIntoView({ block: 'nearest' });
}

Here it is in use, driven only by the keyboard:

Animation: keyboard focus tabs into a custom select, arrow keys move the highlighted option, type-ahead jumps to Qatar, Enter chooses it, and the same works in Arabic RTL
Tab, ↓, type-ahead, Enter, Esc. Then the same control in Arabic, right to left.

▶ Try it live: https://oriebalzyuot96.github.io/blog/accessibility-that-ships/#demo-select

5. Live regions: say what changed

When content changes without a page load, such as a saved toast, a validation summary, a “3 results” count or a chat message, screen reader users need to hear it. WCAG calls these status messages (SC 4.1.3).

<!-- Render once, empty, at app start. Updating a region that was just inserted often isn't announced. -->
<div id="sr-polite" class="visually-hidden" aria-live="polite"></div>
<div id="sr-assertive" class="visually-hidden" role="alert"></div>
export function announce(message: string, urgent = false) {
  const el = document.getElementById(urgent ? 'sr-assertive' : 'sr-polite')!;
  el.textContent = '';                        // clear so a repeated message is spoken again
  requestAnimationFrame(() => (el.textContent = message));
}

announce('Profile saved');                     // polite: waits for a pause
announce('Session expires in 1 minute', true); // assertive: interrupts. Use rarely.

6. Motion

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

That blanket rule is a safety net, not a design. Better:

7. Text scaling, spacing and reflow

Beyond meeting 4.5:1, many products I work on offer accessibility modes in a settings panel. Each one is a data attribute on <html>, read by CSS:

One engineering rule: never put filter on <html> or <body> for invert or grayscale. A filter creates a containing block for position: fixed descendants, so your sticky header, modals and toasts break. Apply it to a wrapper, and re-invert images and video inside it.

html[data-contrast="invert"] .app-root { filter: invert(1) hue-rotate(180deg); }
html[data-contrast="invert"] .app-root :is(img, video, picture) { filter: invert(1) hue-rotate(180deg); }
html[data-links="highlight"] a { text-decoration: underline 2px; text-underline-offset: 3px; }

@media (forced-colors: active) {
  .badge { border: 1px solid CanvasText; }        /* backgrounds disappear; keep shapes */
}
The same sample card in four modes: default, high contrast, grayscale and inverted
Default (deliberately low contrast), high contrast, grayscale and invert. Each mode is one attribute on one container.

▶ Try it live: https://oriebalzyuot96.github.io/blog/accessibility-that-ships/#demo-modes

9. Accessibility in Arabic and RTL

Arabic accessibility is where “we support RTL” products quietly fail.

Two lists: mirror arrows, drawers, logical spacing, directional gradients and arrow keys; don't mirror check marks, media controls, clocks, numbers and real-object icons
Direction-aware things flip. Things that describe the real world do not.

Test Arabic with a screen reader that has a good Arabic voice. NVDA, VoiceOver and TalkBack all support Arabic, but voices and settings vary. Listen to a full form at least once.

10. Forms and authentication (the WCAG 2.2 additions)

11. Testing: axe, keyboard and screen readers

Automated tools find only part of the problems. They’re great at missing names, contrast and invalid ARIA, and blind to “this flow makes no sense”. Use all three layers.

1. axe in CI with Playwright, on every key page, in each theme and direction:

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

for (const theme of ['light', 'dark'] as const) {
  test(`checkout is accessible (${theme})`, async ({ page }) => {
    await page.addInitScript(t => localStorage.setItem('ui-theme', t), theme);
    await page.goto('/checkout');
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
      .analyze();
    expect(results.violations).toEqual([]);
  });
}

2. A keyboard-only pass on every new feature. Unplug the mouse:

3. A screen reader pass on new widgets and critical flows: NVDA with Firefox or Chrome on Windows, VoiceOver with Safari on macOS and iOS, TalkBack on Android. Listen for names, roles, states and announcements, in English and Arabic.

Also check: zoom to 400%, Windows forced colours (contrast themes), and the browser DevTools accessibility tree for the widget you just built.

The checklist

Accessibility that ships is mostly discipline: native first, patterns over invention, and tests that run on every merge. Your users won’t thank you for it, because they’ll never notice. That’s the point.


About the author

Orieb Alzyuot (عريب الزيوت) is a Senior Front-End & UI/UX Engineer based in Amman, Jordan, with 6+ years of experience building government, fintech, HR and enterprise products used every day. Her work includes:

Open to senior and lead front-end roles, contracts and freelance work, remote or with relocation.

Find me here:

If this helped, follow me on Medium for more on design systems, accessibility, Angular, React and front-end architecture. 💜