Skip to content
Harshit Purwar
Go back

Accessible Component APIs: What I Got Wrong

Table of contents

Open Table of contents

I Used to Think Accessibility Was a Checklist

Let me start with an uncomfortable truth: for the first two years of my career building component libraries, I treated accessibility like a checklist. Slap on some aria-labels, make sure things are focusable, run Lighthouse, call it a day.

It took shipping a design system to 30+ teams — and watching it fail real users — to understand that accessible component APIs aren’t about following rules. They’re about making the wrong thing hard to build.

This post is not a best practices guide. It’s a collection of lessons I learned the hard way. If you’re building reusable components today, maybe you can skip a few of my mistakes.

Lesson 1: Semantic HTML Is Not Enough (But Skipping It Is Worse)

Every article on accessibility starts with “use semantic HTML.” And they’re right. But here’s what nobody tells you: semantic HTML gives you a baseline, not a finish line.

I once built a Card component using proper <article> tags, headings, and landmark roles. Technically correct. But teams were nesting 4-5 of these inside each other, creating a screen reader experience that sounded like a robot reading a table of contents for 30 seconds before reaching any content.

What I learned: The API you expose matters more than the HTML you render. I started asking a different question — not “is this semantic?” but “does this component make it easy to create an accessible page, or just an accessible component in isolation?”

We ended up adding a headingLevel prop so teams could control the heading hierarchy contextually:

// Before: always renders h3, breaks heading hierarchy
<Card title="Latest updates" />

// After: teams control the heading level based on context
<Card title="Latest updates" headingLevel={2} />

Small API change. Massive impact on real-world accessibility.

Lesson 2: ARIA Is a Repair Tool, Not a Feature

Here’s a take that might be controversial: if your component API requires consumers to pass ARIA attributes, your API has failed.

I used to expose props like ariaLabel, ariaDescribedBy, and role on every component. I thought I was being helpful — giving developers flexibility. What actually happened? 80% of the time, developers either ignored these props entirely or used them incorrectly.

One team set role="button" on a component that already rendered a <button>. Another used aria-label on a <div> wrapping visible text, making screen readers ignore the visible text entirely.

What I learned: Bake the ARIA into the component logic. Make accessibility the default path, not an opt-in prop.

// Bad API: pushes a11y responsibility to the consumer
<Modal ariaLabel="..." ariaModal={true} role="dialog">

// Better API: handles a11y internally, exposes only what's needed
<Modal title="Confirm deletion">

The Modal internally sets role="dialog", aria-modal="true", and uses the title prop as aria-labelledby. The consumer never thinks about ARIA. That’s the point.

Lesson 3: Keyboard Navigation Is an API Design Problem

I spent weeks building a custom Dropdown component with full keyboard support — arrow keys, Home, End, Escape, Enter. It was beautiful. It also broke the moment someone put an input field inside the dropdown.

The arrow keys now conflicted between “navigate dropdown items” and “move cursor in input.” We’d handled keyboard navigation as an implementation detail instead of an API concern.

What I learned: Keyboard behavior should be composable, not hardcoded.

We refactored to a compound component pattern that let consumers control keyboard zones:

<Dropdown>
  <Dropdown.Trigger>Options</Dropdown.Trigger>
  <Dropdown.Menu>
    <Dropdown.SearchInput /> {/* Manages its own keyboard scope */}
    <Dropdown.Item>Edit</Dropdown.Item>
    <Dropdown.Item>Delete</Dropdown.Item>
  </Dropdown.Menu>
</Dropdown>

Each sub-component manages its own keyboard behavior and communicates through context. No conflicts. No surprised developers.

Lesson 4: Composition Beats Configuration (Especially for A11y)

Early in our design system, we had a Button component with 14 props. Want an icon? iconLeft="check". Want a loading state? isLoading={true}. Want it to look like a link? variant="link".

