Accessible design systems: what React Aria gives you…
Moving from a styled component library to headless primitives plus Tailwind. What accessibility work is genuinely free, what is not, and the rich-text rule…
There are two ways to get accessible components: adopt a library that ships both behaviour and appearance, or adopt one that ships only behaviour and write the appearance yourself. I have shipped products both ways, and I now default to the second — not because the first is bad, but because of what happens six months in.
The problem with a styled library, stated fairly
A library that ships its own look is the fastest possible start and it is genuinely accessible. Its focus management is better than what you would write, its dialog traps focus correctly, its menus implement the full keyboard interaction, and none of that is work you have to do.
The cost arrives when the design is not the library's design. Every project I run has its own type scale, its own spacing rhythm, its own colour treatment. Overriding a library's styling means fighting specificity, and there is a point in every such project where a component gets reimplemented locally because bending it was harder than rewriting it. That local reimplementation has none of the accessibility work, and nobody notices, because it looks right.
Headless primitives invert that. You get the behaviour — focus, keyboard, ARIA, screen-reader announcements — and you own every pixel from the start. There is nothing to fight, so there is no incentive to defect.
What you genuinely get for free
Worth being concrete about, because "accessible" is a word that gets used without content:
- Focus management in overlays. A dialog traps focus, restores it on close, closes on escape, and locks scroll behind it. All four are things a hand-rolled modal gets wrong, and a mouse user notices none of them while a keyboard user is trapped.
- Full keyboard interaction on composite widgets. A radio group is one tab stop with arrows moving inside it. A row of buttons looks identical and gives each option its own tab stop, no checked state, and no group semantics for a screen reader to announce.
- Correct relationships. Label, description and error are wired to the control with real ids, which is what makes an error announced instead of merely visible.
The pattern I would highlight is the last one. An error message that turns a border red communicates nothing to somebody who cannot see the border, and a describedby that points at an element which does not exist announces nothing at all — which is indistinguishable from having no error.
What is not free
Everything visual, which is the deal you signed. The two that surprise people:
Focus visibility is yours. Headless primitives give you the focus state; making it visible is a styling decision, and the default of removing outlines because they are ugly is how a keyboard user loses the ability to tell where they are. Style :focus-visible deliberately, on everything interactive.
Contrast is yours. No library can know that your muted text on your surface colour is 3.1:1. That is a palette decision, made once, and checked with a tool rather than an eye.
The rule that costs me every time, and why I keep it
Every multi-line text input in my products is a rich-text editor. Not the long ones — every one. Somebody writing about their own work gets to emphasise a word.
That rule has a real accessibility cost, and I want to state it rather than wave it away: a contenteditable region is measurably harder for a screen reader than a native textarea, and Android IME and autocorrect misbehave with it in ways a textarea never does. Since these ship as Capacitor apps, that second one is not theoretical.
So the rule carries its own mitigations, and a field without all of them is not finished:
- The editable element exposes
role="textbox" and aria-multiline, bound to a real label.
- The toolbar is keyboard reachable, every button a real
<button> with aria-pressed read from the live editor state — not from local component state, which lies the moment the caret moves.
aria-describedby reaches the help text and the error, exactly as a text field would.
One component, varying capability. A field whose content must stay plain is the same editor with its formatting hidden, never a different control. Two components drift: one gets the character counter, the other gets the error styling, and neither gets both.
Theming is where a design system either holds or quietly stops working
Every product I ship has one appearance control: a single icon in the header opening one panel carrying every axis — colour treatment, dark mode, type scale, spacing, radius, motion. For signed-out visitors as well as signed-in ones, on every route, at every width.
The implementation detail that decides whether this holds is boring and absolute: every component reads its values from tokens. A hardcoded radius, colour, spacing value or font family is invisible to the control. It does not break — it simply does not respond, so the user changes a setting and one card ignores them, which reads as a bug in the setting rather than in the card.
There is no gate for this that I have found. A hardcoded value typechecks, lints, builds and looks correct in the treatment it was written against. The only reliable check is to switch every axis and use the pages, which is exactly the kind of manual step that gets skipped — so I do it at the end of every batch of screens rather than at the end of a project, when the list of offenders would be too long to face.
The second half is applying the choice before first paint. A theme restored after hydration produces a flash of the wrong theme, and in a dark-mode app used at night that flash is the most physically unpleasant moment in the product. It has to be a small synchronous script that reads the stored preference and stamps the attributes before anything renders — which means that script is a second consumer of the token registry, and the two can drift. Mine is asserted by a test that executes the script and counts what it actually writes, because an earlier version of that test checked the axes were declared while the loop underneath wrote three of six.
The mistake I made, and the gate I added
Building a component vocabulary before the pages that use it produced a set of invented class names that typechecked, linted, built, and rendered as completely unstyled markup. It looked deliberate. It shipped.
Now every project carries an assertion that every class name used in the markup has a rule in the stylesheets or appears in the generated utility output. It found five invented classes in this site's blog work last week — I had reached for btn-secondary, and this design system has btn-ghost.
An unstyled class is the perfect defect: it typechecks, it lints, it builds, and it looks like a decision.
https://aoneahsan.com/blog/accessible-design-systems-radix-ui