Tailwind with Radix UI Tutorial
Combine Radix UI's accessible unstyled primitives with Tailwind utilities to build production-grade dialogs, menus, and tooltips that look exactly the way you want.
What you'll learn
- ✓Why Radix and Tailwind pair so well
- ✓Styling Radix primitives with utility classes
- ✓Using data attributes for open and closed states
- ✓Animating with data-state selectors
- ✓Keeping accessibility intact
Prerequisites
- •Comfortable with React and Tailwind utilities
What and Why
Radix UI ships unstyled, accessible React primitives for dialogs, dropdowns, popovers, tooltips, tabs, and more. It handles focus management, keyboard navigation, and ARIA wiring, but it ships zero visual styles. Tailwind, on the other hand, is a styling system without any behavioral primitives. The two complement each other almost perfectly. You get accessibility done correctly and a fast, utility-first styling workflow on top.
Mental Model
Think of Radix as the skeleton and Tailwind as the skin. Each Radix component exposes parts like Root, Trigger, Content, and Overlay. Each part renders an element you can decorate with utility classes. Radix communicates internal state through data-state attributes on those elements, which Tailwind can target through variants like data-[state=open]:opacity-100.
Radix primitive (behavior + a11y)
-> renders element with data-state="open"
-> Tailwind classes style it
-> data-[state=open]:scale-100
-> data-[state=closed]:opacity-0
Hands-on Example
A styled dialog using Radix Dialog primitives.
import * as Dialog from '@radix-ui/react-dialog';
export function ConfirmDialog() {
return (
<Dialog.Root>
<Dialog.Trigger className="rounded bg-blue-600 px-4 py-2 text-white">
Delete project
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay
className="fixed inset-0 bg-black/50
data-[state=open]:animate-in data-[state=open]:fade-in" />
<Dialog.Content
className="fixed left-1/2 top-1/2 w-full max-w-md -translate-x-1/2
-translate-y-1/2 rounded-lg bg-white p-6 shadow-xl
data-[state=open]:animate-in data-[state=open]:zoom-in-95">
<Dialog.Title className="text-lg font-semibold">Are you sure?</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-slate-600">
This action cannot be undone.
</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close className="rounded px-3 py-1.5 text-slate-700 hover:bg-slate-100">
Cancel
</Dialog.Close>
<button className="rounded bg-red-600 px-3 py-1.5 text-white">Delete</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Radix handles focus trapping, escape key dismissal, scroll locking, and the ARIA roles. You only choose colors, spacing, and motion. The data-[state=open] variants let you key animations off the primitive’s state without writing imperative open and close handlers.
For animations, tailwindcss-animate is the common companion plugin. It adds animate-in, fade-in, and zoom-in-95 utilities that you can pair with data-state variants.
Common Pitfalls
A frequent mistake is wrapping a Radix Trigger in your own button. By default, Trigger already renders a button. Either let it render the element, or pass asChild and forward refs to your own component. Otherwise you end up with nested buttons, which break accessibility and HTML validation.
Another pitfall is forgetting the Portal. Dialogs and dropdowns rendered inline will get clipped by overflow-hidden ancestors. Always wrap content in Dialog.Portal so it lands on the document root.
Styling Content with a transform for centering, then animating with another transform, can fight your positioning. Use Tailwind’s transform utilities consistently or rely on the animate plugin’s pre-baked transforms.
Finally, do not strip aria-* attributes that Radix injects. Tailwind classes change appearance, not semantics.
Best Practices
Build small wrapper components that pre-bake your design system into Radix parts. For example, a Dialog component that always renders Overlay, Content, and your standard close button. This keeps your call sites short and consistent. Use CSS variables for theme tokens so dark mode flips automatically. Lean on data-state variants instead of conditional class names in JSX, because they react instantly to user input without re-renders.
Group spacing and typography utilities together for readability. Extract long class strings with clsx and a small helper when a component has many variants. Test with keyboard only and a screen reader at least once per primitive, because Radix gives you accessibility for free only if you do not accidentally break it.
Wrap-up
Radix plus Tailwind is one of the most productive ways to build a custom design system in React. Radix handles the gnarly details of accessible behavior. Tailwind keeps styling fast and local. Start with a single primitive like Dialog, get comfortable with data-state variants, then expand to Dropdown, Popover, and Tabs. Within a week you will have a small library of components that feels handcrafted and stays accessible by default.
Related articles
- Tailwind Tailwind with Headless UI Tutorial
Use Headless UI's accessible React components alongside Tailwind utilities to build menus, dialogs, comboboxes, and toggles that match your design system exactly.
- Tailwind Tailwind Animation and Transition Utilities
Animate hover states, page mounts, and component transitions with Tailwind's transition and animation utilities and a few custom keyframes.
- Tailwind Tailwind with shadcn/ui: An Overview
A practical overview of how Tailwind CSS and shadcn/ui work together to give you a component library you can actually own and customize.
- React React Modal and Overlay Patterns
Build accessible, composable modals and overlays in React using portals, focus traps, and headless primitives — with pitfalls around scroll lock, stacking, and a11y.