The accessibility bugs were endless. An icon-only button with no accessible name. A loading button that didn’t announce to screen readers. A link-styled button that confused developers into using it for navigation (where an actual <a> tag was needed).

What I learned: Smaller, composable components are inherently more accessible because each piece can enforce its own accessibility contract.

// Before: one component, a million ways to get a11y wrong
<Button iconLeft="check" isLoading={true} variant="link" />

// After: composed components that each enforce their own rules
<Button>
  <Button.Icon name="check" />  {/* Warns if Button has no text */}
  <Button.Label>Save</Button.Label>
  <Button.Spinner />  {/* Auto-adds aria-busy and live region */}
</Button>

Button.Icon checks at dev time whether the button has a text label. If it doesn’t, it throws a warning in development: “Icon-only buttons require an aria-label.” The pit of success gets deeper.

Lesson 5: Testing Accessibility Is Not Running a Linter

We integrated axe-core into our CI pipeline. Violations went down. We felt good. Then a user who navigates with a screen reader filed a bug: “I can’t tell which tab is selected in your TabGroup component.”

axe-core passed because aria-selected was technically present. But it was set on a wrapper <div> instead of the <button role="tab">. The automated tool couldn’t catch the semantic mismatch.

What I learned: Automated tools catch maybe 30-40% of real accessibility issues. The rest requires:

  1. Manual keyboard testing — Can you complete every flow without a mouse?
  2. Screen reader testing — Does the announced experience make sense?
  3. Integration tests that assert behavior, not just attributes:
it("announces the selected tab to screen readers", () => {
  render(<TabGroup defaultIndex={0} />);

  const firstTab = screen.getByRole("tab", { name: "Overview" });
  expect(firstTab).toHaveAttribute("aria-selected", "true");

  // The important part: test the INTERACTION, not just the attribute
  fireEvent.keyDown(firstTab, { key: "ArrowRight" });
  const secondTab = screen.getByRole("tab", { name: "Details" });
  expect(secondTab).toHaveFocus();
  expect(secondTab).toHaveAttribute("aria-selected", "true");
  expect(firstTab).toHaveAttribute("aria-selected", "false");
});

Lesson 6: The Best Accessibility API Is the One Developers Can’t Screw Up

This is the meta-lesson that ties everything together. After three years of building component APIs, I’ve come to believe that accessibility is fundamentally an API design problem, not a knowledge problem.

Most developers aren’t ignoring accessibility out of malice or laziness. They’re shipping under pressure with components that make the inaccessible path easier than the accessible one.

Every time I found myself writing documentation that said “make sure to add aria-label when using this component without visible text,” I was admitting a design failure. The component should have required visible text or an explicit accessibility label as a mandatory prop — making it impossible to create an inaccessible instance.

// API that allows inaccessible usage
type IconButtonProps = {
  icon: string;
  ariaLabel?: string; // Optional = forgotten
};

// API that prevents inaccessible usage
type IconButtonProps = {
  icon: string;
  accessibleName: string; // Required = enforced
};

What I’d Do Differently If I Started Over

  1. Start with keyboard and screen reader testing from day one — not after v1 ships.
  2. Make every component’s API require accessibility inputs — no optional aria-* escape hatches.
  3. Use compound components by default — they naturally distribute accessibility concerns.
  4. Write integration tests that simulate real assistive technology flows — not just attribute checks.
  5. Treat “works with a mouse and eyes” as an incomplete implementation — not the default definition of “done.”

The Bottom Line

Accessible component APIs are not about knowing ARIA specs by heart. They’re about designing interfaces where the path of least resistance is also the accessible path. Make the right thing easy. Make the wrong thing loud. That’s it.

If you’re building a component library or design system, ask yourself: can a developer use this component incorrectly and accidentally exclude someone? If the answer is yes, the component needs a better API — not better documentation.


Share this post on:

Next Post
Lighthouse Performance Metrics to Improve User Experience