How to Build an Accessible Design System with React and Tailwind CSS

Most design systems start with a colour palette and end with a Storybook full of tidy buttons. Somewhere in between, someone tabs into a modal, gets stuck, and quietly closes the ticket. Accessibility does not have to be a heroic audit at the end of a project. Treat it as part of the component contract, the same way you treat prop names and types, and most of it disappears into the build.

What follows is a practical path for a small React component library styled with Tailwind CSS, where WCAG 2.2 compliance comes from the components themselves rather than from a checklist applied page by page.

Design tokens come first

If your colours only exist as hex values scattered across components, contrast becomes impossible to audit and expensive to fix later. Start with semantic tokens in one place: surface, text-primary, text-muted, border, border-focus, danger. Name them by role rather than hue, so a component asking for a danger background never cares whether danger happens to be red or amber.

In Tailwind CSS v3 you extend the theme in tailwind.config.js; in v4 you declare the same values in CSS with the theme directive. Either way, map tokens to Tailwind utilities once and use those utilities everywhere. Then check the pairs you actually ship. Body text against its background needs a contrast ratio of at least 4.5:1, large text of roughly 24px (or 18.66px bold) needs 3:1, and non-text elements such as input borders, icons and focus rings need 3:1 against their neighbours.

WCAG 2.2 nudges you on things tokens alone will not solve. Focus indicators must not be entirely hidden behind sticky headers or cookie banners (2.4.11), and pointer targets should be at least 24 by 24 CSS pixels or have equivalent spacing (2.5.8). A 32px icon button is easier to hit with a thumb and easier to hit with a mouse, so it is a sensible default.

Build a small set of primitives with a boring API

Resist the urge to publish twenty components in the first sprint. Button, TextField, Field (label, hint and error), Dialog and Disclosure will cover a surprising amount of a typical product, and each one is easier to get right in isolation.

Two small utilities pay for themselves immediately. Use clsx to compose conditional class names, and tailwind-merge to resolve conflicts so that a className passed by a consumer beats the component's default rather than fighting it. Without that, every override becomes an important flag and your design system starts to feel hostile. Decide how refs are handled too, and keep it consistent across the library, because inconsistent ref handling quietly breaks focus management in the components that depend on it.

Keep semantics in the markup

A heading component should take a level and render the matching h1 to h6, not pick a tag based on how big the text looks. Decouple visual size from semantic level: a level-two heading with large styling is honest, while reaching for an h4 because it renders smaller is how pages end up with an outline that makes no sense to anyone listening.

Let the props enforce the accessible path

The most reliable way to make accessible usage the default is to make the inaccessible version impossible to type.

  • Make a label required on icon-only buttons. If the prop is optional, someone will omit it and ship a button that announces nothing.
  • Generate ids inside your Field component with useId, and wire htmlFor, aria-describedby and aria-invalid automatically so hint and error text are announced rather than merely displayed.
  • Prefer native elements. A button gets keyboard activation, focus and role for free. Only reach for explicit roles and tabIndex when no native element fits, and then follow an established ARIA pattern closely.
  • Choose your disabled state deliberately. Native disabled removes a control from the tab order; if users need to know why something is unavailable, use aria-disabled, keep it focusable and explain it in visible text.

Focus, keyboard and motion

Focus is the part of accessibility that automated tools detect worst and users notice fastest.

  • Replace outlines, never remove them. A focus-visible ring with a two-pixel width and a visible offset is a decent starting point, with ring and offset colours drawn from tokens that contrast against both the component and the page.
  • Support the keys people expect. Enter and Space activate buttons, Escape closes the topmost layer, and arrow keys move within tabs, menus and listboxes.
  • Move focus into a dialog when it opens, keep it inside while it is open, and return it to the trigger on close. Marking background content as inert is the tidiest way to prevent focus escaping.
  • Add a skip link to the main content and make it appear on focus rather than staying off-screen.
  • Wrap animation in motion-reduce variants so decorative movement stops for people who have asked their operating system for less of it.

Never let colour carry meaning alone

An error shown only as a red border is invisible to someone who cannot distinguish it, and easy to miss for everyone else. Pair colour with text and an icon, and label required fields with the word Required rather than a lone asterisk.

Dark mode is a second theme, not an inversion. Re-check every token pair in it, because a border that reads clearly in the day theme can sit too close to the surface at night. In forced-colours mode browsers discard your backgrounds, so do not rely on a shadow or a background fill to define a boundary. Use a border.

Test components once, not pages forever

Storybook is a reasonable home for this. Write a story per state — default, hover, focus, disabled, error, loading — and run an axe check against each one in continuous integration. Automated tools catch a subset of real problems, mostly missing labels and contrast failures, so treat them as a floor rather than a finish line. Then do the parts a tool cannot:

  1. Tab through the component with the mouse untouched. Can you reach everything, see where you are, and get out again?
  2. Turn on a screen reader and confirm the name, role and state announced match what is on screen.
  3. Zoom to 200 per cent, then set the viewport to 320 CSS pixels and check nothing is cut off or requires horizontal scrolling.
  4. Enable the operating system's high-contrast and reduced-motion settings and repeat the basics.
  5. Read the props in isolation. If you cannot use the component correctly without reading the docs, the API is doing too little.

Start with three components and ship them

Pick Button, TextField and Dialog. Get focus behaviour, labelling, states and contrast right in those three, note the decisions alongside the code, and release them. A small system that everyone trusts gets used; a sprawling one that developers work around teaches people to bypass it entirely.

Grow the library when a real screen needs a real component, and add each new one with the same three questions: what role does it expose, what happens when you tab through it, and what does it sound like when it is read aloud?

Photo: ApexDigitalAgency / Pixabay

Related News
New Year Codebase Health Check: A January Checklist for Development Teams

A practical January checklist for development teams: audit dependencies, target test coverage, prune...

Why Your CSS Grid Layout Breaks on Mobile: Common Mistakes and Fixes

CSS Grid usually breaks on mobile because of sizing floors, not the grid itself. Here's why implicit...

A Developer's Checklist for GDPR-Compliant Logging

A practical checklist for keeping personal data out of application logs, setting sensible retention...