Blog Post
5 min read

A11y as a Feature, Not a Checklist

Accessibility sticks when it is part of the product narrative, not just a lint rule that fails the build.

Published on March 26, 2026

Accessibility sticks when it is part of the product narrative, not just a lint rule that fails the build.

The best teams do not treat a11y as a compliance box. They treat it as a core feature: measurable, owned, and built into every decision.

Treat It Like Performance

Just as you set performance SLAs (LCP < 2.5s, INP < 200ms), set a11y SLAs:

  • Focus visible everywhere: Every interactive element must show a visible focus indicator. No outline: none without a replacement.
  • Minimum contrast: 3:1 for non-text UI, 4.5:1 for text. No light gray on white.
  • Semantic HTML where it exists: If HTML has a button element, use it. Do not build buttons out of divs.
  • ARIA only when HTML is insufficient: ARIA is a bridge for custom components. If HTML solves it, use HTML.

Enforce in CI

Add axe or pa11y to your test suite. Fail the build on critical violations:

# CI step
npx pa11y-ci --runners axe,htmlcs
// Playwright test
import { injectAxe, checkA11y } from "axe-playwright";

test("homepage is accessible", async ({ page }) => {
  await page.goto("https://example.com");
  await injectAxe(page);
  await checkA11y(page, null, {
    detailedReport: true,
    detailedReportOptions: { html: true },
  });
});

This catches obvious issues: missing alt text, low contrast, broken ARIA attributes.

Design with Assistive Tech in Mind

Keyboard-First Flows

Every user flow must work with a keyboard alone. No mouse, no touch.

This is not just for keyboard users; it is for assistive tech users. Screen reader users navigate with a keyboard. Voice control users send keyboard commands.

Test it yourself: Unplug your mouse. Navigate the page with Tab, Enter, Space, and arrow keys. If you get stuck, your page is broken for assistive tech users.

Common pitfalls:

  • Dropdown menus that require a mouse hover.
  • Carousels that require swiping.
  • Modal dialogs that do not trap focus.
  • Menu buttons that do not announce state (expanded/collapsed).

Focus Management

Focus should be visible and logical:

  1. Visible focus: Use a clear focus indicator (outline, background color, etc.).

    button:focus-visible {
      outline: 3px solid var(--color-brand);
      outline-offset: 2px;
    }
    
  2. Logical order: Tab order should follow reading order. Usually automatic if you use semantic HTML. If not, use tabIndex sparingly:

    <!-- Good: follows reading order -->
    <nav><a href="/">Home</a></nav>
    <main><h1>Welcome</h1></main>
    
    <!-- Bad: jumps around -->
    <div tabindex="1">Third</div>
    <div tabindex="0">First</div>
    <!-- tabindex="0" is last in order -->
    <div tabindex="2">Second</div>
    
  3. Focus trapping: In modals and drawers, trap focus so the user cannot tab out to the background.

    // In a modal component
    const handleKeyDown = (e: React.KeyboardEvent) => {
      if (e.key === "Tab") {
        const focusableElements = ref.current?.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
        );
        const firstElement = focusableElements?.[0] as HTMLElement;
        const lastElement = focusableElements?.[
          focusableElements.length - 1
        ] as HTMLElement;
    
        if (e.shiftKey && document.activeElement === firstElement) {
          lastElement?.focus();
          e.preventDefault();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          firstElement?.focus();
          e.preventDefault();
        }
      }
    };
    

Live Regions and Announcements

Use aria-live to announce dynamic content changes to screen reader users.

Do not overuse it. Reserve it for important updates:

<!-- Good: announces new messages -->
<div aria-live="polite" aria-label="Chat messages">
  <p>User: Hello</p>
  <p>Bot: Hi there!</p>
  <!-- This gets announced when it appears -->
</div>

<!-- Bad: announces every keystroke -->
<div aria-live="assertive">
  You typed: h You typed: he You typed: hel
  <!-- Too noisy -->
</div>

Best practice: Prefer inline updates over live regions. If you can show the status directly on the page, do that instead of hiding it in a live region.

Modal, Menu, and Toast Semantics

Modals should use role="dialog" and trap focus:

<div role="dialog" aria-labelledby="dialog-title" aria-modal="true">
  <h2 id="dialog-title">Confirm Deletion</h2>
  <p>Are you sure?</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

Menus should support arrow key navigation:

// Menu component
const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === "ArrowDown") {
    focusNextItem();
  } else if (e.key === "ArrowUp") {
    focusPreviousItem();
  } else if (e.key === "Enter" || e.key === " ") {
    selectCurrentItem();
  }
};

Toasts should announce non-intrusively with aria-live="polite":

<div aria-live="polite" aria-atomic="true">✓ Saved successfully</div>

Measure Outcomes

Accessibility is not a checkbox. Measure whether it actually helps users.

Task Success for Keyboard-Only Users

Run usability tests with keyboard-only navigation:

  • Can the user complete the task (e.g., submit a form, navigate to a product, checkout)?
  • How many steps did it take?
  • Did they get stuck?

Track success rate: aim for 95%+ task completion.

Session Replays with Screen Reader Usage

With user consent, tag session replays that include screen reader usage. Review them:

  • Where do users struggle?
  • Which announcements are missed or confusing?
  • Where do they take detours due to focus issues?

Log Skipped Interactions

Log interactions that fail:

  • Focus lost (user tabbed and landed outside the app).
  • Scroll blocked (user could not scroll to reach content).
  • Interaction failed (user pressed Enter, nothing happened).

Aggregate: "20% of keyboard users skip the search form because focus jumps unexpectedly."

This tells you exactly what to fix.

Accessible by Default

Bake a11y into your design system so every team inherits it:

// Button component in design system
export function Button({
  children,
  onClick,
  icon,
  variant = 'primary',
  disabled = false
}) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      aria-label={icon && !children ? 'Button description' : undefined}
      className={clsx(
        'px-4 py-2 rounded font-medium transition-colors',
        variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
        disabled && 'opacity-50 cursor-not-allowed'
      )}
    >
      {icon && <span>{icon}</span>}
      {children}
    </button>
  );
}

Every button automatically has:

  • Focus visible styling.
  • Proper ARIA labeling.
  • Keyboard support.
  • Disabled state styling.

Teams do not have to think about a11y; they inherit it.