back to blog
    Nov 2025·7 min

    Building Accessible Component Libraries That Scale

    Lessons from building a 60+ component design system with WCAG AA compliance from day one.

    AccessibilityDesign Systems

    Accessibility retrofits are expensive because they change component APIs. Building it in from the first primitive costs almost nothing — mostly it means not reinventing keyboard behaviour.

    Own the styling, borrow the semantics

    Focus management, roll-over arrow navigation, and typeahead in a listbox are solved problems. Wrap a headless primitive and spend your effort on tokens and variants.

    tsx
    const button = cva(
      "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " +
        "disabled:pointer-events-none disabled:opacity-50",
      {
        variants: {
          variant: {
            primary: "bg-primary text-primary-foreground hover:bg-primary/90",
            ghost: "hover:bg-accent hover:text-accent-foreground",
          },
          size: { sm: "h-9 px-3", md: "h-10 px-4" },
        },
        defaultVariants: { variant: "primary", size: "md" },
      },
    );
    
    export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
      ({ className, variant, size, ...props }, ref) => (
        <button ref={ref} className={cn(button({ variant, size }), className)} {...props} />
      ),
    );
    Button.displayName = "Button";

    Test accessibility in CI, not in review

    ts
    import { render } from "@testing-library/react";
    import userEvent from "@testing-library/user-event";
    import { axe } from "jest-axe";
    
    it("dialog traps focus and closes on Escape", async () => {
      const onClose = vi.fn();
      const { container, getByRole } = render(<Dialog open onClose={onClose} title="Settings" />);
    
      expect(await axe(container)).toHaveNoViolations();
      expect(getByRole("dialog")).toHaveAttribute("aria-labelledby");
    
      await userEvent.keyboard("{Escape}");
      expect(onClose).toHaveBeenCalledOnce();
    });

    Rules that kept sixty components honest

    • Never remove a focus ring; restyle it with a token so it works on every surface.
    • Colour contrast is a token constraint, checked in CI — not a designer's judgement call per screen.
    • Every interactive component ships a keyboard section in its docs; if it cannot be written, the component is wrong.
    • Icon-only controls require an accessible name at the type level, so omitting one fails the build.

    WCAG AA at this scale was never a heroic effort. It was a handful of constraints enforced by tooling, applied before the first component shipped.