Headless UI, unstyled components from Tailwind Labs
Headless UI is a set of interface components that supply behaviour and accessibility while supplying no appearance at all. A dialog knows how to trap focus and close on escape, a dropdown handles arrow keys and type ahead, but both render bare HTML you style yourself. It comes from Tailwind Labs, the team behind Tailwind CSS, under an MIT licence.
What "unstyled" means and why it makes sense
A classic component library hands you a finished look that you then override. That sounds convenient for the first week and turns into a fight with somebody else's selectors, won by whoever writes more exclamation marks into a stylesheet.
The unstyled approach reverses the division of labour. The library takes on what is simultaneously hard and dull, namely focus management, keyboard handling, role attributes, and screen reader announcements. You take on appearance, which is where you wanted the final say anyway.
The payoff shows best in one example. A correctly implemented dialog has to move focus inside on open, prevent it leaving by tab, hand it back to the triggering element on close, lock background scrolling, close on escape, and wire up its description references. That is several hundred lines nobody wants to write twice, and lines that are almost always written incompletely when written by hand under deadline.
The cost is that you get nothing visually for free. An empty component looks like unstyled HTML, so the first screen takes longer than in a library shipping a theme. It is an investment that repays on a bespoke design system and does not repay on an internal panel whose looks nobody cares about.
Project activity, something worth knowing
Before the code, one practical fact you will not find on the project's own site and that genuinely affects the decision.
The React version sits at 2.2.10, released on 7 April 2026. The most recent repository changes date from 13 April 2026, several months back. The project is not abandoned, but the release rhythm has clearly slowed compared with the period when version 2 was being built.
The more serious matter concerns Vue. That package stands at 1.7.23 and never received a version 2. It therefore lacks everything version 2 brought on the React side: built in floating element positioning, a checkbox component, form components, virtualisation for long lists. The team explained in a repository discussion that there are no concrete plans, since Tailwind CSS occupies their priorities.
The conclusion is simple and worth drawing before a project starts. On React this remains a sensible choice, particularly if you already use Tailwind. On Vue you are building on a version that will not be developed, so it is better to look elsewhere.
Headless UI against the alternatives
| Feature | Headless UI | Radix UI | Ark UI | React Aria |
|---|---|---|---|---|
| Supported frameworks | React, Vue with caveats | React | React, Vue, Solid, Svelte | React |
| Component count | a dozen or so | around thirty | around forty | over forty |
| Floating element positioning | built in since version 2 | built in | built in | a separate package |
| Development pace | clearly slowed | active | active | active |
| Licence | MIT | MIT | MIT | Apache 2.0 |
| Tailwind integration | best in class | good | good | good |
The decision hinges on two things. The first is the framework: if it is not React, Headless UI drops out or commits you to a frozen version. The second is the component set, where this library is noticeably thinner than the competition and lacks things a project will eventually want, a tooltip, a context menu, or a slider among them.
The advantage that still holds is the fit with Tailwind. State variants are expressed directly as conditional classes with no extra attributes of your own, and the documentation is written by people who assume you style with utility classes. On a project built on Tailwind CSS that removes considerable friction.
Installation and configuration
React
# npm
npm install @headlessui/react
# yarn
yarn add @headlessui/react
# pnpm
pnpm add @headlessui/reactVue 3
# npm
npm install @headlessui/vue
# yarn
yarn add @headlessui/vue
# pnpm
pnpm add @headlessui/vueBasic configuration with Tailwind CSS
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@headlessui/react/**/*.js',
],
theme: {
extend: {},
},
plugins: [],
}TypeScript support
Headless UI is written in TypeScript and ships with full type definitions:
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
interface MenuItem {
id: string
label: string
href: string
disabled?: boolean
}
interface DropdownProps {
items: MenuItem[]
label: string
}
function Dropdown({ items, label }: DropdownProps) {
return (
<Menu>
<MenuButton>{label}</MenuButton>
<MenuItems>
{items.map((item) => (
<MenuItem key={item.id} disabled={item.disabled}>
<a href={item.href}>{item.label}</a>
</MenuItem>
))}
</MenuItems>
</Menu>
)
}Render props vs data attributes
Headless UI offers two ways to style component states:
Render props (traditional)
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
function DropdownRenderProps() {
return (
<Menu>
{({ open }) => (
<>
<MenuButton className={open ? 'bg-blue-500' : 'bg-gray-500'}>
Options
</MenuButton>
<MenuItems>
<MenuItem>
{({ focus, disabled }) => (
<a
className={`
block px-4 py-2
${focus ? 'bg-blue-500 text-white' : 'text-gray-900'}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
href="/account"
>
Account
</a>
)}
</MenuItem>
</MenuItems>
</>
)}
</Menu>
)
}Data attributes (new - recommended)
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
function DropdownDataAttributes() {
return (
<Menu>
<MenuButton className="data-[open]:bg-blue-500 bg-gray-500 px-4 py-2 rounded">
Options
</MenuButton>
<MenuItems className="mt-2 w-56 rounded-lg bg-white shadow-lg p-1">
<MenuItem>
<a
className="block px-4 py-2 rounded data-[focus]:bg-blue-500 data-[focus]:text-white data-[disabled]:opacity-50"
href="/account"
>
Account
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}Data attributes work seamlessly with Tailwind CSS:
data-[open]- Menu is opendata-[focus]- Element has focusdata-[active]- Element is activedata-[selected]- Element is selecteddata-[disabled]- Element is disableddata-[checked]- Checkbox/Switch is checked
Menu (Dropdown) - Detailed guide
Menu is one of the most commonly used components. It supports full keyboard navigation and is accessible to screen readers.
Basic Menu
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
function BasicMenu() {
return (
<Menu as="div" className="relative inline-block text-left">
<MenuButton className="inline-flex items-center gap-2 rounded-md bg-gray-800 py-1.5 px-3 text-sm font-semibold text-white shadow-inner hover:bg-gray-700 focus:outline-none">
Options
<ChevronDownIcon className="h-5 w-5 fill-white/60" />
</MenuButton>
<MenuItems
anchor="bottom end"
className="w-52 origin-top-right rounded-xl border border-white/5 bg-gray-800 p-1 text-sm text-white shadow-lg focus:outline-none"
>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
Edit
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-[focus]:inline">
⌘E
</kbd>
</button>
</MenuItem>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
Duplicate
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-[focus]:inline">
⌘D
</kbd>
</button>
</MenuItem>
<div className="my-1 h-px bg-white/5" />
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
Archive
<kbd className="ml-auto hidden font-sans text-xs text-white/50 group-data-[focus]:inline">
⌘A
</kbd>
</button>
</MenuItem>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 text-red-400 data-[focus]:bg-red-500/20">
Delete
<kbd className="ml-auto hidden font-sans text-xs text-red-400/50 group-data-[focus]:inline">
⌘⌫
</kbd>
</button>
</MenuItem>
</MenuItems>
</Menu>
)
}Menu with links and router
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import Link from 'next/link'
function MenuWithLinks() {
const menuItems = [
{ href: '/profile', label: 'Profile' },
{ href: '/settings', label: 'Settings' },
{ href: '/billing', label: 'Billing' },
{ href: '/team', label: 'Team' },
]
return (
<Menu>
<MenuButton className="flex items-center gap-2 rounded-full border-2 border-white/20 p-2 hover:border-white/40">
<img
src="/avatar.jpg"
alt="User avatar"
className="h-8 w-8 rounded-full"
/>
</MenuButton>
<MenuItems
anchor="bottom end"
className="w-48 rounded-xl bg-white shadow-lg ring-1 ring-black/5 p-1"
>
{menuItems.map((item) => (
<MenuItem key={item.href}>
<Link
href={item.href}
className="block rounded-lg px-3 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100"
>
{item.label}
</Link>
</MenuItem>
))}
<div className="my-1 h-px bg-gray-100" />
<MenuItem>
<button
onClick={() => signOut()}
className="flex w-full rounded-lg px-3 py-2 text-sm text-red-600 data-[focus]:bg-red-50"
>
Sign out
</button>
</MenuItem>
</MenuItems>
</Menu>
)
}Menu with groups
import { Menu, MenuButton, MenuItems, MenuItem, MenuSection, MenuHeading, MenuSeparator } from '@headlessui/react'
function GroupedMenu() {
return (
<Menu>
<MenuButton className="px-4 py-2 bg-blue-500 text-white rounded-lg">
Actions
</MenuButton>
<MenuItems className="w-64 bg-white rounded-xl shadow-lg p-2">
<MenuSection>
<MenuHeading className="px-3 py-1 text-xs font-semibold text-gray-400 uppercase">
Edit
</MenuHeading>
<MenuItem>
<button className="w-full text-left px-3 py-2 rounded-lg data-[focus]:bg-gray-100">
Undo
</button>
</MenuItem>
<MenuItem>
<button className="w-full text-left px-3 py-2 rounded-lg data-[focus]:bg-gray-100">
Redo
</button>
</MenuItem>
</MenuSection>
<MenuSeparator className="my-1 h-px bg-gray-200" />
<MenuSection>
<MenuHeading className="px-3 py-1 text-xs font-semibold text-gray-400 uppercase">
Selection
</MenuHeading>
<MenuItem>
<button className="w-full text-left px-3 py-2 rounded-lg data-[focus]:bg-gray-100">
Cut
</button>
</MenuItem>
<MenuItem>
<button className="w-full text-left px-3 py-2 rounded-lg data-[focus]:bg-gray-100">
Copy
</button>
</MenuItem>
<MenuItem>
<button className="w-full text-left px-3 py-2 rounded-lg data-[focus]:bg-gray-100">
Paste
</button>
</MenuItem>
</MenuSection>
</MenuItems>
</Menu>
)
}Dialog (Modal) - Complete guide
Dialog is a modal component with full focus management and accessibility.
Basic Dialog
import { useState } from 'react'
import { Dialog, DialogPanel, DialogTitle, DialogBackdrop } from '@headlessui/react'
function BasicDialog() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button
onClick={() => setIsOpen(true)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Open Dialog
</button>
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
{/* Backdrop */}
<DialogBackdrop className="fixed inset-0 bg-black/30" />
{/* Container for centering */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<DialogTitle className="text-lg font-bold text-gray-900">
Deactivate Account
</DialogTitle>
<p className="mt-2 text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your data
will be permanently removed. This action cannot be undone.
</p>
<div className="mt-4 flex gap-3 justify-end">
<button
onClick={() => setIsOpen(false)}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={() => {
setIsOpen(false)
}}
className="px-4 py-2 text-sm bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Deactivate
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</>
)
}Dialog with animations
import { useState, Fragment } from 'react'
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
function AnimatedDialog() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button
onClick={() => setIsOpen(true)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
>
Open Modal
</button>
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => setIsOpen(false)} className="relative z-50">
{/* Animated backdrop */}
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50" />
</TransitionChild>
<div className="fixed inset-0 flex items-center justify-center p-4">
{/* Animated panel */}
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
<DialogTitle className="text-xl font-semibold">
Payment successful
</DialogTitle>
<p className="mt-2 text-gray-600">
Your payment has been successfully submitted. We've sent you
an email with all of the details of your order.
</p>
<button
onClick={() => setIsOpen(false)}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg w-full"
>
Got it, thanks!
</button>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</>
)
}Dialog with a form
import { useState } from 'react'
import { Dialog, DialogPanel, DialogTitle, Field, Label, Input, Description } from '@headlessui/react'
function DialogWithForm() {
const [isOpen, setIsOpen] = useState(false)
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log({ name, email })
setIsOpen(false)
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="px-4 py-2 bg-green-500 text-white rounded-lg"
>
Subscribe to Newsletter
</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-md rounded-xl bg-white p-6 shadow-xl">
<DialogTitle className="text-lg font-bold">
Subscribe to our newsletter
</DialogTitle>
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<Field>
<Label className="block text-sm font-medium text-gray-700">
Name
</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 data-[focus]:ring-2"
required
/>
</Field>
<Field>
<Label className="block text-sm font-medium text-gray-700">
Email
</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required
/>
<Description className="mt-1 text-sm text-gray-500">
We'll never share your email with anyone else.
</Description>
</Field>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={() => setIsOpen(false)}
className="px-4 py-2 text-gray-600"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-500 text-white rounded-lg"
>
Subscribe
</button>
</div>
</form>
</DialogPanel>
</div>
</Dialog>
</>
)
}Listbox (Select) - Custom select
Listbox is an accessible alternative to the native <select>, giving you full control over the appearance.
Basic Listbox
import { useState } from 'react'
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox' },
]
function BasicListbox() {
const [selected, setSelected] = useState(people[0])
return (
<Listbox value={selected} onChange={setSelected}>
<div className="relative w-72">
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-blue-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-300">
<span className="block truncate">{selected.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" />
</span>
</ListboxButton>
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none">
{people.map((person) => (
<ListboxOption
key={person.id}
value={person}
className="relative cursor-pointer select-none py-2 pl-10 pr-4 data-[focus]:bg-blue-100 data-[selected]:bg-blue-50"
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{person.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
)
}Listbox with multiple selection
import { useState } from 'react'
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
const frameworks = [
{ id: 1, name: 'React' },
{ id: 2, name: 'Vue' },
{ id: 3, name: 'Angular' },
{ id: 4, name: 'Svelte' },
{ id: 5, name: 'Solid' },
]
function MultipleListbox() {
const [selectedFrameworks, setSelectedFrameworks] = useState([frameworks[0]])
return (
<Listbox value={selectedFrameworks} onChange={setSelectedFrameworks} multiple>
<div className="relative w-72">
<ListboxButton className="w-full rounded-lg bg-white py-2 px-3 text-left shadow-md">
{selectedFrameworks.length === 0
? 'Select frameworks'
: selectedFrameworks.map((f) => f.name).join(', ')}
</ListboxButton>
<ListboxOptions className="absolute mt-1 w-full rounded-md bg-white shadow-lg">
{frameworks.map((framework) => (
<ListboxOption
key={framework.id}
value={framework}
className="cursor-pointer px-4 py-2 data-[focus]:bg-blue-100 data-[selected]:bg-blue-500 data-[selected]:text-white"
>
{framework.name}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
)
}Combobox - Autocomplete select
Combobox combines a text input with a dropdown list - perfect for search and autocomplete scenarios.
import { useState } from 'react'
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption, ComboboxButton } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
const countries = [
{ id: 1, name: 'Poland', code: 'PL' },
{ id: 2, name: 'Germany', code: 'DE' },
{ id: 3, name: 'France', code: 'FR' },
{ id: 4, name: 'United Kingdom', code: 'GB' },
{ id: 5, name: 'United States', code: 'US' },
{ id: 6, name: 'Canada', code: 'CA' },
{ id: 7, name: 'Australia', code: 'AU' },
{ id: 8, name: 'Japan', code: 'JP' },
]
function CountryCombobox() {
const [selected, setSelected] = useState(countries[0])
const [query, setQuery] = useState('')
const filteredCountries =
query === ''
? countries
: countries.filter((country) =>
country.name
.toLowerCase()
.replace(/\s+/g, '')
.includes(query.toLowerCase().replace(/\s+/g, ''))
)
return (
<Combobox value={selected} onChange={setSelected}>
<div className="relative w-72">
<div className="relative">
<ComboboxInput
className="w-full rounded-lg border border-gray-300 py-2 pl-3 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
displayValue={(country: typeof countries[0]) => country?.name}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search countries..."
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" />
</ComboboxButton>
</div>
<ComboboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5">
{filteredCountries.length === 0 && query !== '' ? (
<div className="px-4 py-2 text-gray-500">No countries found.</div>
) : (
filteredCountries.map((country) => (
<ComboboxOption
key={country.id}
value={country}
className="relative cursor-pointer select-none py-2 pl-10 pr-4 data-[focus]:bg-blue-100"
>
{({ selected }) => (
<>
<div className="flex items-center gap-2">
<span className="text-lg">{getFlagEmoji(country.code)}</span>
<span className={selected ? 'font-medium' : 'font-normal'}>
{country.name}
</span>
</div>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ComboboxOption>
))
)}
</ComboboxOptions>
</div>
</Combobox>
)
}
function getFlagEmoji(countryCode: string) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map((char) => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}Switch (Toggle) - Toggle switch
import { useState } from 'react'
import { Switch, Field, Label, Description } from '@headlessui/react'
function ToggleSwitch() {
const [enabled, setEnabled] = useState(false)
return (
<Field className="flex items-center justify-between p-4 bg-white rounded-lg shadow">
<div>
<Label className="font-medium text-gray-900">
Enable notifications
</Label>
<Description className="text-sm text-gray-500">
Receive email notifications about updates
</Description>
</div>
<Switch
checked={enabled}
onChange={setEnabled}
className="group relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 data-[checked]:bg-blue-600"
>
<span className="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-1 group-data-[checked]:translate-x-6" />
</Switch>
</Field>
)
}Switch with icons
import { useState } from 'react'
import { Switch } from '@headlessui/react'
import { SunIcon, MoonIcon } from '@heroicons/react/24/solid'
function ThemeToggle() {
const [darkMode, setDarkMode] = useState(false)
return (
<Switch
checked={darkMode}
onChange={setDarkMode}
className="group relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 transition-colors data-[checked]:bg-gray-800"
>
<span className="sr-only">Toggle dark mode</span>
<span className="absolute left-1 text-yellow-500 transition-opacity group-data-[checked]:opacity-0">
<SunIcon className="h-5 w-5" />
</span>
<span className="absolute right-1 text-blue-300 opacity-0 transition-opacity group-data-[checked]:opacity-100">
<MoonIcon className="h-5 w-5" />
</span>
<span className="inline-block h-6 w-6 transform rounded-full bg-white shadow transition-transform translate-x-1 group-data-[checked]:translate-x-7" />
</Switch>
)
}Tabs - Tabbed interfaces
import { Tab, TabGroup, TabList, TabPanels, TabPanel } from '@headlessui/react'
function TabsExample() {
const categories = {
Recent: [
{ id: 1, title: 'Does drinking coffee make you smarter?', date: '5h ago' },
{ id: 2, title: 'So you have bought coffee... now what?', date: '2h ago' },
],
Popular: [
{ id: 1, title: 'Is tech making coffee better or worse?', date: '1d ago' },
{ id: 2, title: 'The most innovative coffee brewing methods', date: '2d ago' },
],
Trending: [
{ id: 1, title: 'Ask Me Anything: coffee brewing tips', date: '12h ago' },
{ id: 2, title: 'The worst advice you can give a coffee lover', date: '4h ago' },
],
}
return (
<div className="w-full max-w-md">
<TabGroup>
<TabList className="flex space-x-1 rounded-xl bg-blue-900/20 p-1">
{Object.keys(categories).map((category) => (
<Tab
key={category}
className="w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-blue-700 ring-white/60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2 data-[selected]:bg-white data-[selected]:shadow data-[hover]:bg-white/[0.12]"
>
{category}
</Tab>
))}
</TabList>
<TabPanels className="mt-2">
{Object.values(categories).map((posts, idx) => (
<TabPanel
key={idx}
className="rounded-xl bg-white p-3 ring-white/60 ring-offset-2 ring-offset-blue-400 focus:outline-none focus:ring-2"
>
<ul>
{posts.map((post) => (
<li
key={post.id}
className="relative rounded-md p-3 hover:bg-gray-100"
>
<h3 className="text-sm font-medium leading-5">{post.title}</h3>
<p className="mt-1 text-xs text-gray-500">{post.date}</p>
</li>
))}
</ul>
</TabPanel>
))}
</TabPanels>
</TabGroup>
</div>
)
}Disclosure - Accordion
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
const faqs = [
{
question: 'What is your refund policy?',
answer: 'If you are unhappy with your purchase for any reason, email us within 90 days and we will refund you in full, no questions asked.',
},
{
question: 'Do you offer technical support?',
answer: 'Yes! We offer 24/7 technical support via email and chat. Premium customers also get phone support.',
},
{
question: 'What payment methods do you accept?',
answer: 'We accept all major credit cards, PayPal, and bank transfers for enterprise customers.',
},
]
function FAQ() {
return (
<div className="w-full max-w-md space-y-2">
{faqs.map((faq, index) => (
<Disclosure key={index}>
<DisclosureButton className="flex w-full justify-between rounded-lg bg-blue-100 px-4 py-2 text-left text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring focus-visible:ring-blue-500/75">
<span>{faq.question}</span>
<ChevronDownIcon className="h-5 w-5 text-blue-500 ui-open:rotate-180 transform transition-transform" />
</DisclosureButton>
<DisclosurePanel className="px-4 pb-2 pt-4 text-sm text-gray-500">
{faq.answer}
</DisclosurePanel>
</Disclosure>
))}
</div>
)
}Popover - Tooltip on steroids
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
function PopoverExample() {
return (
<Popover className="relative">
<PopoverButton className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-white">
Solutions
<ChevronDownIcon className="h-5 w-5" />
</PopoverButton>
<PopoverPanel
anchor="bottom"
className="absolute z-10 mt-2 w-80 rounded-xl bg-white shadow-lg ring-1 ring-black/5 p-4"
>
<div className="space-y-4">
<a href="/analytics" className="block rounded-lg p-3 hover:bg-gray-50">
<p className="font-semibold text-gray-900">Analytics</p>
<p className="text-sm text-gray-500">
Get a better understanding of your traffic
</p>
</a>
<a href="/engagement" className="block rounded-lg p-3 hover:bg-gray-50">
<p className="font-semibold text-gray-900">Engagement</p>
<p className="text-sm text-gray-500">
Speak directly to your customers
</p>
</a>
<a href="/security" className="block rounded-lg p-3 hover:bg-gray-50">
<p className="font-semibold text-gray-900">Security</p>
<p className="text-sm text-gray-500">
Your customers' data will be safe
</p>
</a>
</div>
</PopoverPanel>
</Popover>
)
}Radio Group - Radio button group
import { useState } from 'react'
import { RadioGroup, Radio, Label, Description, Field } from '@headlessui/react'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
const plans = [
{ name: 'Startup', ram: '12GB', cpus: '6 CPUs', disk: '160 GB SSD', price: '$40' },
{ name: 'Business', ram: '16GB', cpus: '8 CPUs', disk: '512 GB SSD', price: '$80' },
{ name: 'Enterprise', ram: '32GB', cpus: '12 CPUs', disk: '1024 GB SSD', price: '$160' },
]
function PlanSelector() {
const [selected, setSelected] = useState(plans[0])
return (
<RadioGroup value={selected} onChange={setSelected} className="space-y-2">
<Label className="sr-only">Server size</Label>
{plans.map((plan) => (
<Field key={plan.name}>
<Radio
value={plan}
className="group relative flex cursor-pointer rounded-lg bg-white px-5 py-4 shadow-md focus:outline-none data-[checked]:bg-blue-500/10 data-[checked]:ring-2 data-[checked]:ring-blue-500"
>
<div className="flex w-full items-center justify-between">
<div>
<Label className="font-semibold text-gray-900 group-data-[checked]:text-blue-900">
{plan.name}
</Label>
<Description className="text-sm text-gray-500 group-data-[checked]:text-blue-700">
{plan.ram} / {plan.cpus} / {plan.disk}
</Description>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-gray-900">{plan.price}</span>
<CheckCircleIcon className="h-6 w-6 text-blue-500 opacity-0 group-data-[checked]:opacity-100" />
</div>
</div>
</Radio>
</Field>
))}
</RadioGroup>
)
}Transition - Animations
Headless UI provides a Transition component for smooth animations.
import { useState, Fragment } from 'react'
import { Transition } from '@headlessui/react'
function NotificationTransition() {
const [isShowing, setIsShowing] = useState(true)
return (
<div className="flex flex-col items-center py-16">
<button
onClick={() => setIsShowing(!isShowing)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
>
Toggle Notification
</button>
<Transition
show={isShowing}
enter="transition-all duration-300 ease-out"
enterFrom="opacity-0 scale-95 translate-y-4"
enterTo="opacity-100 scale-100 translate-y-0"
leave="transition-all duration-200 ease-in"
leaveFrom="opacity-100 scale-100 translate-y-0"
leaveTo="opacity-0 scale-95 translate-y-4"
>
<div className="mt-4 p-4 bg-green-100 border border-green-500 rounded-lg">
<p className="text-green-800">
Your changes have been saved successfully!
</p>
</div>
</Transition>
</div>
)
}Integration with React Hook Form
import { useForm, Controller } from 'react-hook-form'
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, Switch } from '@headlessui/react'
const countries = [
{ id: 1, name: 'Poland' },
{ id: 2, name: 'Germany' },
{ id: 3, name: 'France' },
]
interface FormData {
country: typeof countries[0]
newsletter: boolean
}
function FormWithHeadlessUI() {
const { control, handleSubmit } = useForm<FormData>({
defaultValues: {
country: countries[0],
newsletter: false,
},
})
const onSubmit = (data: FormData) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Country</label>
<Controller
control={control}
name="country"
render={({ field }) => (
<Listbox value={field.value} onChange={field.onChange}>
<ListboxButton className="w-full px-4 py-2 border rounded-lg text-left">
{field.value.name}
</ListboxButton>
<ListboxOptions className="mt-1 border rounded-lg bg-white shadow-lg">
{countries.map((country) => (
<ListboxOption
key={country.id}
value={country}
className="px-4 py-2 cursor-pointer data-[focus]:bg-blue-100"
>
{country.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
)}
/>
</div>
<div className="flex items-center gap-3">
<Controller
control={control}
name="newsletter"
render={({ field }) => (
<Switch
checked={field.value}
onChange={field.onChange}
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 data-[checked]:bg-blue-600"
>
<span className="inline-block h-4 w-4 transform rounded-full bg-white transition translate-x-1 data-[checked]:translate-x-6" />
</Switch>
)}
/>
<label className="text-sm">Subscribe to newsletter</label>
</div>
<button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded-lg">
Submit
</button>
</form>
)
}Vue.js integration
<template>
<Menu as="div" class="relative">
<MenuButton class="px-4 py-2 bg-blue-500 text-white rounded-lg">
Options
</MenuButton>
<MenuItems class="absolute mt-2 w-56 bg-white rounded-lg shadow-lg p-1">
<MenuItem v-slot="{ active }">
<a
:class="[active ? 'bg-blue-100' : '', 'block px-4 py-2 rounded']"
href="/account"
>
Account
</a>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
:class="[active ? 'bg-blue-100' : '', 'block px-4 py-2 rounded']"
href="/settings"
>
Settings
</a>
</MenuItem>
</MenuItems>
</Menu>
</template>
<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
</script>Best practices
1. Always use semantic HTML
<MenuButton>Options</MenuButton>
<div onClick={openMenu}>Options</div>The first example is correct - MenuButton renders a proper <button> element. The second example is wrong - a div lacks the proper semantics for interactive elements.
2. Customize with the as prop
<MenuItem as="a" href="/profile">
Profile
</MenuItem>
<MenuItem as={Link} href="/profile">
Profile
</MenuItem>
<MenuItem as={Fragment}>
<MyCustomItem />
</MenuItem>The as prop lets you control the underlying HTML element or component that gets rendered. You can render as a link, a Next.js Link, or even a custom component.
3. Accessibility is automatic
Headless UI automatically adds:
- ARIA attributes
- Roles
- Keyboard navigation
- Focus management
- Screen reader announcements
4. Use data attributes instead of render props
<MenuItem className="data-[focus]:bg-blue-100">
<a href="/profile">Profile</a>
</MenuItem>
<MenuItem>
{({ focus }) => (
<a className={focus ? 'bg-blue-100' : ''} href="/profile">
Profile
</a>
)}
</MenuItem>The first approach using data attributes is the newer, cleaner API. The second approach using render props is the older API that still works but is more verbose.
FAQ - Frequently asked questions
Does Headless UI work with Next.js App Router?
Yes! Headless UI works with both Pages Router and App Router. Just remember to add 'use client' for interactive components.
How to combine Headless UI with Framer Motion?
You can use Framer Motion instead of the built-in Transition:
import { motion, AnimatePresence } from 'framer-motion'
<Menu>
<MenuButton>Options</MenuButton>
<AnimatePresence>
{open && (
<MenuItems
as={motion.div}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
static
>
{/* items */}
</MenuItems>
)}
</AnimatePresence>
</Menu>Can I use Headless UI without Tailwind CSS?
Yes, the library assumes no particular styling method. It works with CSS modules, styled components, a plain stylesheet, or a preprocessor. The Tailwind fit is a convenience, not a requirement.
Does Headless UI support server rendering?
Yes, the components render correctly on the server. In Next.js remember the client component directive, since all interactivity needs a browser.
Is it worth starting a new project on Headless UI?
On React and Tailwind, yes, with the caveat that the component set is narrow and you will write some things yourself. On Vue, no, because that package sits at 1.7.23 and never received a version 2 equivalent.
What version 2 brought
It is worth knowing what changed, because older tutorials describe patterns that still work but are no longer recommended.
The largest change concerns how you style states. Previously a component passed state information through a render function, so you wrote expressions returning classes depending on whether an item was active or selected. Now states land on the element as data attributes and conditional classes are written directly in Tailwind. The code gets shorter and clearer, and the difference shows most on lists, where every item used to carry its own function.
The second change is built in floating element positioning. Menus and dropdowns place themselves relative to their button, accounting for viewport edges, with no extra library. That used to be the most common reason people abandoned this library for the competition.
The third is transitions without a separate wrapper component. Enter and exit animation is enabled by a prop on the element itself, removing a nesting level that confused many people.
Components that had been missing arrived too: a checkbox, form elements, and virtualisation for long lists in the autocomplete field, so a list of several thousand options no longer kills the browser.
Where the library's responsibility ends
This distinction is worth understanding, because it decides whether your interface is actually accessible or merely assembled from accessible parts.
The library is responsible for the behaviour of an individual component. It guarantees that a dropdown carries correct roles, that arrow keys move the selection, that focus returns where it should, and that a screen reader announces state changes. That is the layer where mistakes are easiest to make and hardest to spot without knowing the specification.
Everything else is on you, and it amounts to more than people usually assume. Source order determines focus order, so a layout built on visually reordering elements can read very differently from how it looks. Text contrast against the background is your colour choice, not the library's concern. Form labels, error messages, and the association between them also sit on your side, since the library does not know what your fields mean.
There is one more thing that unstyled components make easy to forget: focus visibility. Since you decide the appearance, you have to ensure a focused element is distinguishable from the rest. The browser's default outline disappears when you reset styles and returns only when you deliberately bring it back.
A practical test takes a few minutes and catches most problems. Put the mouse aside, move through the screen with the tab key alone, and check three things: whether you can always see where you are, whether the order makes sense, and whether escape closes anything you opened. Meet those three conditions and you are ahead of most deployments.
Common mistakes
The first is expecting a component to look sensible immediately. An unstyled element looks broken and regularly leads people to conclude the library does not work. That is intended behaviour rather than a defect.
The second is losing accessibility to your own styles. Removing the focus outline because "it looks ugly" undoes half the work the library does for you. If the default outline clashes with the design, replace it with your own indicator rather than with nothing.
The third is reaching for a hand rolled solution at the first obstacle. When a component lacks a feature you need, writing your own dialog in thirty minutes is tempting. Those thirty minutes do not include a focus trap, keyboard handling, or screen reader behaviour, so the real cost is many times higher and shows up later.
The fourth is mixing this library with another behaviour layer in one project. Two systems managing focus can collide on nested dialogs, and diagnosing that takes longer than standardising from the start.
The fifth is relying on a component the library does not have. The set is narrow and lacks a tooltip, a slider, and a context menu among others. Check the component list before deciding, not halfway through a project.
The source and releases live in the Headless UI repository, and full component documentation is on the project site.