Headless UI - Kompletny Przewodnik po Unstyled Accessible Components
Czym jest Headless UI?
Headless UI to biblioteka w pełni dostępnych, unstyled komponentów UI dla React i Vue, stworzona przez zespół Tailwind Labs - tych samych ludzi, którzy stworzyli Tailwind CSS. Nazwa "headless" oznacza, że komponenty dostarczają całą logikę, zachowanie i dostępność (accessibility), ale nie narzucają żadnych stylów - to Ty decydujesz, jak mają wyglądać.
Headless UI rozwiązuje jeden z największych problemów w budowaniu nowoczesnych interfejsów: jak stworzyć w pełni dostępne, interaktywne komponenty bez walki z gotowymi stylami czy ograniczeniami designu. Zamiast nadpisywać setki linii CSS, po prostu stosujesz własne klasy (najczęściej Tailwind CSS) do renderowanych elementów.
Filozofia Headless
Tradycyjne biblioteki UI (Bootstrap, Material UI, Ant Design) narzucają określony wygląd. Zmiana designu wymaga nadpisywania stylów, co prowadzi do:
- Rozbudowanych arkuszy CSS ze specyficznymi selektorami
- Konfliktów z wbudowanymi stylami
- Trudności w utrzymaniu spójności
- Problemów z aktualizacją biblioteki
Headless UI odwraca tę koncepcję:
- Zero stylów - Komponenty renderują czyste HTML
- Pełna logika - Zarządzanie stanem, focus, keyboard navigation
- Pełna dostępność - ARIA attributes, role, screen reader support
- Pełna kontrola - Ty decydujesz o każdym aspekcie wyglądu
Headless UI vs Inne Biblioteki
| Cecha | Headless UI | Radix UI | React Aria | Material UI |
|---|---|---|---|---|
| Stylowanie | Zero stylów | Zero stylów | Zero stylów | Gotowe style |
| Framework | React + Vue | React only | React only | React only |
| Twórca | Tailwind Labs | WorkOS | Adobe | |
| Komponenty | 10 | 30+ | 40+ | 70+ |
| Rozmiar | ~15KB | ~50KB | ~80KB | ~300KB |
| Dostępność | WAI-ARIA | WAI-ARIA | WAI-ARIA | WCAG 2.1 |
| Tailwind | Natywne | Dobra | Dobra | Słaba |
| Licencja | MIT | MIT | Apache 2.0 | MIT |
Kiedy wybrać Headless UI?
Wybierz Headless UI, gdy:
- Używasz Tailwind CSS (idealna integracja)
- Potrzebujesz Vue lub React
- Chcesz pełnej kontroli nad wyglądem
- Zależy Ci na małym bundle size
- Budujesz własny design system
Rozważ alternatywy, gdy:
- Potrzebujesz więcej komponentów (Radix UI)
- Potrzebujesz primitives bez renderingu (React Aria)
- Wolisz gotowe style (Material UI, Chakra UI)
Instalacja i Konfiguracja
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/vuePodstawowa konfiguracja z 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 jest napisany w TypeScript i dostarcza pełne typy:
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 oferuje dwa sposoby stylowania stanów:
Render Props (Tradycyjne)
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 (Nowe - zalecane)
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 działają z Tailwind CSS:
data-[open]- Menu jest otwartedata-[focus]- Element ma focusdata-[active]- Element jest aktywnydata-[selected]- Element jest wybranydata-[disabled]- Element jest wyłączonydata-[checked]- Checkbox/Switch jest zaznaczony
Menu (Dropdown) - Szczegółowy Przewodnik
Menu to jeden z najczęściej używanych komponentów. Obsługuje pełną nawigację klawiaturą i jest dostępny dla screen readerów.
Podstawowe 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 z Linkami i Routerem
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 z Grupami
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) - Kompletny Przewodnik
Dialog to komponent modal z pełnym zarządzaniem focusem i dostępnością.
Podstawowy 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={() => {
// Handle deactivation
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 z Animacjami
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 z Formularzem
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 to dostępna alternatywa dla natywnego <select>, dająca pełną kontrolę nad wyglądem.
Podstawowy 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 z 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 łączy input tekstowy z dropdown listą - idealny do wyszukiwania i autouzupełniania.
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) - Przełącznik
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 z Ikony
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 - Karty
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 na Sterydach
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 - Grupa Radio Buttons
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 - Animacje
Headless UI dostarcza komponent Transition do płynnych animacji.
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>
)
}Integracja z 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. Zawsze używaj semantycznego HTML
// ✅ Dobrze - Menu.Button renderuje button
<MenuButton>Options</MenuButton>
// ❌ Źle - div bez semantyki
<div onClick={openMenu}>Options</div>2. Customizuj z as prop
// Renderuj jako link
<MenuItem as="a" href="/profile">
Profile
</MenuItem>
// Renderuj jako Next.js Link
<MenuItem as={Link} href="/profile">
Profile
</MenuItem>
// Renderuj jako custom component
<MenuItem as={Fragment}>
<MyCustomItem />
</MenuItem>3. Accessibility jest automatyczna
Headless UI automatycznie dodaje:
- ARIA attributes
- Role
- Keyboard navigation
- Focus management
- Screen reader announcements
4. Używaj data attributes zamiast render props
// ✅ Nowsze, czystsze API
<MenuItem className="data-[focus]:bg-blue-100">
<a href="/profile">Profile</a>
</MenuItem>
// ⚠️ Starsze API (nadal działa)
<MenuItem>
{({ focus }) => (
<a className={focus ? 'bg-blue-100' : ''} href="/profile">
Profile
</a>
)}
</MenuItem>FAQ - Najczęściej Zadawane Pytania
Czy Headless UI działa z Next.js App Router?
Tak! Headless UI działa zarówno z Pages Router jak i App Router. Pamiętaj o 'use client' dla interaktywnych komponentów.
Jak połączyć Headless UI z Framer Motion?
Możesz używać Framer Motion zamiast wbudowanych 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>Czy mogę używać Headless UI bez Tailwind CSS?
Tak! Headless UI jest agnostyczny wobec stylowania. Możesz używać:
- Tailwind CSS
- CSS Modules
- Styled Components
- Emotion
- Vanilla CSS
- Sass/Less
Jaki jest rozmiar bundle Headless UI?
Około 15KB gzipped dla wszystkich komponentów. Import pojedynczych komponentów zmniejsza bundle dzięki tree-shaking.
Czy Headless UI wspiera SSR?
Tak, w pełni wspiera Server-Side Rendering w Next.js, Remix i innych frameworkach.
Podsumowanie
Headless UI to idealne rozwiązanie dla deweloperów, którzy:
- Chcą pełnej kontroli nad wyglądem
- Potrzebują dostępnych komponentów
- Używają Tailwind CSS
- Cenią małe bundle size
- Budują własne design systemy
Biblioteka dostarcza 10 starannie zaprojektowanych komponentów z pełną dostępnością, nawigacją klawiaturą i wsparciem dla screen readerów. Dzięki integracji z Tailwind CSS i wsparciu dla React i Vue, Headless UI jest doskonałym wyborem dla nowoczesnych aplikacji.
Headless UI - Complete guide to unstyled accessible components
What is Headless UI?
Headless UI is a library of fully accessible, unstyled UI components for React and Vue, created by the Tailwind Labs team - the same people who built Tailwind CSS. The name "headless" means that the components provide all the logic, behavior, and accessibility, but impose no styles whatsoever - you decide how they look.
Headless UI solves one of the biggest problems in building modern interfaces: how to create fully accessible, interactive components without fighting pre-built styles or design constraints. Instead of overriding hundreds of lines of CSS, you simply apply your own classes (most commonly Tailwind CSS) to the rendered elements.
The headless philosophy
Traditional UI libraries (Bootstrap, Material UI, Ant Design) impose a specific look. Changing the design requires overriding styles, which leads to:
- Bloated CSS stylesheets with highly specific selectors
- Conflicts with built-in styles
- Difficulty maintaining consistency
- Problems when updating the library
Headless UI flips this concept:
- Zero styles - Components render clean HTML
- Full logic - State management, focus, keyboard navigation
- Full accessibility - ARIA attributes, roles, screen reader support
- Full control - You decide every aspect of the appearance
Headless UI vs other libraries
| Feature | Headless UI | Radix UI | React Aria | Material UI |
|---|---|---|---|---|
| Styling | Zero styles | Zero styles | Zero styles | Built-in styles |
| Framework | React + Vue | React only | React only | React only |
| Creator | Tailwind Labs | WorkOS | Adobe | |
| Components | 10 | 30+ | 40+ | 70+ |
| Size | ~15KB | ~50KB | ~80KB | ~300KB |
| Accessibility | WAI-ARIA | WAI-ARIA | WAI-ARIA | WCAG 2.1 |
| Tailwind | Native | Good | Good | Poor |
| License | MIT | MIT | Apache 2.0 | MIT |
When to choose Headless UI?
Choose Headless UI when:
- You use Tailwind CSS (perfect integration)
- You need Vue or React support
- You want full control over appearance
- You care about small bundle size
- You are building your own design system
Consider alternatives when:
- You need more components (Radix UI)
- You need rendering-free primitives (React Aria)
- You prefer ready-made styles (Material UI, Chakra UI)
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?
Absolutely! Headless UI is styling-agnostic. You can use:
- Tailwind CSS
- CSS Modules
- Styled Components
- Emotion
- Vanilla CSS
- Sass/Less
What is the bundle size of Headless UI?
Around 15KB gzipped for all components. Importing individual components reduces the bundle thanks to tree-shaking.
Does Headless UI support SSR?
Yes, it fully supports Server-Side Rendering in Next.js, Remix, and other frameworks.
Summary
Headless UI is the ideal solution for developers who:
- Want full control over appearance
- Need accessible components
- Use Tailwind CSS
- Value small bundle size
- Are building their own design systems
The library provides 10 carefully designed components with full accessibility, keyboard navigation, and screen reader support. Thanks to its integration with Tailwind CSS and support for both React and Vue, Headless UI is an excellent choice for modern applications.