August 6, 2026 · Frontend· 10 min read
Write the accessibility once
Component libraries are beautiful, and a lot of them cannot be operated without a mouse. So I built one the other way round, accessibility first and appearance last, and found out what that costs.
Most component libraries are beautiful. That is the first thing you notice: a landing page with gorgeous type, tasteful shadows, an animated hero, and a sidebar listing forty components. It looks like care. Sometimes it is.
Then you press Tab. The focus ring is gone, because someone set outline: none to tidy it up and never put anything back. You open the modal and focus is still sitting behind it, so a screen reader is reading out the page underneath a dialog it cannot see. The pretty dropdown is a div with a click handler: no role, no keyboard, announced as nothing at all. And the body text is grey-on-grey at 3:1, because that is the palette on the mood board.
What gets to me is not that this is hard. Some of it genuinely is. It is that accessibility is so reliably the part that got cut: the pass you make at the end if there is time, and there is never time. Meanwhile the shadows went through three revisions and the hero animation got four. Forty components shipped and not one of them can be operated without a mouse.
I wanted to know what it would cost to build one the other way round. Accessibility first, appearance last, and see what that does to the shape of the thing.
I also wanted the same components in Vue and in React. The obvious way is to write them twice. I have done that before, on a team, and I know how it ends: the two implementations start identical, and six months later the React tabs handle Home and End and the Vue tabs do not, because someone fixed a bug in one and nobody thought to port it.
What drifts is never the rendering. It is the accessibility. Rendering is visible; if a panel stops appearing, someone notices that afternoon. A tab list that quietly stops responding to arrow keys can go unnoticed for a year, because everyone testing it is using a mouse.
So I built it the other way around, and the thing I learned was not really about architecture.
Why there is a core at all
Every behaviour lives once, in plain TypeScript with no dependencies and no import of any framework: the state transitions, the keyboard map, the ARIA attributes, the focus management. That package does not know Vue or React exist. CI fails the build if it ever learns; there is a check that reads the source and the built output for framework imports, because a type-only import is easy to add by accident and invisible in a diff.
The core exports a pure reducer and a connect() function. connect() takes the current state and hands back neutral prop bags: plain objects describing what each part of the component should be, with no opinion about who renders them.
Each framework adapter supplies one translator, normalizeProps, and renders. That is the whole adapter. No keyboard handler lives in either of them.
// core: knows nothing about any framework
const api = connect(state, send, normalizeProps);
// react adapter
<button {...api.triggerProps}>{children}</button>
// vue adapter
<button v-bind="api.triggerProps">...</button>It costs a layer of indirection, and I want to be honest about that rather than pretend it is free. A single one-off component is more code this way. If you need one dropdown, in one framework, write the dropdown. The pattern starts paying at the second framework, and again at every component after the first, because the expensive part has already been written.
The bugs that do not throw
Here is where the architecture stopped being the interesting part.
The core emits event names in React's camelCase form (onKeyDown) because converting that to what Vue wants is mechanical, and going the other direction is guesswork. Vue wants onKeydown: capital on the first letter after "on", lowercase for the rest.
The tempting shortcut is to lowercase the whole thing. Do not.
// right
onKeyDown -> onKeydown
// wrong, and silent
onKeyDown -> onkeydownonkeydown fails Vue's internal test for what counts as an event listener, so Vue assigns it as a DOM property on the element instead. Which works. On a fresh client-side mount it behaves exactly like the correct version, so every test you write in jsdom passes and every manual check in dev passes.
Then you server-render it, and the property is not part of the HTML, and hydration does not reattach it. The component arrives in the browser looking perfect and completely inert. No error. No warning. Nothing in the console. The only thing that catches it is a test that server-renders the component and then asserts it still responds.
The same shape of problem shows up with ids. A headless library needs ids to wire aria-labelledby and aria-controls to the elements they point at, and the obvious implementation is a counter in the library. That produces a different id on the server than on the client, which is a hydration mismatch, which manifests as ARIA attributes pointing at elements that do not exist. So the core does not generate ids at all. It takes them as input, and each adapter passes in its own framework's useId().
Notice the pattern. Neither of these is a hard bug to fix. Both are nearly impossible to notice, because accessibility failures do not raise exceptions. They just quietly exclude someone.
axe is a floor, not a ceiling
I run axe on every component and every documentation page, and it finds real things. It is also nowhere near sufficient, and I think the industry is a little too comfortable treating a clean automated scan as a finished job.
axe cannot press a key. It cannot tell you that your arrow-key handler moves focus in the wrong direction, or that Home does nothing, or that your focus trap lets Tab escape on the last element. It cannot open your dialog and check that focus actually moved inside it. That matters, because a modal that never moves focus is announced to a screen reader user as nothing at all. It cannot notice that closing the dialog dropped focus onto the body instead of returning it to the trigger, so the user is now at the top of the page with no idea where they are.
So every interactive component ships an explicit keyboard test that asserts its full key map from the WAI-ARIA Authoring Practices: not "it renders", but every key the pattern promises. And the focus behaviour is tested in a real browser rather than in jsdom, because jsdom's focus model is an approximation and it will happily tell you a trap works when it does not.
That is most of why the test count looks the way it does: 894 unit tests for the state machines, and 213 browser tests for everything that only a real browser can tell the truth about.
Contrast is a gate, not a design review
The colours are OKLCH custom properties. A script parses them, converts to sRGB, computes WCAG relative luminance, and measures 22 declared pairs against the ratio each one actually needs: 4.5:1 for text, 3:1 for anything that is the only indicator of a state, like a focus ring or the border on an invalid field.
$ pnpm contrast
fg on bg 14.82:1 >= 4.5 body text
on-accent on accent 7.31:1 >= 4.5 text on the accent fill
line-strong on surface-sunk 3.42:1 >= 3.0 switch track border
all measured pairs meet their required ratioIt runs in CI. Change a colour and the build tells you what you broke and which component you broke it for. Every pair has a written purpose next to it, so the failure is a sentence rather than a number, and the list doubles as documentation of which colours are load-bearing and which are decorative. The hairline between menu items is deliberately below 3:1, and that is allowed precisely because it never indicates state. Writing that down is what stops someone "fixing" it later.
The accessibility bug in my own instructions
This is the part I keep thinking about.
The stylesheet is optional and split per component, so you only import what you use. Every component page told you how:
import '@caioalfonso/kanso-styles/tokens';
import '@caioalfonso/kanso-styles/switch';There is a third file. base.css holds the things every component depends on but none of them own: the box-sizing reset, the reduced-motion opt-out, the clipping that keeps a form component's hidden mirror input out of sight, and the :focus-visible ring. No component stylesheet imports it.
So the documented installation produced a control with no visible focus indicator. A WCAG 2.4.7 failure, in an accessibility library, in its own instructions.
It survived from the day that page was written, and it would have gone on surviving indefinitely, because of something worth internalising: the documentation site imports the bundled stylesheet, which pulls in base.css automatically. Every live component preview on every page had a perfect focus ring. The examples looked right. They were rendered by different code than the code printed above them, so they proved nothing about it. And the fact that they looked correct is exactly what stopped me from checking.
No test caught it. No scan caught it. I found it while consolidating seven duplicated installation sections into one page, which I was only doing because the duplication annoyed me. Tidying found a real defect that the entire test suite was structurally incapable of seeing, because the suite tests the library and this was a bug in the instructions.
What I actually took from it
I did not build the shared core to avoid typing things twice. I built it because accessibility is the part that is expensive to get right and cheap to get subtly wrong, and writing it once is the only way I know to stop two implementations drifting apart. The architecture is downstream of the accessibility, not the other way round.
And the rest of it comes down to one uncomfortable property: none of these failures announce themselves. The inert component renders. The dangling id validates. The unstyled focus ring looks fine to anyone using a mouse. The instructions read perfectly well. Every one of them is invisible to the person who wrote it and obvious to the person it excludes.
Which is why the answer is never "be careful". It is to make the machine check: press the keys in a real browser, server-render it and poke it again, measure the contrast on every build. Be suspicious of anything that looks right for reasons you have not verified, especially your own documentation.
Seven components is not the point
It is seven components. I am not going to dress that up: seven is not many, and the libraries I was complaining about at the top have forty.
There will probably be more. The whole point of the shared core is that the eighth component is cheaper than the first, and that it arrives already knowing how to handle the keyboard, the ARIA, the focus and the contrast, because none of that gets rewritten per component or per framework. What exists now is less a library than a foundation with seven things standing on it. Adding to it is the easy part; that was the design goal.
And I should be straight about the visual design: it is fine. It is not great. It might not even be good. I am not a designer and it shows. Everything is spare and square and grey, which is partly a deliberate aesthetic and partly just the limit of what I can do well.
I have made my peace with that, because of which half is hard to change. Every value in the thing is a custom property. Hand it to someone with a better eye and they can retheme all of it (colour, spacing, radius, motion) from a handful of tokens, without touching one line of behaviour, and the contrast gate will tell them the moment a choice stops being readable. The keyboard handling and the focus management they get for free, whether they think about them or not.
That is the trade I would make every time. You can always make an accessible thing prettier. Making a pretty thing accessible, afterwards, once the markup is forty divs deep and the focus ring was removed on purpose, is the job nobody ever quite finishes.