EmpireUI
Get Pro
← Blog7 min read#react-aria#tailwind-css#accessibility

React Aria + Tailwind: Accessible Components with Utility Classes

Build fully accessible React components using React Aria Components and Tailwind CSS utility classes — no custom CSS required, WAI-ARIA patterns included out of the box.

Code editor showing React component code with accessibility attributes highlighted

Why React Aria and Tailwind Belong Together

Honestly, most component libraries treat accessibility as an afterthought — a checklist item bolted on after the visual design is already locked in. React Aria Components from Adobe's React Spectrum project flips that completely. Accessibility is the foundation, not the finish.

Tailwind CSS, on the other hand, is purely about styling. It doesn't care about ARIA roles or keyboard navigation. That's actually its strength here. You get a clean separation: React Aria owns the behavior and semantics, Tailwind owns the visuals. No opinions colliding.

The combination works because React Aria Components expose a renderProps pattern and a className prop that accepts either a string or a function. That function receives state like isPressed, isFocused, isDisabled — and you apply Tailwind classes conditionally based on that state. It's honestly elegant.

Setting Up React Aria Components with Tailwind v4

You'll need react-aria-components version 1.3.0 or later. Install it alongside Tailwind v4.0.2 and you're good to go. No additional plugins required on either side.

npm install react-aria-components
npm install tailwindcss@4.0.2 @tailwindcss/vite

In your tailwind.config.ts (or the new CSS-first config in v4), you don't need any special setup for React Aria. The only thing worth adding is the data-* attribute variants if you want to target React Aria's data attributes directly — though the renderProps pattern handles most cases without them. If you're new to the v4 config format, check out the Tailwind v4 features overview for a solid rundown of what changed.

Building an Accessible Button with State-Driven Classes

The Button component from React Aria is the simplest place to start. It handles focus management, keyboard activation, and touch press events automatically. You never write onKeyDown handlers for space or enter again.

import { Button } from 'react-aria-components';

export function PrimaryButton({ children }: { children: React.ReactNode }) {
  return (
    <Button
      className={({ isPressed, isFocusVisible, isDisabled }) =>
        [
          'inline-flex items-center justify-center',
          'rounded-lg px-4 py-2 text-sm font-medium',
          'bg-violet-600 text-white',
          'transition-colors duration-150',
          isPressed ? 'bg-violet-800 scale-[0.98]' : 'hover:bg-violet-700',
          isFocusVisible
            ? 'outline outline-2 outline-offset-2 outline-violet-400'
            : 'outline-none',
          isDisabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer',
        ].join(' ')
      }
    >
      {children}
    </Button>
  );
}

