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.

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.
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 handledThe 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:
- Reachable and operable by keyboard. Tab to it, activate with Enter or Space, every mouse action has a keyboard equivalent.
- Focus is visible. A clear
:focus-visiblering, neveroutline: nonewithout a replacement. - It has an accessible name. Visible text, or
aria-label/aria-labelledbywhen the control is icon-only. - Its state is exposed.
aria-expanded,aria-pressed,aria-current,aria-invalid— whatever the sighted user sees, a screen reader user hears. - It respects `prefers-reduced-motion`.
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}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.
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
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});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});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.
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.