Web Performance & Accessibility

Accessibility Standards & Testing

4 min read

Accessibility (a11y) is no longer a "nice to have" — it is a legal requirement in many jurisdictions and a core evaluation area in frontend interviews. WCAG 2.2 was published on October 5, 2023, and you should know its key additions.

WCAG 2.2 AA — What You Need to Know

WCAG is organized into four principles: Perceivable, Operable, Understandable, Robust (POUR). Most companies target AA conformance as the standard.

Key New Criteria in WCAG 2.2

2.5.8 Target Size (Minimum) — Level AA

Interactive targets must be at least 24x24 CSS pixels:

/* Ensure all interactive elements meet minimum target size */
button, a, input[type="checkbox"], input[type="radio"], select {
  min-width: 24px;
  min-height: 24px;
}

/* Better: use generous touch targets for mobile */
.btn {
  min-width: 44px;  /* Apple HIG recommendation */
  min-height: 44px;
  padding: 12px 16px;
}

/* Inline links are exempt if text spacing meets requirements */

2.4.11 Focus Not Obscured (Minimum) — Level AA

When an element receives keyboard focus, it must not be fully hidden by sticky headers, footers, modals, or other overlapping content:

/* BAD: Sticky header can cover focused elements */
.header {
  position: sticky;
  top: 0;
  z-index: 100;
}

/* GOOD: Account for sticky header when scrolling to focused elements */
:target {
  scroll-margin-top: 80px; /* Height of sticky header */
}

/* Ensure focused elements scroll into visible area */
*:focus {
  scroll-margin-top: 80px;
  scroll-margin-bottom: 60px;
}

3.3.7 Redundant Entry — Level A

Do not force users to re-enter information they have already provided in the same session:

// BAD: Asking for shipping address after user already entered it as billing
function CheckoutForm() {
  return (
    <form>
      <BillingAddressFields />
      {/* Forces user to re-type everything */}
      <ShippingAddressFields />
    </form>
  );
}

// GOOD: Offer to reuse previously entered data
function CheckoutForm() {
  const [sameAsBilling, setSameAsBilling] = useState(true);

  return (
    <form>
      <BillingAddressFields />
      <label>
        <input
          type="checkbox"
          checked={sameAsBilling}
          onChange={(e) => setSameAsBilling(e.target.checked)}
        />
        Shipping address same as billing
      </label>
      {!sameAsBilling && <ShippingAddressFields />}
    </form>
  );
}

3.3.8 Accessible Authentication (Minimum) — Level AA

Authentication must not rely on cognitive function tests (like remembering a password or solving a puzzle) without providing alternatives:

// GOOD: Provide multiple authentication methods
function LoginPage() {
  return (
    <div>
      <h1>Sign In</h1>
      {/* Allow password managers to autofill */}
      <input type="email" autoComplete="email" />
      <input type="password" autoComplete="current-password" />

      {/* Alternative: passkey/biometric */}
      <button onClick={handlePasskeyLogin}>Sign in with Passkey</button>

      {/* Alternative: magic link */}
      <button onClick={handleMagicLink}>Send me a sign-in link</button>

      {/* If using CAPTCHA, provide an accessible alternative */}
    </div>
  );
}

ARIA Roles, States, and Properties

The first rule of ARIA: do not use ARIA if you can use semantic HTML instead.

Semantic HTML vs. ARIA

<!-- BAD: Using ARIA to recreate what HTML already provides -->
<div role="button" tabindex="0" aria-pressed="false"
     onclick="handleClick()" onkeydown="handleKeydown(event)">
  Click me
</div>

<!-- GOOD: Use the native element — it has built-in keyboard handling,
     focus management, and screen reader support -->
<button onclick="handleClick()">Click me</button>

When ARIA Is Necessary

<!-- Custom combobox — no native HTML equivalent -->
<div role="combobox" aria-expanded="true" aria-haspopup="listbox"
     aria-owns="results-list">
  <input type="text" aria-autocomplete="list"
         aria-controls="results-list" aria-activedescendant="option-3">
</div>
<ul id="results-list" role="listbox">
  <li id="option-1" role="option">Result 1</li>
  <li id="option-2" role="option">Result 2</li>
  <li id="option-3" role="option" aria-selected="true">Result 3</li>
</ul>

<!-- Live region for dynamic content updates -->
<div aria-live="polite" aria-atomic="true">
  3 results found
</div>

<!-- Progress indicator -->
<div role="progressbar" aria-valuenow="65" aria-valuemin="0"
     aria-valuemax="100" aria-label="Upload progress">
  65%
</div>

Keyboard Navigation Patterns

