Skip to main content
accessibility

The Accessibility Tax You Pay Later Is Always Higher

Retrofitting WCAG compliance into a 2-year-old codebase costs 10x what building it in from the start would have. Here's what that debt actually looks like.


8 min read

Accessibility debt accrues interest faster than any other kind of technical debt because it's woven into your component API surface. When you ship a <CustomDropdown> that uses div and onClick instead of a button and onKeyDown, you haven't just shipped one broken component — you've shipped a broken contract that every consumer of that component will replicate. Auditing and fixing it later means touching every callsite, rewriting internal focus logic, updating tests, and coordinating with design to verify the interaction still looks right. The fix that would have taken 20 minutes at component creation takes a sprint.


What "building it in" actually means

Building accessibility in doesn't mean adding ARIA attributes at the end of a PR. It means starting from the right primitive.

Semantic HTML first. A <button> element gives you keyboard focus, Enter and Space activation, disabled state, and correct role — for free, from the browser. A <div role="button" tabindex="0"> gives you a role and focusability, but you still have to write the keydown handler yourself, manage disabled state manually, and hope you didn't miss an edge case. The cost difference is measurable at authoring time and enormous at audit time.

ARIA roles on interactive elements that can't use native HTML. A custom tab panel, a combobox, a tree view — these have no native HTML equivalent. The ARIA spec defines the role, state, and property semantics for all of them. Reaching for role="combobox" with aria-expanded, aria-controls, and aria-activedescendant at construction time is a two-minute decision. Retrofitting it means understanding every state the component can be in and mapping each to the correct ARIA attribute — after the component already has 40 consumers who depend on its current behavior.

Keyboard navigation patterns from the start. The ARIA Authoring Practices Guide defines interaction patterns for every composite widget. A listbox uses arrow keys to move focus. A dialog traps focus and dismisses on Escape. A menu bar uses arrow keys to navigate between menus and Enter to open. These patterns aren't optional if you want assistive technology users to be able to use your product — and they're essentially free to implement when you're building the component for the first time.

Focus management in modals and route changes. When a modal opens, focus should move to it. When it closes, focus should return to the trigger element. When a route changes, focus should move to the main content area or the new page's heading. React doesn't do this automatically. But useRef + .focus() in the right lifecycle is a ten-line pattern. Retrofitting it means identifying every place a modal is opened, tracking what triggered it, and threading a ref through components that were never designed to hold one.


What retrofitting actually looks like

I audited a two-year-old React codebase recently. The component library had 34 interactive components. Of those, 11 used div or span as their root element for clickable interactions. Not a single custom dropdown had keyboard support. Every modal stole focus but didn't restore it. Every form had visible error states that were invisible to screen readers because the error message wasn't associated with the input via aria-describedby.

Here's the pattern that appeared most often:

// What was shipped
function Dropdown({ options, onSelect }) {
  const [open, setOpen] = useState(false);

  return (
    <div className="dropdown">
      <div className="trigger" onClick={() => setOpen(!open)}>
        Select an option
      </div>
      {open && (
        <div className="options">
          {options.map((opt) => (
            <div key={opt.value} onClick={() => onSelect(opt)}>
              {opt.label}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

No keyboard access. No ARIA. No focus management. A screen reader announces nothing useful. A keyboard-only user can't open it at all.

The fix is not additive. It's a rewrite:

// What it should have been
function Dropdown({ options, onSelect, label }) {
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const triggerRef = useRef(null);
  const listboxId = useId();

  function handleKeyDown(e) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setActiveIndex((i) => Math.min(i + 1, options.length - 1));
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      setActiveIndex((i) => Math.max(i - 1, 0));
    } else if (e.key === "Enter" && activeIndex >= 0) {
      onSelect(options[activeIndex]);
      setOpen(false);
      triggerRef.current?.focus();
    } else if (e.key === "Escape") {
      setOpen(false);
      triggerRef.current?.focus();
    }
  }

  return (
    <div className="dropdown">
      <button
        ref={triggerRef}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={listboxId}
        onClick={() => setOpen(!open)}
      >
        {label}
      </button>
      {open && (
        <ul
          id={listboxId}
          role="listbox"
          aria-label={label}
          onKeyDown={handleKeyDown}
        >
          {options.map((opt, i) => (
            <li
              key={opt.value}
              role="option"
              aria-selected={i === activeIndex}
              onClick={() => {
                onSelect(opt);
                setOpen(false);
                triggerRef.current?.focus();
              }}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

That rewrite takes 45 minutes and requires updating tests. Multiplied by 11 components, coordinated across a team, with QA cycles — it's a sprint, minimum. If the dropdown has edge cases (grouped options, async loading, clearable), it's longer.


How to audit what you have

Three tools that together cover most of the audit:

axe-core as a test utility catches the mechanical violations: missing alt attributes, form inputs without labels, color contrast failures, ARIA misuse. Drop jest-axe into your component test suite and you get automated regression coverage on every component render.

import { axe } from "jest-axe";

test("Dropdown has no accessibility violations", async () => {
  const { container } = render(<Dropdown options={options} label="Filter" />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

pa11y-ci runs against URLs, which means it catches page-level issues that component tests miss: skip navigation links, landmark structure, heading hierarchy, <main> presence. It integrates into CI as a pre-deploy check.

Manual keyboard testing catches what automated tools can't: focus order that's technically correct but confusing, modals that trap focus but don't announce their purpose, form flows that work with a mouse but strand a keyboard user at step 3. Tab through your most important user flow. If you get stuck, a screen reader user will too.


The ROI argument that isn't about legal risk

The legal risk argument for accessibility is real (the number of ADA digital accessibility lawsuits has increased every year for the past eight years), but it's the weakest version of the argument because it treats accessibility as cost avoidance rather than user reach.

Approximately 15-20% of the global population has some form of disability. In the US, that's 61 million people. Permanent motor, visual, cognitive, and auditory disabilities are part of that number — but so are situational impairments: a parent holding a child, a user in bright sunlight, someone who broke their arm. Keyboard navigation doesn't just help screen reader users. It helps power users, users on tablets, users with reduced motion preferences, users with repetitive strain injuries.

The users you're excluding when you ship inaccessible components aren't an edge case. They're a material percentage of your user base. Building accessibility in from the start is the only version of the argument where you come out ahead on both sides — you don't pay the retrofit cost, and you don't spend two years excluding users you could have served.

The 20-minute component decision compounds in both directions. Make the right call early and it costs almost nothing. Make the wrong call and it costs a sprint per component, plus the time those users spent unable to use your product at all.