TL;DR: Headless UI gives you accessible interactive components (Dialog, Listbox, Combobox, Popover) with zero default styles. Built by the Tailwind team - it handles WAI-ARIA and keyboard navigation, you handle appearance with Tailwind classes. Official docs: headlessui.com.
What is Headless UI?
Headless UI provides accessible interactive components - Dialog, Listbox, Combobox, Menu, Popover, Switch, Tab - with zero default styles. It's built by the Tailwind CSS team and is the official complement to Tailwind: Headless UI handles behaviour and accessibility, you handle appearance with Tailwind classes.
When to use it
Use Headless UI if you're on Tailwind CSS and want accessible components without fighting a styling API. If you're not using Tailwind, Radix UI offers a broader component set with a similar headless approach.
Headless UI vs Radix UI: Both are headless and accessible. Headless UI has a slightly simpler API; Radix UI covers more components and provides more composability granularity. They're interchangeable for most use cases.
Key Features
- Tailwind-first - every example in docs uses Tailwind utility classes
- Transition API - built-in
Transitioncomponent for animated enter/leave states - Combobox - autocomplete/search select with full keyboard support
- v2 improvements - React 18 first,
data-*attribute API for state-driven Tailwind
Installation
npm install @headlessui/react
Basic Usage
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
function DropdownMenu() {
return (
<Menu as="div" className="relative">
<MenuButton className="px-4 py-2 bg-indigo-600 text-white rounded-lg">
Options
</MenuButton>
<MenuItems className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg ring-1 ring-black/5">
<MenuItem>
{({ focus }) => (
<a
href="/profile"
className={`block px-4 py-2 text-sm ${focus ? 'bg-indigo-50' : ''}`}
>
Profile
</a>
)}
</MenuItem>
<MenuItem>
{({ focus }) => (
<button
className={`block w-full text-left px-4 py-2 text-sm text-red-600 ${focus ? 'bg-red-50' : ''}`}
onClick={() => signOut()}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Menu>
)
}
Combobox (Autocomplete)
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/react'
import { useState } from 'react'
function CountryPicker({ countries }: { countries: string[] }) {
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(countries[0])
const filtered = query === ''
? countries
: countries.filter(c => c.toLowerCase().includes(query.toLowerCase()))
return (
<Combobox value={selected} onChange={setSelected}>
<ComboboxInput
onChange={(e) => setQuery(e.target.value)}
className="w-full border rounded-lg px-3 py-2"
/>
<ComboboxOptions className="absolute mt-1 w-full bg-white shadow-lg rounded-lg">
{filtered.map(country => (
<ComboboxOption key={country} value={country} className="px-3 py-2 cursor-pointer data-[focus]:bg-indigo-50">
{country}
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
)
}
TypeScript Tips
Use the as prop to render as your custom component types:
import { MenuItem } from '@headlessui/react'
import Link from 'next/link'
<MenuItem as={Link} href="/settings">
Settings
</MenuItem>
Common Gotchas
data-*attributes (v2) - v2 replaced render props withdata-headlessui-stateand standarddata-focus,data-selectedattributes. Use Tailwind'sdata-[focus]:variant syntax instead of the render prop pattern- Portal rendering -
MenuItems,ListboxOptions, etc. render inside the component tree by default (not a portal). For nested scroll contexts, wrap with<Float>from@headlessui-float/react
Related
- Radix UI - broader headless component set with more granular composability; not tied to Tailwind CSS
- shadcn/ui - pre-styled component library built on Radix UI; useful when you want less configuration
- CSS Container Queries Guide - Tailwind CSS and container queries work naturally together for adaptive component layouts
Dialog (Modal)
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'
import { useState } from 'react'
function ConfirmDialog() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Delete account</button>
<Dialog open={isOpen} onClose={() => setIsOpen(false)} className="relative z-50">
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
<DialogTitle className="text-lg font-semibold">Confirm deletion</DialogTitle>
<p className="mt-2 text-sm text-gray-500">This action cannot be undone.</p>
<div className="mt-4 flex gap-3">
<button onClick={() => setIsOpen(false)} className="flex-1 rounded-lg border px-4 py-2 text-sm">
Cancel
</button>
<button className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm text-white">
Delete
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</>
)
}
Dialog automatically traps focus, closes on Escape, and sets aria-modal. No additional ARIA attributes needed.
Migrating from v1 to v2
The main breaking change in v2 is the styling API. Render props are replaced by Tailwind data-attribute variants:
| v1 render prop | v2 data attribute |
|---|---|
{({ active }) => <li className={active ? 'bg-blue-50' : ''}>} | <li className="data-[focus]:bg-blue-50"> |
{({ selected }) => <span>{selected && <CheckIcon />}</span>} | <span className="hidden data-[selected]:block"> |
Vue support was dropped in v2 (React-only). The official v2 migration guide covers all changes. Use the anchor prop on MenuItems or ListboxOptions for automatic Floating UI positioning instead of manual absolute positioning.
Is Unstyled the Right Trade?
Headless UI hands you behaviour and no appearance: focus management, keyboard interaction, ARIA wiring, and portal positioning, with every visual decision left to you. That is either exactly what you want or a slow way to rebuild a component library, and which one depends on the project.
It pays off when the design is genuinely custom. If a designer has specified a menu that doesn't look like anyone else's menu, starting from a styled library means fighting its defaults for a week and still shipping something that looks fought-over. Starting from nothing means the only CSS in your bundle is CSS you wrote. The accessibility work, which is the part teams reliably get wrong when they hand-roll a listbox, is already done and tested.
It works against you when the design is ordinary. A standard admin panel with standard dropdowns does not benefit from styling twelve components by hand, and shadcn/ui or Radix with a preset theme will get you there in an afternoon. There's no prize for having typed the padding yourself.
One practical note on scope: the component list is deliberately short. There's no date picker, no data table, no toast. Check the roster covers what you need before committing to it as the project's UI foundation, because the gaps get filled by a second library and now you're theming two things.