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.