Skip to content
C Codeloom
Tailwind

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.

·4 min read · By Codeloom
Intermediate 9 min read

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
Radix and Tailwind layering

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.