# Headless UI Headless UI is a collection of completely unstyled, fully accessible UI components for React and Vue, designed to integrate seamlessly with Tailwind CSS. The library provides essential interactive components like menus, dialogs, listboxes, comboboxes, and more, implementing WAI-ARIA design patterns for accessibility. Components are "headless" meaning they provide functionality and accessibility without imposing any styling, giving developers complete control over the visual presentation while handling complex state management, keyboard navigation, focus management, and screen reader support automatically. The library is distributed as three npm packages: `@headlessui/react` for React applications (currently v2.2.9), `@headlessui/vue` for Vue 3 applications, and `@headlessui/tailwindcss` as a Tailwind CSS plugin that provides utility variants for styling components based on their internal state. All components support both controlled and uncontrolled modes, work seamlessly with form submissions, and offer flexible APIs through render props, allowing developers to access internal component state for conditional rendering and dynamic styling. The components handle edge cases like scroll locking, focus trapping, click-outside detection, and proper cleanup automatically. ## Installation ### React Installation ```bash npm install @headlessui/react ``` ```jsx import { Menu, Dialog, Listbox } from '@headlessui/react' function App() { return ( Options {({ active }) => Account} ) } ``` ### Vue Installation ```bash npm install @headlessui/vue ``` ```vue ``` ### Tailwind CSS Plugin Installation ```bash npm install @headlessui/tailwindcss ``` ```js // tailwind.config.js module.exports = { plugins: [ require('@headlessui/tailwindcss'), // Or with custom prefix require('@headlessui/tailwindcss')({ prefix: 'ui' }) ] } ``` ## Button Component Accessible button with state management for hover, focus, active, and disabled states ```jsx import { Button } from '@headlessui/react' function ActionButton() { return ( ) } // With render prop for state-based styling function StatefulButton() { return ( ) } // Button types function FormButtons() { return (
) } ``` ## Checkbox Component Accessible checkbox with indeterminate state support and form integration ```jsx import { Checkbox, Field, Label, Description } from '@headlessui/react' import { useState } from 'react' function TermsCheckbox() { const [enabled, setEnabled] = useState(false) return ( ) } // Indeterminate checkbox function SelectAllCheckbox() { const [selectedItems, setSelectedItems] = useState([]) const items = ['Item 1', 'Item 2', 'Item 3'] const allSelected = selectedItems.length === items.length const someSelected = selectedItems.length > 0 && !allSelected return (
setSelectedItems(checked ? items : [])} className="size-6 rounded border border-gray-300" > {({ checked, indeterminate }) => ( <> {indeterminate ? '−' : checked ? '✓' : ''} )} {items.map((item) => ( { setSelectedItems(checked ? [...selectedItems, item] : selectedItems.filter(i => i !== item) ) }} > {item} ))}
) } // Uncontrolled checkbox with form integration function NewsletterForm() { return (
{ e.preventDefault() const formData = new FormData(e.currentTarget) console.log('Subscribe:', formData.get('newsletter')) // Output: "on" if checked, null if unchecked }}> {({ checked }) => ( {checked && '✓'} )}
) } ``` ## Field, Fieldset, Label, Description, and Legend Components Form layout components for accessible form field grouping and labeling ```jsx import { Field, Fieldset, Label, Description, Legend, Input, Checkbox } from '@headlessui/react' function UserProfileForm() { return (
{/* Single field with label and description */} We'll never share your email with anyone else. {/* Fieldset for grouping related fields */}
Notification Preferences Standard message rates may apply
{/* Disabled fieldset */}
Premium Features (Upgrade Required)
) } // Field with render prop for state access function DynamicField() { return ( {({ disabled }) => ( <> )} ) } ``` ## Input Component Accessible text input with automatic label and description association ```jsx import { Input, Field, Label, Description } from '@headlessui/react' import { useState } from 'react' function EmailInput() { const [email, setEmail] = useState('') return ( setEmail(e.target.value)} className="block w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" placeholder="you@example.com" /> ) } // Input with validation state function ValidatedInput() { const [value, setValue] = useState('') const isInvalid = value.length > 0 && value.length < 3 return ( setValue(e.target.value)} invalid={isInvalid} className="data-[invalid]:border-red-500 data-[focus]:ring-2" /> {isInvalid && ( Username must be at least 3 characters )} ) } // Input with render prop for state-based styling function StateBasedInput() { return ( {({ focus, hover, disabled, invalid }) => ( )} ) } ``` ## Select Component Native select element with accessibility features and state management ```jsx import { Select, Field, Label } from '@headlessui/react' import { useState } from 'react' function CountrySelect() { const [country, setCountry] = useState('us') return ( ) } // Select with render prop function StyledSelect() { return ( )} ) } // Invalid state handling function FormSelect() { const [value, setValue] = useState('') const isInvalid = value === '' return ( ) } ``` ## Textarea Component Multi-line text input with automatic resizing and accessibility ```jsx import { Textarea, Field, Label, Description } from '@headlessui/react' import { useState } from 'react' function CommentTextarea() { const [comment, setComment] = useState('') return ( Share your thoughts with the community ) } ``` ## DataInteractive Component Utility component for tracking interactive states (hover, focus, active) on any element ```jsx import { DataInteractive } from '@headlessui/react' function InteractiveCard() { return ( {({ hover, focus, active }) => (

