Headless UI
"Headless UI: unstyled accessible components for Tailwind, Dialog, Combobox, Listbox, Menu, Transition, render props, React/Vue"
Headless UI provides completely unstyled, fully accessible UI components designed to integrate seamlessly with Tailwind CSS. Built by the Tailwind Labs team, it offers a small set of high-quality components that handle all the complex accessibility and interaction logic while leaving visual design entirely to you. Each component exposes its internal state through render props and data attributes, giving you full control over styling based on component state. ## Key Points - Completely unstyled -- pairs naturally with Tailwind CSS - WAI-ARIA compliant accessibility out of the box - Render props expose state for conditional styling - Available for both React and Vue - Small focused API surface: fewer components, each done well 1. **Use the `as` prop** to control which HTML element is rendered. `<Menu as="div">` renders a div instead of a Fragment, useful for positioning context. 2. **Pair Transition with Dialog** for enter/exit animations. Use `TransitionChild` for independent animation of backdrop and panel. 3. **Use `data-[focus]` and `data-[selected]`** data attributes in v2+ instead of render props for simpler styling with Tailwind. 4. **Always provide a meaningful `onClose` handler** for Dialog. Headless UI calls this on Escape key and backdrop click. 5. **Use `multiple` prop on Listbox and Combobox** for multi-select scenarios. The value becomes an array. 6. **Use `nullable` on Combobox** to allow clearing the selection back to null. 7. **Keep the component tree flat** -- Headless UI components expect their child parts as direct or near-direct descendants.
skilldb get ui-components-services-skills/Headless UIFull skill: 363 linesHeadless UI Skill
Core Philosophy
Headless UI provides completely unstyled, fully accessible UI components designed to integrate seamlessly with Tailwind CSS. Built by the Tailwind Labs team, it offers a small set of high-quality components that handle all the complex accessibility and interaction logic while leaving visual design entirely to you. Each component exposes its internal state through render props and data attributes, giving you full control over styling based on component state.
Key principles:
- Completely unstyled -- pairs naturally with Tailwind CSS
- WAI-ARIA compliant accessibility out of the box
- Render props expose state for conditional styling
- Available for both React and Vue
- Small focused API surface: fewer components, each done well
Setup
Installation
# React
npm install @headlessui/react
# Vue
npm install @headlessui/vue
# Tailwind CSS is expected to be configured in the project
npm install -D tailwindcss @tailwindcss/forms
Import Pattern
// React -- named imports from the single package
import {
Dialog,
DialogPanel,
DialogTitle,
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
Menu,
MenuButton,
MenuItems,
MenuItem,
Transition,
TransitionChild,
} from "@headlessui/react";
Key Techniques
Dialog (Modal)
import { useState } from "react";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
export function ConfirmDeleteDialog() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)} className="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700">
Delete Account
</button>
<Transition show={isOpen}>
<Dialog onClose={() => setIsOpen(false)} className="relative z-50">
{/* Backdrop */}
<TransitionChild
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/30" aria-hidden="true" />
</TransitionChild>
{/* Panel */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
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 rounded-xl bg-white p-6 shadow-xl">
<DialogTitle className="text-lg font-semibold">Delete Account</DialogTitle>
<p className="mt-2 text-sm text-gray-500">
Are you sure? This action cannot be undone. All your data will be permanently removed.
</p>
<div className="mt-6 flex justify-end gap-3">
<button onClick={() => setIsOpen(false)} className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50">
Cancel
</button>
<button onClick={() => setIsOpen(false)} className="rounded-md bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700">
Delete
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</>
);
}
Combobox (Autocomplete)
import { useState } from "react";
import { Combobox, ComboboxInput, ComboboxButton, ComboboxOptions, ComboboxOption } from "@headlessui/react";
import { Check, ChevronsUpDown } from "lucide-react";
interface Person {
id: number;
name: string;
}
const people: Person[] = [
{ 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" },
];
export function PeopleCombobox() {
const [selected, setSelected] = useState<Person | null>(null);
const [query, setQuery] = useState("");
const filtered = query === ""
? people
: people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase())
);
return (
<Combobox value={selected} onChange={setSelected} onClose={() => setQuery("")}>
<div className="relative w-72">
<div className="relative">
<ComboboxInput
className="w-full rounded-lg border py-2 pl-3 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
displayValue={(person: Person | null) => person?.name ?? ""}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search people..."
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronsUpDown className="h-4 w-4 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 border">
{filtered.length === 0 && query !== "" ? (
<div className="px-4 py-2 text-sm text-gray-500">Nothing found.</div>
) : (
filtered.map((person) => (
<ComboboxOption
key={person.id}
value={person}
className="relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm data-[focus]:bg-blue-50 data-[selected]:font-medium"
>
{({ selected }) => (
<>
<span>{person.name}</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<Check className="h-4 w-4 text-blue-600" />
</span>
)}
</>
)}
</ComboboxOption>
))
)}
</ComboboxOptions>
</div>
</Combobox>
);
}
Listbox (Select)
import { useState } from "react";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from "@headlessui/react";
import { Check, ChevronsUpDown } from "lucide-react";
const statuses = [
{ id: 1, name: "Active", color: "bg-green-400" },
{ id: 2, name: "Paused", color: "bg-yellow-400" },
{ id: 3, name: "Inactive", color: "bg-gray-400" },
{ id: 4, name: "Archived", color: "bg-red-400" },
];
export function StatusSelect() {
const [selected, setSelected] = useState(statuses[0]);
return (
<Listbox value={selected} onChange={setSelected}>
<div className="relative w-56">
<ListboxButton className="relative w-full cursor-pointer rounded-lg border bg-white py-2 pl-3 pr-10 text-left text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<span className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${selected.color}`} />
{selected.name}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronsUpDown className="h-4 w-4 text-gray-400" />
</span>
</ListboxButton>
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg border z-10">
{statuses.map((status) => (
<ListboxOption
key={status.id}
value={status}
className="relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm data-[focus]:bg-blue-50"
>
{({ selected: isSelected }) => (
<>
<span className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${status.color}`} />
{status.name}
</span>
{isSelected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
<Check className="h-4 w-4" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
</Listbox>
);
}
Menu (Dropdown)
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
import { Edit, Trash, Copy, MoreVertical } from "lucide-react";
export function ActionsMenu() {
return (
<Menu as="div" className="relative">
<MenuButton className="rounded-full p-1 hover:bg-gray-100">
<MoreVertical className="h-5 w-5 text-gray-500" />
</MenuButton>
<MenuItems className="absolute right-0 mt-1 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg border z-10">
<MenuItem>
<button className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100">
<Edit className="h-4 w-4" /> Edit
</button>
</MenuItem>
<MenuItem>
<button className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100">
<Copy className="h-4 w-4" /> Duplicate
</button>
</MenuItem>
<MenuItem>
<button className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 data-[focus]:bg-red-50">
<Trash className="h-4 w-4" /> Delete
</button>
</MenuItem>
</MenuItems>
</Menu>
);
}
Transition
import { Transition } from "@headlessui/react";
import { useState } from "react";
export function NotificationBanner() {
const [show, setShow] = useState(true);
return (
<>
<Transition
show={show}
enter="transition-all duration-300 ease-out"
enterFrom="opacity-0 -translate-y-4"
enterTo="opacity-100 translate-y-0"
leave="transition-all duration-200 ease-in"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-4"
>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-4 flex items-center justify-between">
<p className="text-sm text-blue-800">A new software update is available.</p>
<button onClick={() => setShow(false)} className="text-sm font-medium text-blue-600 hover:text-blue-800">
Dismiss
</button>
</div>
</Transition>
{!show && (
<button onClick={() => setShow(true)} className="text-sm text-blue-600 underline">
Show notification
</button>
)}
</>
);
}
Data Attributes for Styling (v2+)
// Headless UI v2 exposes data attributes for styling without render props
<ComboboxOption
value={item}
className="py-2 px-4 text-sm
data-[focus]:bg-blue-100
data-[selected]:font-bold
data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
>
{item.name}
</ComboboxOption>
// You can also use render props for more complex logic
<ComboboxOption value={item}>
{({ focus, selected, disabled }) => (
<div className={`py-2 px-4 ${focus ? "bg-blue-100" : ""} ${selected ? "font-bold" : ""}`}>
{item.name}
</div>
)}
</ComboboxOption>
Best Practices
- Use the
asprop to control which HTML element is rendered.<Menu as="div">renders a div instead of a Fragment, useful for positioning context. - Pair Transition with Dialog for enter/exit animations. Use
TransitionChildfor independent animation of backdrop and panel. - Use
data-[focus]anddata-[selected]data attributes in v2+ instead of render props for simpler styling with Tailwind. - Always provide a meaningful
onClosehandler for Dialog. Headless UI calls this on Escape key and backdrop click. - Use
multipleprop on Listbox and Combobox for multi-select scenarios. The value becomes an array. - Use
nullableon Combobox to allow clearing the selection back to null. - Keep the component tree flat -- Headless UI components expect their child parts as direct or near-direct descendants.
Anti-Patterns
- Adding your own keyboard handlers for arrow keys, Enter, Escape, etc. Headless UI manages all keyboard interactions internally. Adding your own will cause conflicts.
- Using CSS
display: noneto hide dropdowns instead of letting Headless UI manage mount/unmount. The component handles focus trapping and cleanup on unmount. - Wrapping MenuItems or ListboxOptions in extra divs that break the parent-child relationship. This disrupts keyboard navigation and ARIA tree.
- Forgetting to add
z-indexto dropdown/popover content. Since these render in-place (no portal by default), they need appropriate z-index to appear above surrounding content. - Using Headless UI just for styling when you only need simple interactive elements. If you do not need the accessibility behavior, plain HTML with Tailwind is lighter.
- Mixing controlled and uncontrolled patterns by providing both
valueanddefaultValue. Pick one approach and stick with it.
Install this skill directly: skilldb add ui-components-services-skills
Related Skills
Ark UI
"Ark UI: state machine-driven components, headless primitives, Select, Combobox, DatePicker, Toast, styling with any CSS framework"
Daisyui
"daisyUI: Tailwind CSS component library, themes, responsive utilities, component classes, customization, semantic color names"
Kobalte
Kobalte: accessible, unstyled UI component library for SolidJS with WAI-ARIA compliance and composable compound component APIs
Melt UI
Melt UI: headless, accessible UI component builders for Svelte using the builder pattern with full styling freedom
Park UI
Park UI: pre-designed styled components built on Ark UI primitives, available for React, Vue, and Solid with Panda CSS or Tailwind CSS styling
Radix UI
"Radix UI: accessible primitives, Dialog, Dropdown, Popover, Tabs, Toast, composition pattern, styling with className, controlled/uncontrolled"