Focus Trapping in Modals

When a modal opens, focus must stay within the modal until it closes:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const previousFocusRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    // Save the element that was focused before modal opened
    previousFocusRef.current = document.activeElement;

    const modal = modalRef.current;
    const focusableElements = modal.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    // Focus the first element in the modal
    firstFocusable?.focus();

    function handleKeyDown(e) {
      if (e.key === 'Escape') {
        onClose();
        return;
      }
      if (e.key !== 'Tab') return;

      // Trap focus within modal
      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable.focus();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable.focus();
        }
      }
    }

    modal.addEventListener('keydown', handleKeyDown);
    return () => {
      modal.removeEventListener('keydown', handleKeyDown);
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div ref={modalRef} role="dialog" aria-modal="true"
           aria-labelledby="modal-title" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>
  );
}

Route Change Announcements

Single-page applications must announce navigation to screen readers:

function RouteAnnouncer() {
  const location = useLocation();
  const [announcement, setAnnouncement] = useState('');

  useEffect(() => {
    // Announce the new page title to screen readers
    const pageTitle = document.title;
    setAnnouncement(`Navigated to ${pageTitle}`);
  }, [location]);

  return (
    <div
      role="status"
      aria-live="assertive"
      aria-atomic="true"
      className="sr-only"
    >
      {announcement}
    </div>
  );
}

Allow keyboard users to skip repetitive navigation:

<!-- First focusable element on the page -->
<a href="#main-content" class="skip-link">Skip to main content</a>

<nav><!-- Long navigation --></nav>

<main id="main-content" tabindex="-1">
  <!-- Page content -->
</main>

<style>
  .skip-link {
    position: absolute;
    top: -40px;
    left: 0;
    padding: 8px 16px;
    background: #000;
    color: #fff;
    z-index: 1000;
    transition: top 0.2s;
  }
  .skip-link:focus {
    top: 0; /* Visible only when focused via keyboard */
  }
</style>

Color Contrast Requirements

Text Type Minimum Contrast Ratio (AA)
Normal text (< 18pt or < 14pt bold) 4.5:1
Large text (≥ 18pt or ≥ 14pt bold) 3:1
UI components and graphical objects 3:1
/* GOOD: High contrast combinations */
.text-primary { color: #1a1a1a; } /* On white: 16.7:1 */
.text-secondary { color: #595959; } /* On white: 7.0:1 */

/* BAD: Insufficient contrast */
.text-light { color: #aaaaaa; } /* On white: 2.3:1 — FAILS */

Motion and Color Scheme Preferences

prefers-reduced-motion

Respect users who are sensitive to motion:

/* Default: animations enabled */
.card {
  transition: transform 0.3s ease;
}
.card:hover {
  transform: scale(1.05);
}

/* Reduce or remove motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
  .card:hover {
    transform: none;
  }

  /* Replace animations with opacity changes */
  .fade-in {
    animation: none;
    opacity: 1;
  }
}

prefers-color-scheme

Support system dark mode preference:

:root {
  --bg-color: #ffffff;
  --text-color: #1a1a1a;
  --border-color: #e0e0e0;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #1a1a1a;
    --text-color: #f0f0f0;
    --border-color: #333333;
  }
}

body {
  background-color: var(--bg-color);
  color: var(--text-color);
}

Screen Reader Testing Basics

VoiceOver (macOS)

Action Shortcut
Turn on/off Cmd + F5
Read next element VO + Right Arrow (VO = Ctrl + Option)
Interact with element VO + Shift + Down Arrow
Stop reading Ctrl
Open Rotor (landmarks, headings) VO + U

NVDA (Windows)

Action Shortcut
Turn on/off Ctrl + Alt + N
Read next element Down Arrow
List headings H (or Insert + F7)
List landmarks D
Stop reading Ctrl

Accessibility Tree in Chrome DevTools

  1. Open DevTools → Elements tab
  2. Click the "Accessibility" pane in the sidebar
  3. Inspect any element to see its:
    • Role: what type of element the screen reader sees
    • Name: what the screen reader announces (from label, aria-label, alt text)
    • State: checked, expanded, selected, etc.
  4. Enable "Full accessibility tree" in DevTools settings to replace the DOM tree view

Interview tip: When discussing accessibility, always frame it as a core engineering concern, not an afterthought. Mention that semantic HTML solves 80% of accessibility issues, ARIA handles the remaining 20%, and automated testing catches about 30% of issues — manual testing with a screen reader is essential.

Congratulations on completing Module 5! Take the quiz to test your knowledge of performance and accessibility. :::

Quiz

Module 5: Web Performance & Accessibility

Take Quiz