Interactive Card

Hover, focus, or click to see state changes

Hover: {hover ? '✓' : '✗'} |{' '} Focus: {focus ? '✓' : '✗'} |{' '} Active: {active ? '✓' : '✗'}
)}
) } // Custom interactive button with state indicators function StateAwareButton() { return ( {({ hover, focus, active }) => ( )} ) } // Interactive list item with custom styling function InteractiveListItem({ title, description }) { return ( {({ hover, focus, active }) => (

{title}

{description}

)}
) } ``` ## CloseButton Component Utility component for closing parent Dialog, Popover, or other closable components ```jsx import { Dialog, CloseButton } from '@headlessui/react' import { useState } from 'react' function DialogWithCloseButton() { const [isOpen, setIsOpen] = useState(false) return ( <> setIsOpen(false)}>
Settings
Manage your account settings and preferences.
Cancel
) } // CloseButton in Popover function PopoverWithClose() { return ( Open

Notification

You have new messages

) } // Using useClose hook for programmatic closing import { useClose } from '@headlessui/react' function CustomCloseButton() { let close = useClose() return ( ) } ``` ## Menu Component Dropdown menu with keyboard navigation, focus management, and ARIA attributes ```jsx import { Menu } from '@headlessui/react' function MyMenu() { return ( {({ open, close }) => ( <> Actions {open ? '▲' : '▼'} {({ active, disabled }) => ( Account settings )} Disabled item {({ close }) => ( )} )} ) } ``` ## Dialog Component Modal overlay with focus trap, scroll locking, and backdrop click-to-close ```jsx import { Dialog } from '@headlessui/react' import { useState } from 'react' function DeleteAccountDialog() { const [isOpen, setIsOpen] = useState(false) return ( <> setIsOpen(false)} className="relative z-50" > {/* Backdrop */} {/* Full-screen container to center the panel */}
Delete Account This will permanently delete your account and all associated data. This action cannot be undone.
) } ``` ## Listbox Component Accessible select dropdown with keyboard navigation and custom value comparison ```jsx import { Listbox } from '@headlessui/react' import { useState } from 'react' function TeamSelector() { const people = [ { id: 1, name: 'Alice Johnson', role: 'Designer', avatar: 'AJ' }, { id: 2, name: 'Bob Smith', role: 'Developer', avatar: 'BS' }, { id: 3, name: 'Charlie Brown', role: 'Manager', avatar: 'CB' }, ] const [selected, setSelected] = useState(people[1]) return (
{({ open }) => ( <> Assign to
{selected.avatar} {selected.name} {open ? '▲' : '▼'} {people.map((person) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ active ? 'bg-blue-100 text-blue-900' : 'text-gray-900' } ${selected ? 'font-semibold' : 'font-normal'}` } > {({ selected }) => ( <>
{person.avatar} {person.name} {person.role}
{selected && ( )} )}
))}
)}
) } // Multiple selection mode function MultiSelectListbox() { const [selectedPeople, setSelectedPeople] = useState([]) return ( {selectedPeople.length === 0 ? 'Select people' : `${selectedPeople.length} selected`} Alice Bob Charlie ) } ``` ## Combobox Component Autocomplete input with filtering and keyboard navigation ```jsx import { Combobox } from '@headlessui/react' import { useState } from 'react' function UserSearch() { const people = [ { id: 1, name: 'Alice Johnson', email: 'alice@example.com' }, { id: 2, name: 'Bob Smith', email: 'bob@example.com' }, { id: 3, name: 'Charlie Brown', email: 'charlie@example.com' }, { id: 4, name: 'Diana Prince', email: 'diana@example.com' }, ] const [selected, setSelected] = useState(null) const [query, setQuery] = useState('') const filteredPeople = query === '' ? people : people.filter(person => person.name.toLowerCase().includes(query.toLowerCase()) || person.email.toLowerCase().includes(query.toLowerCase()) ) return (
Search user
person?.name || ''} onChange={(e) => setQuery(e.target.value)} placeholder="Type to search..." /> {filteredPeople.length === 0 && query !== '' ? (
No people found.
) : ( filteredPeople.map((person) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ active ? 'bg-blue-500 text-white' : 'text-gray-900' } ${selected ? 'font-semibold' : 'font-normal'}` } > {({ active, selected }) => ( <>
{person.name} {person.email}
{selected && ( )} )}
)) )}
) } // Multiple selection with tags function TagCombobox() { const [selectedPeople, setSelectedPeople] = useState([]) const [query, setQuery] = useState('') return (
{selectedPeople.map((person) => ( {person} ))}
setQuery(e.target.value)} placeholder="Add more..." /> Alice Bob Charlie
) } ``` ## Popover Component Floating overlay with positioning and click-outside-to-close behavior ```jsx import { Popover } from '@headlessui/react' function SolutionsPopover() { const solutions = [ { name: 'Analytics', description: 'Get insights into your data', href: '/analytics' }, { name: 'Engagement', description: 'Connect with your audience', href: '/engagement' }, { name: 'Security', description: 'Your data is safe', href: '/security' }, ] return ( {({ open, close }) => ( <> Solutions
{solutions.map((item) => (
{item.name}

{item.description}

))}
)}
) } // With backdrop overlay function PopoverWithBackdrop() { return ( Open Menu

Menu

Content goes here

) } ``` ## Disclosure Component Show/hide component for accordions and expandable sections ```jsx import { Disclosure } from '@headlessui/react' function FAQ() { const faqs = [ { question: 'What is your refund policy?', answer: '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.', }, { question: 'Do you offer technical support?', answer: 'Yes, we offer 24/7 technical support via email and chat for all customers.', }, { question: 'Do you ship internationally?', answer: 'Yes, we ship all over the world. Shipping costs will apply, and will be added at checkout.', }, ] return (
{faqs.map((faq, index) => ( {({ open }) => ( <> {faq.question} {faq.answer} )} ))}
) } // Single disclosure with default open state function SingleDisclosure() { return ( {({ open, close }) => ( <> {open ? '−' : '+'} Advanced Settings

