Post 08

An Accessible Component Library Without the Overwhelm

You do not need to memorise WCAG to build an accessible component library. You need a handful of primitives done right and a short checklist applied consistently. Here is the version that fits in your head — with the code.

Oct 3, 2025/10 min readAccessibility
ShareY
An Accessible Component Library Without the Overwhelm

Accessibility gets treated as a specialist discipline you bolt on before an audit. For a component library, it is mostly the opposite: a small number of decisions, made once, at the primitive level, that everything else inherits for free.

Build on headless primitives

Do not hand-roll a dropdown, a dialog, a combobox, or a tabs component. The keyboard interaction models for these are genuinely intricate — roving tabindex, focus trapping, aria-activedescendant, escape handling, scroll locking. Use Radix, React Aria, or Ark, and put your styling on top.

components/ui/dialog.tsx — style, don't reimplement
1"use client";2import * as DialogPrimitive from "@radix-ui/react-dialog";3import { X } from "lucide-react";4 5export const Dialog = DialogPrimitive.Root;6export const DialogTrigger = DialogPrimitive.Trigger;7 8export function DialogContent({ children, ...props }: DialogPrimitive.DialogContentProps) {9  return (10    <DialogPrimitive.Portal>11      <DialogPrimitive.Overlay className="fixed inset-0 bg-black/60" />12      <DialogPrimitive.Content13        {...props}14        className="fixed left-1/2 top-1/2 w-full max-w-md -translate-x-1/215                   -translate-y-1/2 border bg-background p-6"16      >17        {children}18        <DialogPrimitive.Close aria-label="Close" className="absolute right-4 top-4">19          <X className="size-4" />20        </DialogPrimitive.Close>21      </DialogPrimitive.Content>22    </DialogPrimitive.Portal>23  );24}25// focus trap, restore-focus-on-close, aria-modal, esc-to-close: all handled
Watch out

The combobox is the one people try to build from scratch and regret. Predictive text, aria-activedescendant, screen-reader announcements of result counts, mobile keyboard behaviour — it is a genuinely hard component. Never hand-roll it.

The primitive-level checklist

For every interactive component you do own, five things:

  1. Reachable and operable by keyboard. Tab to it, activate with Enter or Space, every mouse action has a keyboard equivalent.
  2. Focus is visible. A clear :focus-visible ring, never outline: none without a replacement.
  3. It has an accessible name. Visible text, or aria-label / aria-labelledby when the control is icon-only.
  4. Its state is exposed. aria-expanded, aria-pressed, aria-current, aria-invalid — whatever the sighted user sees, a screen reader user hears.
  5. It respects `prefers-reduced-motion`.
an icon button that is not a trap for screen readers
1export function IconButton({2  label, icon: Icon, ...props3}: { label: string; icon: LucideIcon } & React.ComponentProps<"button">) {4  return (5    <button6      {...props}7      aria-label={label}                 // the icon is decorative8      className="focus-visible:ring-2 focus-visible:ring-ring rounded p-2"9    >10      <Icon aria-hidden className="size-4" />11    </button>12  );13}
one focus style, applied globally
1:where(a, button, input, select, textarea, [tabindex]):focus-visible {2  outline: 2px solid var(--color-primary);3  outline-offset: 2px;4  border-radius: 2px;5}

Colour and contrast, settled at the token layer

Contrast failures should be impossible by construction. If your semantic tokens are chosen to hit 4.5:1 for body text and 3:1 for large text and UI components against every surface they are used on, then no component can fail contrast without going off-token.

checked once, at design time, against every surface
1--color-foreground:        oklch(0.98 0 0);   /* on bg: 15.8:1  ✓ */2--color-muted-foreground:  oklch(0.72 0 0);   /* on bg:  6.1:1  ✓ */3--color-primary:           oklch(0.72 0.19 145); /* on bg: 7.4:1 ✓ */4--color-primary-foreground: oklch(0.14 0.02 145); /* on primary: 6.9:1 ✓ */

Also: never use colour as the only signal. An error is red and has an icon and has text.

Test with the tools your users use

a11y check in the component test
1import { render } from "@testing-library/react";2import { axe } from "jest-axe";3 4it("has no axe violations", async () => {5  const { container } = render(<DatePicker label="Start date" />);6  expect(await axe(container)).toHaveNoViolations();7});
and a keyboard-only e2e path in Playwright
1test("menu is fully keyboard operable", async ({ page }) => {2  await page.goto("/components/menu");3  await page.keyboard.press("Tab");        // focus the trigger4  await page.keyboard.press("Enter");      // open5  await page.keyboard.press("ArrowDown");  // move to first item6  await page.keyboard.press("Enter");      // activate7  await expect(page.getByRole("status")).toHaveText("Profile opened");8});
Note

Automated tools find about 30% of issues — the mechanical ones. The other 70% you find by unplugging the mouse and by tabbing through with VoiceOver or NVDA running. Budget time for that, it is not optional.

Document the accessibility contract

Each component gets a short note: what keyboard interactions it supports, what ARIA it sets, and any requirements it puts on the consumer. That is what keeps the system accessible as it grows past the people who built it.

Found this useful? Pass it on.

All posts

Want a custom write-up for your team? Get in touch.

Building something like this?

If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.