Skip to content
C Codeloom
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.

·4 min read · By Codeloom
Beginner 9 min read

What you'll learn

  • What Headless UI offers and how it differs from a full UI kit
  • Styling Menu, Dialog, and Combobox with Tailwind
  • Using render props and data attributes
  • Smooth transitions with the Transition component
  • Avoiding common integration mistakes

Prerequisites

  • Comfortable with React and Tailwind utilities

What and Why

Headless UI is a small library from the Tailwind team that ships fully accessible, unstyled React and Vue components. It covers the building blocks you reach for most often: Menu, Listbox, Combobox, Dialog, Disclosure, Popover, Radio Group, Switch, and Tabs. Because it ships no styles, you get a clean canvas to apply Tailwind utilities. The result is components that look like yours but behave correctly without you reinventing focus management or keyboard navigation.

Mental Model

Each Headless UI component exposes a set of subcomponents. State is communicated either through render-prop arguments like { open, active } or through data- attributes on the rendered element. You can use either approach, but data attributes pair particularly well with Tailwind’s variant system because you can write data-[headlessui-state~=open]:opacity-100 and avoid threading state through JSX.

<Menu>
Menu.Button   ->  data-headlessui-state="open"
Menu.Items    ->  data-headlessui-state="open"
  Menu.Item   ->  data-headlessui-state="active"
                     |
                     v
            Tailwind variants style each state
Headless UI flow with Tailwind

Hands-on Example

A dropdown menu styled entirely with Tailwind utilities.

import { Menu } from '@headlessui/react';

export function UserMenu() {
  return (
    <Menu as="div" className="relative inline-block text-left">
      <Menu.Button className="rounded bg-slate-900 px-3 py-2 text-sm text-white">
        Account
      </Menu.Button>
      <Menu.Items
        className="absolute right-0 mt-2 w-48 origin-top-right rounded-md
                   bg-white shadow-lg ring-1 ring-black/5 focus:outline-none">
        <Menu.Item>
          {({ active }) => (
            <a href="/profile"
               className={`${active ? 'bg-slate-100' : ''} block px-4 py-2 text-sm`}>
              Profile
            </a>
          )}
        </Menu.Item>
        <Menu.Item>
          {({ active }) => (
            <a href="/logout"
               className={`${active ? 'bg-slate-100' : ''} block px-4 py-2 text-sm`}>
              Sign out
            </a>
          )}
        </Menu.Item>
      </Menu.Items>
    </Menu>
  );
}

Keyboard navigation, focus rings, escape to close, and roving tab index all work out of the box. You only describe the look. For animations, wrap Menu.Items in Transition and pass enter and leave class strings, or use the newer data-attribute approach with tailwindcss-animate.

A toggle switch with clear accessibility.

import { Switch } from '@headlessui/react';

export function NotificationsToggle({ enabled, onChange }) {
  return (
    <Switch checked={enabled} onChange={onChange}
      className={`${enabled ? 'bg-green-600' : 'bg-slate-300'}
                  relative inline-flex h-6 w-11 items-center rounded-full transition`}>
      <span className={`${enabled ? 'translate-x-6' : 'translate-x-1'}
                        inline-block h-4 w-4 transform rounded-full bg-white transition`} />
    </Switch>
  );
}

Common Pitfalls

The most common mistake is rendering interactive elements inside Menu.Button or Disclosure.Button. Those already render a button, so nesting an <a> or another button creates invalid HTML and breaks keyboard handling. Use the as prop to swap the rendered element when needed.

Forgetting as="div" on the outer wrapper of Menu can also cause layout surprises, because by default it renders a fragment. If you need to position the items absolutely, you must have a wrapping element with position: relative.

Another pitfall is animating with custom CSS that conflicts with the Transition component. Pick one approach. Either lean on Transition enter and leave classes, or use data-state attributes with utilities, but not both.

Best Practices

Wrap each Headless UI component in your own thin component that locks in your design tokens. Your <Dropdown> should render Menu, Menu.Button, and Menu.Items with your standard padding, shadow, and animation. Call sites then look declarative and consistent. Prefer data-attribute variants over render props once you are comfortable, since they read more like CSS and reduce JSX noise.

Pair Headless UI with Heroicons, since both come from the Tailwind team and share design conventions. Always test with the keyboard, because that is where Headless UI shines and where regressions are easiest to introduce.

Wrap-up

Headless UI gives you accessibility-first primitives with no opinions on style. Tailwind gives you a fast styling layer with no opinions on behavior. Together they let small teams ship custom components that feel as polished as those in any commercial UI kit. Start with Menu or Dialog, see how little code it takes, and grow your component library from there.