Headless UI - Accessible Component Primitives
Overview
Headless UI provides completely unstyled, fully accessible UI components designed to integrate beautifully with Tailwind CSS. Built by the Tailwind Labs team, it offers production-ready accessibility without imposing design decisions.
Key Features:
Fully unstyled - bring your own styles
Complete keyboard navigation
Screen reader tested
Focus management
ARIA attributes handled automatically
TypeScript support
React 18 and Vue 3 compatible
SSR compatible
Render props for maximum flexibility
Installation:
React
npm install @headlessui/react
Vue
npm install @headlessui/vue
Component Catalog
Menu (Dropdown)
Accessible dropdown menus with keyboard navigation and ARIA support.
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
function DropdownMenu() {
return (
Options
<MenuItems
transition
anchor="bottom end"
className="w-52 origin-top-right rounded-xl border border-white/5 bg-white/5 p-1 text-sm/6 text-white transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
Edit
</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
</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">
Delete
</button>
</MenuItem>
</MenuItems>
</Menu>
)
}
Menu Features:
Arrow key navigation
Type-ahead search
Automatic focus management
Escape to close
Click outside to close
Portal rendering for positioning
Anchor positioning API
Listbox (Select)
Custom select/dropdown component with full keyboard support.
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
import { useState } from 'react'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
]
function SelectExample() {
const [selected, setSelected] = useState(people[0])
return (
{selected.name}
<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 sm:text-sm">
{people.map((person) => (
<ListboxOption
key={person.id}
value={person}
className="relative cursor-default select-none py-2 pl-10 pr-4 data-[focus]:bg-amber-100 data-[focus]:text-amber-900"
>
{({ 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-amber-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
)
}
Listbox Features:
Single and multiple selection modes
Type-ahead search
Arrow key navigation
Controlled and uncontrolled modes
Disabled options support
Custom value comparison
Combobox (Autocomplete)
Searchable select component with filtering.
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/react'
import { useState } from 'react'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
]
function AutocompleteExample() {
const [selected, setSelected] = useState(people[0])
const [query, setQuery] = useState('')
const filtered =
query === ''
? people
: people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase())
)
return (
person?.name}
onChange={(event) => setQuery(event.target.value)}
/>
<ComboboxOptions className="w-[var(--input-width)] rounded-xl border border-white/5 bg-white/5 p-1 [--anchor-gap:var(--spacing-1)] empty:invisible">
{filtered.map((person) => (
<ComboboxOption
key={person.id}
value={person}
className="group flex cursor-default items-center gap-2 rounded-lg py-1.5 px-3 select-none data-[focus]:bg-white/10"
>
<CheckIcon className="invisible size-4 fill-white group-data-[selected]:visible" />
<div className="text-sm/6 text-white">{person.name}</div>
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
)
}
Combobox Features:
Text input with filtering
Keyboard navigation
Nullable/optional selections
Custom display values
Async data loading support
Multiple selection mode
Dialog (Modal)
Accessible modal dialogs with focus trapping.
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { Fragment, useState } from 'react'
function ModalExample() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
setIsOpen(true)}>Open dialog
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={() => setIsOpen(false)}>
<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/25" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<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-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6 text-gray-900">
Payment successful
</DialogTitle>
<div className="mt-2">
<p className="text-sm text-gray-500">
Your payment has been successfully submitted.
</p>
</div>
<div className="mt-4">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={() => setIsOpen(false)}
>
Got it, thanks!
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</>
)
}
Dialog Features:
Focus trapping
Escape to close
Scroll locking
Return focus on close
Portal rendering
Nested dialogs support
Initial focus control
Popover
Floating panels for tooltips, dropdowns, and more.
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
function PopoverExample() {
return (
Solutions
<PopoverPanel
transition
anchor="bottom"
className="divide-y divide-white/5 rounded-xl bg-white/5 text-sm/6 transition duration-200 ease-in-out [--anchor-gap:var(--spacing-5)] data-[closed]:-translate-y-1 data-[closed]:opacity-0"
>
<div className="p-3">
<a className="block rounded-lg py-2 px-3 transition hover:bg-white/5" href="#">
<p className="font-semibold text-white">Insights</p>
<p className="text-white/50">Measure actions your users take</p>
</a>
<a className="block rounded-lg py-2 px-3 transition hover:bg-white/5" href="#">
<p className="font-semibold text-white">Automations</p>
<p className="text-white/50">Create your own targeted content</p>
</a>
</div>
</PopoverPanel>
</Popover>
)
}
Popover Features:
Anchor positioning
Click or hover triggers
Close on click outside
Nested popovers
Focus management
Portal rendering
RadioGroup
Accessible radio button groups.
import { RadioGroup, RadioGroupOption, RadioGroupLabel } from '@headlessui/react'
import { useState } from 'react'
const plans = [
{ name: 'Startup', ram: '12GB', cpus: '6 CPUs', disk: '160 GB SSD disk' },
{ name: 'Business', ram: '16GB', cpus: '8 CPUs', disk: '512 GB SSD disk' },
{ name: 'Enterprise', ram: '32GB', cpus: '12 CPUs', disk: '1024 GB SSD disk' },
]
function RadioExample() {
const [selected, setSelected] = useState(plans[0])
return (
Server size
{plans.map((plan) => (
{plan.name}
{plan.ram}
·
{plan.cpus}
·
{plan.disk}
))}
)
}
RadioGroup Features:
Arrow key navigation
Disabled options
Custom styling states
Controlled mode
Description support
Switch (Toggle)
Accessible toggle switches.
import { Switch } from '@headlessui/react'
import { useState } from 'react'
function SwitchExample() {
const [enabled, setEnabled] = useState(false)
return (
)
}
Switch Features:
Controlled and uncontrolled
Label support
Description support
Disabled state
Keyboard accessible (Space to toggle)
Tab (Tabs)
Accessible tab navigation.
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
function TabExample() {
const categories = [
{
name: 'Recent',
posts: [
{ id: 1, title: 'Does drinking coffee make you smarter?' },
{ id: 2, title: "So you've bought coffee... now what?" },
],
},
{
name: 'Popular',
posts: [
{ id: 1, title: 'Is tech making coffee better or worse?' },
{ id: 2, title: 'The most innovative things happening in coffee' },
],
},
]
return (
{categories.map((category) => (
{category.name}
))}
{categories.map((category, idx) => (
{category.posts.map((post) => (
{post.title}
))}
))}
)
}
Tab Features:
Arrow key navigation
Default selected tab
Manual activation
Vertical/horizontal orientation
Controlled mode
Disclosure (Accordion)
Expandable content sections.
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import { ChevronUpIcon } from '@heroicons/react/20/solid'
function DisclosureExample() {
return (
{({ open }) => (
<>
What is your refund policy?
${open ? 'rotate-180 transform' : ''} h-5 w-5 text-purple-500}
/>
If you're unhappy with your purchase for any reason, email us within 90 days and we'll refund you in full, no questions asked.
</>
)}
)
}
Disclosure Features:
Controlled and uncontrolled
Default open state
Render props for state access
Multiple disclosures (accordion pattern)
Smooth animations with Transition
Transition
Animation component for enter/leave transitions.
import { Transition } from '@headlessui/react'
import { useState } from 'react'
function TransitionExample() {
const [isShowing, setIsShowing] = useState(false)
return (
<>
setIsShowing(!isShowing)}>Toggle
I will fade in and out
</>
)
}
Transition Features:
CSS class-based transitions
Enter/leave lifecycle
Nested transitions (child coordination)
Appears support (initial mount animation)
Works with React 18 concurrent mode
Advanced Patterns
Render Props Pattern
Access component state for custom rendering.
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
function RenderPropsExample() {
return (
{({ open }) => (
<>
Options {open ? '▲' : '▼'}
{({ selected, focus }) => (
{selected && '✓'} Option A
)}
</>
)}
)
}
Controlled Components
Full control over component state.
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
import { useState } from 'react'
function ControlledTabs() {
const [selectedIndex, setSelectedIndex] = useState(0)
return (
Tab 1
Tab 2
Tab 3
Content 1
Content 2
Content 3
setSelectedIndex(0)}>Reset to first tab
)
}
Portal Rendering
Render components outside DOM hierarchy.
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { createPortal } from 'react-dom'
function PortalMenu() {
return (
Options
{createPortal(
Edit
Delete
,
document.body
)}
)
}
Form Integration
Use with form libraries like React Hook Form.
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { useForm, Controller } from 'react-hook-form'
function FormExample() {
const { control, handleSubmit } = useForm()
const onSubmit = (data) => {
console.log(data)
}
return (
)
}
Vue Support
Headless UI works identically in Vue 3.
<script setup>
import { ref } from 'vue'
import {
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
} from '@headlessui/vue'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
]
const selectedPerson = ref(people[0])
</script>
{{ selectedPerson.name }}
{{ selected ? '✓' : '' }} {{ person.name }}
TypeScript Support
Full type safety with TypeScript.
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
interface User {
id: number
name: string
role: 'admin' | 'user'
}
interface UserMenuProps {
user: User
onEdit: (user: User) => void
onDelete: (userId: number) => void
}
function UserMenu({ user, onEdit, onDelete }: UserMenuProps) {
return (
{user.name}
{({ focus }) => (
onEdit(user)}
>
Edit
)}
{({ focus }) => (
onDelete(user.id)}
>
Delete
)}
)
}
Tailwind CSS Integration
Headless UI is designed for Tailwind CSS.
Data Attributes for States
Headless UI v2 uses data attributes for state styling.
// Modern approach with data attributes
Options
// Available states
// data-[active] - Element is active/focused
// data-[selected] - Element is selected
// data-[disabled] - Element is disabled
// data-[open] - Element/panel is open
// data-[focus] - Element has focus
// data-[checked] - Element is checked (Switch)
Tailwind Plugin
Configure Tailwind for Headless UI states.
// tailwind.config.js
module.exports = {
plugins: [
require('@headlessui/tailwindcss')
]
}
Now use modifiers:
Options
Accessibility Features
ARIA Attributes
All ARIA attributes managed automatically:
aria-expanded on disclosure buttons
aria-selected on tab/option elements
aria-checked on switches
aria-labelledby for associations
aria-describedby for descriptions
role attributes (menu, listbox, dialog, etc.)
Keyboard Navigation
Full keyboard support built-in:
Arrow keys: Navigate options/tabs
Enter/Space: Select/activate
Escape: Close menus/dialogs
Tab: Focus management
Home/End: First/last item (where applicable)
Type-ahead: Search by typing
Focus Management
Automatic focus handling:
Return focus on close (Dialog, Menu, Popover)
Focus trap in modals
Initial focus control
Skip to focused element on open
Screen Reader Support
Tested with:
NVDA (Windows)
JAWS (Windows)
VoiceOver (macOS, iOS)
TalkBack (Android)
Server-Side Rendering
Fully compatible with Next.js, Remix, and other SSR frameworks.
// app/page.tsx (Next.js 13+)
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
export default function Page() {
return (
Options
Edit
)
}
No special configuration needed - components work identically on server and client.
Best Practices
Always provide labels - Use sr-only classes for hidden labels
Style all states - Use data attributes for active, selected, disabled states
Test keyboard navigation - Verify Tab, arrows, Enter, Escape work
Use semantic HTML - Let components render as appropriate elements
Provide focus indicators - Always show focus states for keyboard users
Test with screen readers - Verify announcements are correct
Handle loading states - Show appropriate UI during async operations
Use controlled mode when needed - For complex state management
Combine with Transition - Add smooth animations to open/close
Portal overlays - Use portals for menus/dialogs to avoid z-index issues
Common Pitfalls
❌ Missing Tailwind classes for states:
// WRONG - no visual feedback
Options
// CORRECT
Options
❌ Not using Fragment for render props:
// WRONG - adds extra div
Content
// CORRECT
Content
❌ Forgetting to handle controlled state:
// WRONG - onChange does nothing
...
// CORRECT
...
Resources
Documentation: https://headlessui.com
GitHub: https://github.com/tailwindlabs/headlessui
Examples: https://headlessui.com/react/menu#examples
Tailwind UI: Premium components built with Headless UI
Summary
Headless UI provides unstyled, accessible component primitives
Zero runtime CSS - bring your own styles with Tailwind or custom CSS
Full accessibility - ARIA, keyboard navigation, screen reader support built-in
React and Vue - Identical APIs for both frameworks
TypeScript - Complete type definitions included
Render props - Access component state for custom rendering
SSR compatible - Works with Next.js, Remix, Nuxt
Perfect for - Custom design systems, Tailwind CSS integration, accessible components