DEV Community

Cover image for I Built an Accessible React Component Library from Scratch — Here's What I Learned
Anex_UI
Anex_UI

Posted on

I Built an Accessible React Component Library from Scratch — Here's What I Learned

Why I built it
Every time I started a new React project I'd reach for a UI library and hit the same wall. The big ones (MUI, Chakra) bring too much opinion and bulk. The headless ones (Radix, Headless UI) require you to wire up all the styles yourself. shadcn/ui is great but it's built entirely on Radix under the hood.

I wanted something in between — fully accessible, styled out of the box, but with no heavy runtime dependencies beyond React and Tailwind. So I built it myself.

The tech stack
React 19 — ref as a prop, no forwardRef needed
TypeScript — all components extend native HTML attributes
Tailwind CSS v4 — utility classes for one-offs
CSS Modules — component-scoped base styles and variants
Storybook v10 — a11y plugin set to error mode (violations fail the build)
The rule was simple: if a component has an a11y violation in Storybook, it doesn't ship.

Things I learned building accessible components

  1. Native is underrated

For modals and drawers I used the native element with showModal(). It handles focus trap, Escape to close, and backdrop — all for free, with no JavaScript library.

const modalRef = useRef(null);

const open = () => modalRef.current?.showModal();
const close = () => modalRef.current?.close();

return ...;

  1. ARIA is often overused

The rule I followed: only add ARIA when native HTML semantics aren't enough. A doesn't need role="button". A doesn't need role="navigation". This keeps the markup clean and avoids redundant attributes that can confuse screen readers.

  1. Roving tabindex for keyboard navigation

For components like Tabs and RadioGroup, roving tabindex is the correct pattern — only one item in the group is in the tab order at a time, arrow keys move between them.

// only the active tab is focusable

  1. cloneElement for FormField injection

The FormField component needed to inject aria-describedby, aria-invalid, and aria-required into whatever input was passed as a child — without the user having to wire it up manually. cloneElement made this clean:

const child = React.cloneElement(children, {
id,
'aria-describedby': errorId,
'aria-invalid': !!error,
'aria-required': required,
});

  1. Tailwind v4 + CSS Modules is a great combo

Tailwind v4 handles spacing, responsive utilities, and one-offs. CSS Modules handle component-scoped base styles and variants. The two don't conflict and you get the best of both worlds.

The CLI
One thing I really liked about shadcn/ui was the copy-into-your-project approach — you own the code. I built the same thing for Anex UI:

npx anexui add button
npx anexui add modal drawer toast
npx anexui list

It fetches the component from the registry at anexui.com/registry/, resolves dependencies, and writes the files to src/components/. You get full control — edit, restyle, delete. No black box.

What shipped
53 components across 7 categories:

  • Form primitives — Button, Input, Textarea, Select, Checkbox, Radio Group, Switch, Slider, Label
  • Layout — Container, Stack, Grid, Divider
  • Navigation — Tabs, Breadcrumb, Pagination, Stepper
  • Feedback — Alert, Badge, Progress, Spinner, Skeleton, Toast
  • Overlay — Modal, Drawer, Tooltip, Popover
  • Data display — Avatar, Card, Table, Accordion, Tag, Carousel, Banner, Timeline
  • Form composites — FormField, SearchInput, NumberInput

Try it

npm install anexui
Or copy individual components:

npx anexui add button
Docs, live examples, and a component builder → anexui.com

GitHub → https://github.com/debayansen7/anex-ui-library

I'd love to hear your thoughts — what components are missing, what you'd do differently, or any questions about the a11y patterns or architecture. Drop a comment below!

Paste that straight into Dev.to. A few things to do before publishing:

Add your GitHub URL in the last section
Add a cover image — use the social preview image we made earlier (social-preview.html screenshot)
Set it to "Published" not draft

Top comments (0)