Notice that isFocusVisible — not isFocused. React Aria distinguishes between focus from keyboard navigation (which should show a visible ring) and focus from pointer clicks (which usually shouldn't). That's a WCAG 2.1 compliance detail most hand-rolled solutions miss entirely.

Accessible Select and ComboBox Patterns

Select menus are notoriously hard to get right. The native <select> is accessible but nearly impossible to style in Tailwind without hacks. Custom dropdowns are stylable but usually break screen readers. React Aria's Select and ComboBox give you the best of both worlds.

The Select component wires up a trigger button, a listbox popup, and a hidden <select> for form submission — all with the correct ARIA roles and keyboard navigation baked in. You style each piece with Tailwind. The Popover gets rounded-xl shadow-lg bg-white dark:bg-zinc-900, the ListBoxItem gets hover and selection states via renderProps.

What trips people up is that ListBoxItem has an isSelected state in addition to isFocused. You'll want bg-violet-100 text-violet-900 on selected items and bg-zinc-100 on focused-but-not-selected ones. Getting those two states visually distinct is important for usability — not just aesthetics. For more component patterns in Tailwind, the pattern of separating focus from selection state comes up repeatedly.

Dialog and Modal Accessibility Without a Headache

Building an accessible modal from scratch requires focus trapping, scroll locking, Escape key handling, ARIA role="dialog", aria-modal, aria-labelledby — the list goes on. React Aria's DialogTrigger and Dialog components handle all of it.

import { Button, Dialog, DialogTrigger, Modal, ModalOverlay } from 'react-aria-components';

export function ConfirmDialog() {
  return (
    <DialogTrigger>
      <Button className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700">
        Delete item
      </Button>
      <ModalOverlay
        className={({ isEntering, isExiting }) =>
          [
            'fixed inset-0 z-50 flex items-center justify-center',
            'bg-black/50 backdrop-blur-sm',
            isEntering ? 'animate-in fade-in duration-200' : '',
            isExiting ? 'animate-out fade-out duration-150' : '',
          ].join(' ')
        }
      >
        <Modal className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-zinc-800">
          <Dialog className="outline-none">
            {({ close }) => (
              <div className="space-y-4">
                <h2 className="text-lg font-semibold text-zinc-900 dark:text-white">
                  Confirm deletion
                </h2>
                <p className="text-sm text-zinc-600 dark:text-zinc-300">
                  This action can't be undone.
                </p>
                <div className="flex gap-3 justify-end">
                  <Button
                    onPress={close}
                    className="rounded-md px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-100"
                  >
                    Cancel
                  </Button>
                  <Button className="rounded-md bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700">
                    Delete
                  </Button>
                </div>
              </div>
            )}
          </Dialog>
        </Modal>
      </ModalOverlay>
    </DialogTrigger>
  );
}

The isEntering and isExiting states on ModalOverlay let you hook Tailwind animation classes directly into React Aria's lifecycle. No framer-motion required for basic transitions. You do need tailwindcss-animate installed for the animate-in/animate-out utilities — or you can write your own @keyframes in your CSS layer.

Theming React Aria Components with OKLCH Colors

Here's where things get interesting. React Aria Components ship with a default stylesheet you can optionally use — but most Tailwind setups skip it entirely and rely purely on className-based styling. That means your theme is just Tailwind config, which is exactly where you want it.

If you're using Tailwind v4's new OKLCH color palette (instead of the old HSL values), your focus rings and selection states will look noticeably better across displays. Something like outline-[oklch(70%_0.2_280)] for a violet focus ring gives you perceptually uniform color that stays vivid on wide-gamut screens. The Tailwind OKLCH colors guide goes deep on why this matters for accessibility contrast ratios.

For dark mode, React Aria's renderProps work fine with Tailwind's dark: prefix — but since renderProps are computed inside the component, you need to pair them with dark: variants in your className strings. Something like isFocusVisible ? 'outline outline-violet-400 dark:outline-violet-300' : '' works exactly as you'd expect. You might also want to pair this with a proper theme toggle implementation so users can switch modes.

Form Components: TextField, NumberField, and DatePicker

Forms are where accessibility debt accumulates the fastest. Labels not associated with inputs, error messages not announced to screen readers, required fields not marked — these are the violations that get sites sued. React Aria's form components fix all of that structurally.

TextField wraps a Label, Input, and optional FieldError into a single component that wires up htmlFor, aria-describedby, and aria-invalid automatically. You style each slot with Tailwind. The Input gets rounded-lg border border-zinc-300 px-3 py-2 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20 and the FieldError gets text-sm text-red-600 mt-1. That's it.

The DatePicker is the genuinely impressive one. It renders a calendar popover with full keyboard navigation — arrow keys move between days, Page Up/Down moves months, Home/End jump to week boundaries. All WCAG 2.2 conformant. You provide Tailwind classes for the calendar grid cells and it just works. The isUnavailable state lets you gray out dates with opacity-30 cursor-not-allowed without any custom logic.

Does this mean you can throw away your form library? Not necessarily. React Hook Form or Zod validation still handles the validation logic. React Aria handles the accessibility layer. They compose well — TextField's isInvalid prop accepts a boolean you'd derive from your form state, and errorMessage accepts the string from your validation schema.

Performance and Bundle Size Considerations

React Aria Components is tree-shakable. You import only what you use, and the rest doesn't end up in your bundle. A Button and a TextField add roughly 8-12kb gzipped together. A full DatePicker with the calendar is closer to 25kb gzipped — that's the cost of the date math utilities it includes.

Tailwind's utility classes get purged at build time as usual, so there's no CSS overhead specific to React Aria. The only thing to watch is if you're generating many dynamic className strings with template literals — Tailwind's content scanner needs to see full class names to include them. Avoid splitting class names across lines like bg-${color}-600. Write bg-violet-600 and bg-red-600 separately so the scanner picks them up.

One real footgun: React Aria's Popover and ModalOverlay use React portals by default. If your Tailwind dark: mode relies on a class on a parent element (not the :root), those portaled elements might not inherit the dark class. Either switch to the media dark mode strategy in Tailwind, or use a getContainer prop to portal into your themed wrapper instead of document.body.

FAQ

Do I need React Aria's default CSS stylesheet when using Tailwind?

No. React Aria Components ships an optional default stylesheet, but when you're using Tailwind you skip it entirely. All styling goes through the className prop or renderProps pattern. Just don't import the stylesheet and you're starting from a clean slate.

How do I handle focus-visible styles with React Aria and Tailwind's focus-visible: variant?

React Aria provides an isFocusVisible renderProp that's more reliable than CSS :focus-visible because it accounts for React's synthetic event system and pointer-vs-keyboard detection. Use isFocusVisible from renderProps rather than Tailwind's focus-visible: prefix for React Aria components — you'll get more consistent behavior across browsers.

Can I use React Aria Components with Tailwind's animate-in / animate-out utilities?

Yes. The ModalOverlay, Popover, and Tooltip components expose isEntering and isExiting states in their renderProps. You apply Tailwind's animate-in or animate-out classes conditionally based on those states. You need tailwindcss-animate installed for those utilities, or you can define your own @keyframes in a Tailwind layer.

What's the difference between isFocused and isFocusVisible in React Aria?

isFocused is true whenever the element has focus, regardless of how it got there. isFocusVisible is true only when the element was focused via keyboard or sequential navigation — not from a mouse click or touch. For accessibility, you should show focus rings only when isFocusVisible is true, which matches the intent of the CSS :focus-visible pseudo-class.

Does React Aria Components work with React Server Components in Next.js 15?

React Aria Components are client components — they use hooks and event handlers internally. Mark any file that imports them with 'use client'. You can still compose them inside Server Component pages; just make sure the interactive wrapper component itself has the 'use client' directive at the top.

How do I prevent Tailwind from purging dynamic class names generated in React Aria renderProps?

Tailwind's content scanner needs to see full class name strings to include them. Avoid string interpolation like bg-${color}-500. Instead, write out all possible class names in full — either in the renderProps function directly, or in a const object mapping states to full class strings. Safelist is a last resort if you're generating truly dynamic names at runtime.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Modal Component: Dialog, Alert, Drawer VariantsBuilding UI Components with Tailwind: Patterns That ScaleNeumorphism Cards: 8 Soft-UI Variants with Accessibility FixesEmoji Picker in React: Searchable, Categorised, Lightweight