Advanced configuration options here.

)}
) } ``` ## Tabs Component Tabbed interface with keyboard navigation and automatic selection management ```jsx import { Tab } from '@headlessui/react' import { useState } from 'react' function ProductTabs() { const categories = { Recent: [ { id: 1, title: 'iPhone 15 Pro', date: '2 hours ago' }, { id: 2, title: 'MacBook Pro M3', date: '5 hours ago' }, ], Popular: [ { id: 3, title: 'AirPods Pro', date: 'Most viewed' }, { id: 4, title: 'Apple Watch Ultra', date: 'Most liked' }, ], Trending: [ { id: 5, title: 'Vision Pro', date: 'Trending #1' }, { id: 6, title: 'iPad Air', date: 'Trending #2' }, ], } return (
{Object.keys(categories).map((category) => ( `w-full rounded-lg py-2.5 text-sm font-medium leading-5 ${selected ? 'bg-white text-blue-700 shadow' : 'text-blue-600 hover:bg-white/[0.12] hover:text-blue-800' }` } > {category} ))} {Object.values(categories).map((posts, idx) => (
    {posts.map((post) => (
  • {post.title}

    {post.date}

  • ))}
))}
) } // Controlled tabs with manual selection function ControlledTabs() { const [selectedIndex, setSelectedIndex] = useState(0) return (
`px-4 py-2 rounded ${selected ? 'bg-blue-500 text-white' : 'bg-gray-200'}` }> Overview `px-4 py-2 rounded ${selected ? 'bg-blue-500 text-white' : 'bg-gray-200'}` }> Details
Tab {selectedIndex + 1} of 2
Overview content Details content
) } ``` ## Switch Component Toggle switch with form integration and accessibility ```jsx import { Switch } from '@headlessui/react' import { useState } from 'react' function NotificationSettings() { const [emailEnabled, setEmailEnabled] = useState(true) const [smsEnabled, setSmsEnabled] = useState(false) const [pushEnabled, setPushEnabled] = useState(true) return (
Email notifications Receive email updates about your account activity SMS notifications Get text messages about urgent updates
) } // Form integration with uncontrolled switch function FormWithSwitch() { return (
{ e.preventDefault() const formData = new FormData(e.currentTarget) const data = Object.fromEntries(formData) console.log('Form data:', data) // Output: { notifications: "on" } or {} }}> Enable notifications
) } // Render prop pattern with custom UI function CustomSwitch() { const [enabled, setEnabled] = useState(false) return ( {({ checked }) => (
{checked ? 'ON' : 'OFF'}
)} ) } ``` ## RadioGroup Component Accessible radio button group with complex object values ```jsx import { RadioGroup } from '@headlessui/react' import { useState } from 'react' function PricingPlan() { const plans = [ { id: 'startup', name: 'Startup', price: '$29/month', description: 'Perfect for small teams', features: ['Up to 5 users', '10GB storage', 'Email support'] }, { id: 'business', name: 'Business', price: '$99/month', description: 'For growing companies', features: ['Up to 20 users', '100GB storage', 'Priority support', 'Advanced analytics'] }, { id: 'enterprise', name: 'Enterprise', price: '$299/month', description: 'For large organizations', features: ['Unlimited users', 'Unlimited storage', '24/7 phone support', 'Custom integrations'] }, ] const [selectedPlan, setSelectedPlan] = useState(plans[0]) return (
Select a plan
{plans.map((plan) => ( `${checked ? 'bg-blue-600 text-white' : 'bg-white'} ${active ? 'ring-2 ring-blue-500 ring-offset-2' : ''} relative rounded-lg shadow-md px-5 py-4 cursor-pointer flex focus:outline-none` } > {({ checked }) => (
{plan.name} {plan.price}
{plan.description}
    {plan.features.map((feature, index) => (
  • • {feature}
  • ))}
{checked && (
)}
)}
))}
) } // Form integration with uncontrolled radio group function DeliveryForm() { return (
{ e.preventDefault() const formData = new FormData(e.currentTarget) console.log('Selected delivery:', Object.fromEntries(formData)) // Output: { delivery: "pickup" } }}> Delivery method
`px-4 py-2 rounded ${checked ? 'bg-blue-500 text-white' : 'bg-gray-100'}`} > Pickup (Free) `px-4 py-2 rounded ${checked ? 'bg-blue-500 text-white' : 'bg-gray-100'}`} > Home delivery ($5) `px-4 py-2 rounded ${checked ? 'bg-blue-500 text-white' : 'bg-gray-100'}`} > Dine in
) } // Custom comparator for object equality function ObjectRadioGroup() { const options = [ { id: 1, name: 'Alice', role: 'Admin' }, { id: 2, name: 'Bob', role: 'User' }, { id: 3, name: 'Charlie', role: 'Moderator' }, ] const [selected, setSelected] = useState(options[0]) return ( a.id === z.id} > Select team member {options.map((option) => ( {({ checked }) => ( {option.name} - {option.role} )} ))} ) } ``` ## Transition Component CSS transition component for animating enter/leave states ```jsx import { Transition } from '@headlessui/react' import { useState } from 'react' function AnimatedNotification() { const [isShowing, setIsShowing] = useState(false) return ( <>
Successfully saved!
) } // Complex multi-part transition function SlidingPanel() { const [isOpen, setIsOpen] = useState(false) return ( <> {/* Backdrop */}
setIsOpen(false)} /> {/* Panel */}

