← All articles

Accessibility as an Engineering Discipline: From ARIA Live Regions to axe-core in CI

How we raised our accessibility score from 60% to 98% in a product with 5 million monthly active users — without blocking the team, without a big-bang rewrite, and without hiring an a11y specialist.

When we ran our first automated accessibility audit, the score was 61%. That is not unusual for a product that grew fast without explicit a11y investment — but it was not acceptable for a product used by enterprise clients who have legal accessibility requirements.

We fixed it incrementally over 18 months while continuing to ship new features. Here is the engineering approach that made that possible.

Why 60% to 98% — Not 100%

100% on automated tools does not mean fully accessible. Automated tools like axe-core catch approximately 30–40% of real accessibility issues. The remaining issues require human judgment: keyboard navigation flows, screen reader announcements, cognitive load. We target 98% on automated checks because 100% is often achieved by suppressing legitimate warnings, not by fixing the underlying issues.

The Audit: Understanding What You Are Actually Fixing

Before writing a line of code, we categorized every axe-core violation by root cause. The distribution was illuminating:

  • 42% — missing or incorrect ARIA labels on interactive elements
  • 28% — color contrast failures
  • 15% — missing landmark roles and document structure
  • 10% — keyboard focus management issues (modals, drawers, dropdowns)
  • 5% — form label associations

This told us where to invest. Contrast and ARIA labels together would move the needle 70%. We fixed those first.

Start here Run axe-core on your 5 highest-traffic routes and categorize violations by type before touching any code. Understanding the distribution prevents you from spending days on issues that represent 5% of the problem.

Color Contrast at Scale

Fixing 28% of violations meant touching the design token system. We added a lint step that validates contrast ratios at build time using the design token values:

// scripts/validate-contrast.ts
import { wcagContrast } from 'wcag-contrast';

type TokenPair = { foreground: string; background: string; usage: string };

const pairs: TokenPair[] = [
  { foreground: tokens['color-text-primary'], background: tokens['color-surface-default'], usage: 'body text' },
  { foreground: tokens['color-text-secondary'], background: tokens['color-surface-default'], usage: 'secondary text' },
  { foreground: tokens['color-action-primary-fg'], background: tokens['color-action-primary'], usage: 'primary button' },
];

for (const { foreground, background, usage } of pairs) {
  const ratio = wcagContrast(foreground, background);
  if (ratio < 4.5) {
    console.error(`FAIL [${usage}]: ratio ${ratio.toFixed(2)} — requires 4.5 for AA`);
    process.exit(1);
  }
}

console.log('All contrast ratios pass WCAG AA.');

This runs in CI on every token change. It has caught three contrast regressions from design token updates since we introduced it.

ARIA Live Regions for Dynamic Content

The most impactful single improvement was adding ARIA live regions to our notification system. Screen readers do not automatically announce dynamically inserted content — you have to tell them explicitly.

We built a useAnnounce hook that manages a visually hidden live region:

// hooks/use-announce.ts

let liveRegion: HTMLElement | null = null;

function getOrCreateLiveRegion(politeness: 'polite' | 'assertive'): HTMLElement {
  const id = `live-region-${politeness}`;
  if (!liveRegion) {
    liveRegion = document.createElement('div');
    liveRegion.id = id;
    liveRegion.setAttribute('aria-live', politeness);
    liveRegion.setAttribute('aria-atomic', 'true');
    // Visually hidden but readable by screen readers
    Object.assign(liveRegion.style, {
      position: 'absolute',
      width: '1px',
      height: '1px',
      padding: '0',
      overflow: 'hidden',
      clip: 'rect(0,0,0,0)',
      whiteSpace: 'nowrap',
      border: '0',
    });
    document.body.appendChild(liveRegion);
  }
  return liveRegion;
}

export function useAnnounce() {
  return (message: string, politeness: 'polite' | 'assertive' = 'polite') => {
    const region = getOrCreateLiveRegion(politeness);
    // Clear then set — forces screen reader to re-announce identical messages
    region.textContent = '';
    requestAnimationFrame(() => {
      region.textContent = message;
    });
  };
}
// Usage in a form submission handler
const announce = useAnnounce();

const handleSubmit = async () => {
  try {
    await submitForm(data);
    announce('Form submitted successfully. Redirecting to dashboard.');
  } catch {
    announce('Submission failed. Please check your connection and try again.', 'assertive');
  }
};

Focus Management in Modals and Drawers

Keyboard users expect focus to move into a modal when it opens and return to the trigger when it closes. Getting this wrong is one of the most disorienting experiences for keyboard-only users.

We built a useFocusTrap hook used by every modal, drawer, and dropdown in the codebase:

// hooks/use-focus-trap.ts

export function useFocusTrap(containerRef: RefObject<HTMLElement>, active: boolean) {
  useEffect(() => {
    if (!active || !containerRef.current) return;

    const container = containerRef.current;
    const focusable = container.querySelectorAll<HTMLElement>(
      'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    const previouslyFocused = document.activeElement as HTMLElement;

    first?.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      if (e.shiftKey) {
        if (document.activeElement === first) { e.preventDefault(); last?.focus(); }
      } else {
        if (document.activeElement === last) { e.preventDefault(); first?.focus(); }
      }
    };

    container.addEventListener('keydown', handleKeyDown);
    return () => {
      container.removeEventListener('keydown', handleKeyDown);
      previouslyFocused?.focus(); // Return focus on unmount
    };
  }, [active, containerRef]);
}

Automated Testing in CI with axe-core

Manual a11y testing does not scale across 400+ pages. We run axe-core in two contexts:

Component-level: in Vitest

// components/Button/Button.test.tsx
import { axe } from 'jest-axe'; // works with vitest too

test('Button has no accessibility violations', async () => {
  const { container } = render(<Button intent="primary">Submit</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Page-level: in Playwright

// e2e/a11y.spec.ts
import { checkA11y, injectAxe } from 'axe-playwright';

test('Dashboard page has no critical a11y violations', async ({ page }) => {
  await page.goto('/dashboard');
  await injectAxe(page);
  await checkA11y(page, undefined, {
    runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
    violationCallback: (violations) => {
      console.table(violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })));
    },
  });
});
Note axe-core tests should run against real rendered pages, not static HTML snapshots. Dynamic interactions (open modal, expand accordion, submit form) are where a11y issues concentrate.

Making It Stick: The Team Practice

Technical tooling alone does not maintain an a11y score. We added three lightweight practices:

  • PR checklist item — every PR touching UI components includes "checked with keyboard navigation"
  • Design review gate — new component designs are checked against contrast requirements before development starts
  • Monthly axe-core report — automated report emailed to the team showing any score regression from the previous month

Key Takeaways

  • Audit first, categorize violations by type — fix the 70% before the 5%
  • Validate color contrast at the token layer in CI, not in code review
  • ARIA live regions are essential for any dynamic content — screen readers do not announce DOM insertions automatically
  • Centralize focus management in shared hooks — modal and drawer focus is too easy to get wrong ad hoc
  • Run axe-core at both component level (Vitest) and page level (Playwright) — neither alone is sufficient