Accessibility by the Library, Styling by Me — Building a Headless Component System with Radix UI, React Aria, and tailwind-variants
This article is aimed at frontend developers who are already using React and Tailwind CSS.
If you've ever tried to implement an accessible UI from scratch, you know how tricky it can be. To build a single Dialog correctly, you need to account for role="dialog", aria-modal, focus trapping, background scroll locking, and ESC key handling. I remember looking at that list the first time and thinking, "Do I really have to do all of this myself?" In practice, screen reader users make up roughly 7–10% of all web users, and for global services, failing an accessibility audit can even lead to legal risk.
These days, however, the frontend ecosystem has found a rather elegant solution to this problem. Headless component libraries like Radix UI and React Aria take the entire accessibility logic off your hands, and tailwind-variants lets you layer a systematic styling system on top. Combining these two layers creates a clean division of labor: "accessibility by the library, design by me."
This article is the first in a series on headless component systems paired with design tokens. It walks through how to build a component system with full control over both accessibility and design by combining a headless library with tailwind-variants, complete with real code. We'll also honestly cover which library to choose and what mistakes commonly happen in production.
Core Concepts
What It Means to Separate Logic from Style
Traditional UI libraries (MUI, Chakra, etc.) bundle logic and style together. That's convenient, but the moment a design system emerges or brand customization becomes necessary, the battle begins. The !important hell of overriding existing styles, dependence on internal class names, overrides that break with every upgrade — a familiar sight.
Headless architecture separates this bundle entirely.
A headless component library is a library that provides only the interaction logic — WAI-ARIA compliance, keyboard navigation, focus management — and includes no visual styles whatsoever. Think of it not as an empty shell, but as a colorless component with an accessibility engine built in.
If you're new to WAI-ARIA, it's worth skimming MDN's "WAI-ARIA Basics" article first. Understanding the structure makes it far clearer just how much a headless library handles for you.
This structure breaks down into three layers:
| Layer | Example Tools | Responsibility |
|---|---|---|
| Interaction Layer | Radix UI, React Aria, Base UI | ARIA attributes, keyboard navigation, focus trapping |
| Style Layer | tailwind-variants, CVA | Variant-based Tailwind class composition |
| Token Layer | CSS variables, Tailwind theme | Consistency across colors, typography, and spacing |
How tailwind-variants Differs from CVA
tailwind-variants is a library that ports the variant API concept from Stitches into Tailwind. Its core function, tv(), lets you declaratively define base styles, variants, and compoundVariants, and it automatically merges class conflicts.
It's often compared to CVA (Class Variance Authority). The practical differences I've felt in production are as follows:
| Feature | tailwind-variants | CVA |
|---|---|---|
| Slots (compound component separation) | ✅ | ❌ |
| Style inheritance / extend | ✅ | ❌ |
| Automatic Tailwind class conflict merging | ✅ | Requires separate setup |
| CSS strategy independence | Tailwind-only | CSS strategy agnostic |
Honestly, for something as simple as a single Button, CVA is sufficient. I also wondered at first, "Do I really need tailwind-variants?" But the moment compound components like Dialog appear — where Overlay, Content, Title, and Description are one set — the value of slots becomes immediately apparent. On the other hand, if there's a chance of moving away from Tailwind or if you're running CSS Modules in parallel, CVA is the more neutral choice since it works regardless of CSS strategy.
Comparing Major Headless Libraries
Here's a summary of the libraries I've evaluated across several recent projects or seen colleagues choose:
| Library | Component Count | Characteristics | Primary Use Case |
|---|---|---|---|
| Radix UI | 30+ | Composition API, React-only | shadcn/ui based |
| React Aria (Adobe) | 50+ | Hook-based, includes i18n and adaptive interaction | Highest-level accessibility requirements |
| Base UI (MUI) | 35 | v1.0 released in 2026 | MUI ecosystem maintenance |
| Headless UI | ~10 | Lightweight, made by Tailwind Labs | Small-scale projects |
With Base UI releasing its v1.0 stable version in February 2026, shadcn/ui can now choose between a Radix backend or a Base UI backend, widening the options considerably. From direct comparison, Radix has a more polished composition API and a richer ecosystem, while Base UI has the advantage of long-term maintenance guaranteed by the MUI team, making it better suited for enterprise environments.
Selection criteria summary: If you need the highest level of accessibility plus internationalization, consider React Aria. If your goal is rapid design system development, consider Radix UI + shadcn/ui. If you want to stay within the MUI ecosystem, consider Base UI.
Practical Application
Example 1: Building a Button Component with Radix UI + tailwind-variants
This is the most fundamental pattern. It's shown with Radix's Slot to help you understand the asChild pattern at the same time.
// components/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { tv, type VariantProps } from "tailwind-variants"
const button = tv({
base: [
"inline-flex items-center justify-center rounded-md font-medium",
"transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
],
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2 text-sm",
sm: "h-9 px-3 text-xs",
lg: "h-11 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
})
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof button> {
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp ref={ref} className={button({ variant, size, className })} {...props} />
)
}
)
Button.displayName = "Button"| Code Point | Description |
|---|---|
tv({ base, variants, defaultVariants }) |
Centralizes style definitions in one place. External overrides possible via the className prop |
asChild + Slot |
Used to apply button styles to other elements like Link or RouterLink. Props and ref are forwarded automatically |
React.forwardRef |
Essential when combining with Radix primitives — without ref forwarding, focus management can break |
VariantProps<typeof button> |
TypeScript catches invalid variant values at compile time |
What is the
asChildpattern? It's a pattern that merges props onto a child element via Radix'sSlotcomponent. Used when applying Button styles to an<a>tag, like<Button asChild><a href="/home">Home</a></Button>. If the ref and all props are not properly forwarded, accessibility silently breaks — so always keep theReact.forwardRef+...propsspread pattern together.
Example 2: Building a Modal with Radix Dialog + tailwind-variants Slots
This is the scenario where slots truly shine in compound components. A Dialog needs its Overlay, Content, Title, and Description each styled independently — with slots, you can manage all of this cleanly within a single tv() definition.
// components/modal.tsx
import * as React from "react"
import * as Dialog from "@radix-ui/react-dialog"
import { tv } from "tailwind-variants"
const dialog = tv({
slots: {
overlay: [
"fixed inset-0 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
],
content: [
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"rounded-lg bg-white p-6 shadow-xl",
"w-full max-w-md",
"focus:outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
],
title: "text-lg font-semibold leading-none tracking-tight",
description: "text-sm text-muted-foreground mt-2",
closeButton: "absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100",
},
})
const { overlay, content, title, description, closeButton } = dialog()
interface ModalProps {
trigger: React.ReactNode
title: string
description?: string
children: React.ReactNode
}
export function Modal({ trigger, title: titleText, description: descText, children }: ModalProps) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
{/* Radix automatically handles aria-hidden, role="dialog", and focus trapping */}
<Dialog.Overlay className={overlay()} />
<Dialog.Content className={content()}>
<Dialog.Title className={title()}>{titleText}</Dialog.Title>
{descText && (
<Dialog.Description className={description()}>{descText}</Dialog.Description>
)}
{children}
<Dialog.Close className={closeButton()} aria-label="Close">
<span aria-hidden="true">✕</span>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}Here's a breakdown of what Radix handles automatically in this structure:
| Item Radix Handles Automatically | If Implemented Manually |
|---|---|
role="dialog", aria-modal="true" |
Manually add attributes |
| Focus trap (Tab navigation stays inside Dialog) | Add a library like focusTrap.js |
| Close on ESC key | Add a keydown event listener |
| Restore focus to original position when Dialog closes | Save previous focus element with ref and restore manually |
| Background scroll lock | Directly manipulate body overflow style |
By leveraging Tailwind's interactive modifiers like data-[state=open], you can even handle animations at the CSS level without any JavaScript. And for close buttons whose only text is the ✕ special character, screen readers may not read it correctly — so it's important to always include aria-label="Close". Since this is an article about accessibility, the example code itself should meet accessibility standards.
Example 3: React Aria Hooks + tailwind-variants (Highest-Level Accessibility)
When you need to account for internationalization, screen reader support, and platform-specific interaction differences, React Aria becomes a candidate. Being hook-based, it pairs with styles very freely. React Aria can also be installed as individual packages (@react-aria/*), but the current officially recommended approach is to use the react-aria unified package.
// components/aria-button.tsx
import { useButton, type AriaButtonProps } from "react-aria"
import { useRef } from "react"
import { tv } from "tailwind-variants"
const button = tv({
base: "rounded px-4 py-2 font-medium transition-all",
variants: {
isPressed: {
true: "scale-95 opacity-80",
},
isDisabled: {
true: "cursor-not-allowed opacity-50",
},
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
},
},
defaultVariants: {
variant: "primary",
},
})
interface AriaButtonComponentProps extends AriaButtonProps {
variant?: "primary" | "secondary"
}
export function AriaButton({ children, variant = "primary", ...props }: AriaButtonComponentProps) {
const ref = useRef<HTMLButtonElement>(null)
const { buttonProps, isPressed } = useButton(props, ref)
// buttonProps already includes disabled handling.
// Passing isDisabled separately to tv() is solely for applying CSS style variants,
// and operates independently from the HTML disabled attribute.
return (
<button
{...buttonProps}
ref={ref}
className={button({
variant,
isPressed,
isDisabled: props.isDisabled,
})}
>
{children}
</button>
)
}React Aria's
useButtonhandles not just click events, but also Space/Enter key handling, press state on touch devices, and pointer events during screen magnification. TheisPressedstate connects naturally with styles by passing it as a variant. The reasonisDisabled: props.isDisabledis passed separately totv()is to control CSS styles likecursor-not-allowed. This is distinct frombuttonPropshandling the HTMLdisabledattribute — the roles are different, so it's not a duplicate.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Automated accessibility | WAI-ARIA roles/attributes, keyboard navigation, and focus trapping are handled at the library level — no separate implementation needed |
| Full design control | Since you own 100% of the styles, you can implement a UI that precisely matches your brand identity |
| Type-safe variants | TypeScript autocomplete blocks invalid variant values at compile time |
| Slots pattern | Each part of a compound component can be styled independently while sharing variant state |
| Bundle optimization | Tree-shakable structure means only the components you use are included in the bundle |
| Maintainability | Style definitions are centralized in one place, making consistency easy to maintain |
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Initial build cost | Since there are no styles, every component must be styled manually | Recommended to use shadcn/ui code as a starting point |
| CSS proficiency required | Teams with limited design experience may find the blank canvas daunting | Define design tokens (CSS variables) first, then start on components |
| Risk of breaking accessibility | Misuse of asChild, missing prop forwarding, or missing ref forwarding can silently break accessibility |
Always maintain the React.forwardRef + ...props spread pattern |
| Learning curve | Radix composition API, React Aria hooks, and tailwind-variants slots must all be learned simultaneously | Recommended to adopt incrementally: Radix + CVA first, then tailwind-variants slots |
| Library lock-in | tailwind-variants is Tailwind-only, so switching to CSS-in-JS requires rework | If there's any chance of moving away from Tailwind, CVA is a more neutral choice |
The Most Common Mistakes in Production
When adopting this combination, both I and my teammates tended to stumble at similar points at first.
-
Omitting
React.forwardRef: Radix often uses refs internally to control focus. Using a custom component as a Radix Trigger withoutforwardRefmeans focus restoration won't work properly and will fail an accessibility audit. If focus disappears into the void after closing a Dialog, start by checking here. -
Picking only the needed props instead of spreading
...props:aria-*anddata-*attributes are sometimes injected internally by Radix. Not forwarding them causes styles and behavior to become disconnected. Most bugs wheredata-[state=open]styles don't apply fall into this category. -
Attempting
classNameoverrides withouttailwind-merge:tv()uses tailwind-merge internally to resolve class conflicts. However, if you use onlyclsxwhen merging classes outside oftv(), conflicts likep-2 p-4coexisting will occur. For external merging, always use thecn()utility (a combination ofclsx+tailwind-merge).
Closing Thoughts
Accessibility is not a feature you add later — it should be the first layer of your component architecture. The headless library + tailwind-variants combination is one of the most practical ways to realistically implement this principle.
It's true that the initial setup takes time, but the return on that investment becomes clear as your design system grows. I hope you get to experience the moment when the hours spent wrestling with style overrides disappear and accessibility bug reports start to dwindle.
Three steps you can start right now:
- You can quickly scaffold the foundation with the shadcn/ui CLI. The
pnpm dlx shadcn@latest initcommand gets you started immediately with a Radix + tailwind-variants based component system. It's recommended to open the generatedcomponents/ui/button.tsxand read through how thetv()structure is written. - You can try refactoring one compound component using slots. If you have an existing Dialog or Dropdown component, try moving its style definitions into a
tv({ slots: { ... } })structure — the concept of slots will become naturally intuitive. - Install the axe DevTools browser extension and scan your existing components once. You'll see how many accessibility issues have been hiding in plain sight, and the need for adopting a headless library will feel far more concrete.
References
- Top Headless UI libraries for React in 2026 | GreatFrontEnd
- Headless UI alternatives: Radix Primitives vs React Aria vs Ark UI vs Base UI | LogRocket Blog
- Radix UI vs Headless UI vs Ariakit: The Headless Component War | JavaScript in Plain English
- React Aria Official Docs | Adobe
- React Aria Quality & Accessibility | Adobe
- Tailwind Variants Official Docs — Slots
- Tailwind Variants — Composing Components
- CVA vs. Tailwind Variants: Choosing the Right Tool | DEV Community
- shadcn/ui and Radix: Accessibility When Customizing | BetterLink Blog
- MUI Releases Base UI 1 with 35 Accessible Components | InfoQ
- Base UI Releases | base-ui.com
- shadcn/ui vs Base UI vs Radix: Components in 2026 | PkgPulse
- Build accessible components with React Aria | OpenReplay Blog