Sliding Panel

Content goes here

) } ``` ## Tailwind CSS Plugin Integration State-based utility variants for styling Headless UI components ```jsx import { Menu } from '@headlessui/react' function StyledMenu() { return ( Options Settings Disabled item ) } // Custom prefix configuration // tailwind.config.js module.exports = { plugins: [ require('@headlessui/tailwindcss')({ prefix: 'ui' }) ] } // Available variants: // ui-open / ui-not-open // ui-checked / ui-not-checked // ui-selected / ui-not-selected // ui-active / ui-not-active // ui-disabled / ui-not-disabled // ui-focus-visible / ui-not-focus-visible function AllVariantsExample() { return ( <> {/* Listbox with state variants */} Option A {/* Switch with checked variants */} Toggle ) } ``` ## Summary Headless UI provides a comprehensive suite of accessible, unstyled UI components that handle the complex logic of interactive interfaces while giving developers complete styling freedom. The library excels at solving common challenges in modern web applications including keyboard navigation, focus management, screen reader support, and state synchronization. Each component follows WAI-ARIA design patterns and best practices, ensuring that applications built with Headless UI are accessible by default without requiring deep accessibility expertise from developers. The v2.2.9 release includes enhanced form components like Button, Checkbox, Input, Select, and Textarea that integrate seamlessly with native HTML forms while providing rich state management and accessibility features. The component API is consistent across the library, using render props to expose internal state for conditional styling, supporting both controlled and uncontrolled modes for flexibility, and integrating seamlessly with HTML forms for data submission. The Tailwind CSS plugin extends this integration by providing utility variants that respond to component state, enabling declarative styling patterns that keep component logic and visual presentation clearly separated. Whether building dropdown menus, modal dialogs, autocomplete inputs, tabbed interfaces, or accessible forms, Headless UI provides the foundation for creating polished, accessible user experiences with minimal complexity and maximum control. The library's focus on providing functionality without styling constraints makes it particularly well-suited for design systems and component libraries that require precise visual control.