Headless UI, komponenty bez stylów od Tailwind Labs
Headless UI to zestaw komponentów interfejsu, które dostarczają zachowanie i dostępność, a nie dostarczają żadnego wyglądu. Okno dialogowe wie, jak pułapkować fokus i zamykać się klawiszem ucieczki, lista rozwijana obsługuje strzałki i pisanie po literach, ale oba renderują nagi HTML, który ostylujesz po swojemu. Autorem jest Tailwind Labs, ten sam zespół co za Tailwind CSS, a licencja to MIT.
Co znaczy „bez stylów" i dlaczego to ma sens
Klasyczna biblioteka komponentów daje Ci gotowy wygląd, który potem nadpisujesz. Brzmi wygodnie przez pierwszy tydzień, a potem zamienia się w walkę z cudzymi selektorami, w której wygrywa ten, kto napisze więcej wykrzykników w arkuszu stylów.
Podejście bez stylów odwraca podział pracy. Biblioteka bierze na siebie to, co trudne i nudne zarazem, czyli zarządzanie fokusem, obsługę klawiatury, atrybuty ról i ogłoszenia dla czytników ekranu. Ty bierzesz na siebie wygląd, czyli to, w czym i tak chcesz mieć ostatnie słowo.
Zysk widać najlepiej na jednym przykładzie. Poprawnie zaimplementowane okno dialogowe musi przenieść fokus do środka po otwarciu, nie pozwolić mu wyjść tabulatorem, oddać go elementowi wywołującemu po zamknięciu, zablokować przewijanie tła, zamknąć się klawiszem ucieczki i mieć właściwe powiązania opisów. To kilkaset linii kodu, których nikt nie chce pisać drugi raz, i które prawie zawsze są napisane niekompletnie, kiedy pisze je się samodzielnie pod presją terminu.
Cena jest taka, że nic nie dostajesz za darmo wizualnie. Pusty komponent wygląda jak nieostylowany HTML, więc pierwszy ekran zajmuje więcej czasu niż w bibliotece z gotowym motywem. To inwestycja, która zwraca się przy własnym systemie projektowym i nie zwraca się przy panelu wewnętrznym, którego wygląd nikogo nie obchodzi.
Stan projektu, czyli rzecz, o której warto wiedzieć
Zanim przejdziemy do kodu, jedna informacja praktyczna, której nie znajdziesz na stronie projektu, a która realnie wpływa na decyzję.
Wersja dla Reacta ma numer 2.2.10 i została wydana 7 kwietnia 2026 roku. Ostatnie zmiany w repozytorium pochodzą z 13 kwietnia 2026, czyli sprzed kilku miesięcy. Projekt nie jest porzucony, ale rytm wydań wyraźnie zwolnił w porównaniu z okresem, kiedy powstawała dwójka.
Poważniejsza sprawa dotyczy Vue. Pakiet dla tego frameworka stoi na wersji 1.7.23 i nigdy nie dostał dwójki. Oznacza to brak wszystkiego, co przyniosła wersja druga po stronie Reacta: wbudowanego pozycjonowania elementów pływających, komponentu pola wyboru, komponentów formularza, wirtualizacji długich list. Zespół tłumaczył w dyskusji na repozytorium, że nie ma na to konkretnych planów, bo priorytety zajmuje Tailwind CSS.
Wniosek jest prosty i warto go wyciągnąć przed rozpoczęciem projektu. Przy Reakcie to nadal rozsądny wybór, zwłaszcza jeśli i tak używasz Tailwinda. Przy Vue budujesz na wersji, która nie będzie rozwijana, więc lepiej rozejrzeć się za czymś innym.
Headless UI a alternatywy
| Cecha | Headless UI | Radix UI | Ark UI | React Aria |
|---|---|---|---|---|
| Obsługiwane frameworki | React, Vue z zastrzeżeniem | React | React, Vue, Solid, Svelte | React |
| Liczba komponentów | kilkanaście | około trzydziestu | około czterdziestu | ponad czterdzieści |
| Pozycjonowanie elementów pływających | wbudowane od wersji 2 | wbudowane | wbudowane | osobny pakiet |
| Tempo rozwoju | wyraźnie zwolnione | aktywne | aktywne | aktywne |
| Licencja | MIT | MIT | MIT | Apache 2.0 |
| Integracja z Tailwindem | najlepsza w stawce | dobra | dobra | dobra |
Rozstrzygnięcie zależy od dwóch rzeczy. Pierwsza to framework: jeśli to nie React, Headless UI wypada z gry albo skazuje Cię na wersję zamrożoną. Druga to zestaw komponentów, bo tu biblioteka jest wyraźnie uboższa od konkurencji i brakuje w niej rzeczy, które w projekcie prędzej czy później się przydadzą, na przykład dymka podpowiedzi, menu kontekstowego czy suwaka.
Przewaga, która wciąż działa, to dopasowanie do Tailwinda. Warianty stanów opisujesz tu wprost klasami warunkowymi, bez dokładania własnych atrybutów, a dokumentacja pisana jest przez ludzi, którzy zakładają, że stylujesz klasami narzędziowymi. Przy projekcie stojącym na Tailwind CSS to oszczędza sporo tarcia.
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: [],
}Obsługa TypeScriptu
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>
)
}Atrybuty danych (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 odnośnikami 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) - własny 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 wielokrotnym wyborem
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 - pole z podpowiedziami
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 ikonami
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 przycisków radio
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>
)
}Integracja z Vue.js
<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>Dobre praktyki
1. Zawsze używaj semantycznego HTML
// Dobrze - Menu.Button renderuje button
<MenuButton>Options</MenuButton>
// Zle - div bez semantyki
<div onClick={openMenu}>Options</div>Pierwszy przykład jest poprawny, bo komponent przycisku renderuje prawdziwy element <button>. Drugi jest błędny, bo div nie niesie semantyki elementu interaktywnego.
2. Dostosowanie przez właściwość as
// 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>Właściwość as pozwala wskazać, jaki element HTML albo komponent zostanie wyrenderowany pod spodem. Możesz podać odnośnik, komponent nawigacyjny z frameworka albo własny komponent.
3. Dostępność jest automatyczna
Headless UI automatycznie dodaje:
- atrybuty ARIA
- role
- obsługę klawiatury
- zarządzanie fokusem
- komunikaty dla czytników ekranu
4. Używaj atrybutów danych zamiast render props
// Nowsze, czystsze API
<MenuItem className="data-[focus]:bg-blue-100">
<a href="/profile">Profile</a>
</MenuItem>
// Starsze API (nadal dziala)
<MenuItem>
{({ focus }) => (
<a className={focus ? 'bg-blue-100' : ''} href="/profile">
Profile
</a>
)}
</MenuItem>Pierwsze podejście, oparte o atrybuty danych, to nowsze i czytelniejsze API. Drugie, oparte o render props, to starsze API, które nadal działa, ale jest bardziej rozwlekłe.
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, biblioteka nie zakłada żadnego konkretnego sposobu stylowania. Działa z modułami CSS, komponentami stylowanymi, zwykłym arkuszem czy preprocesorem. Dopasowanie do Tailwinda jest wygodą, nie wymogiem.
Czy Headless UI wspiera renderowanie serwerowe?
Tak, komponenty renderują się poprawnie po stronie serwera. W Next.js pamiętaj o dyrektywie oznaczającej komponent kliencki, bo cała interaktywność wymaga przeglądarki.
Czy warto zaczynać nowy projekt na Headless UI?
Przy Reakcie i Tailwindzie tak, z zastrzeżeniem, że zestaw komponentów jest wąski i część rzeczy dopiszesz sam. Przy Vue nie, bo tamten pakiet stoi na wersji 1.7.23 i nie dostał odpowiednika dwójki.
Co przyniosła wersja druga
Warto wiedzieć, co się zmieniło, bo starsze poradniki opisują wzorce, które nadal działają, ale nie są już zalecane.
Największa zmiana dotyczy sposobu, w jaki stylujesz stany. Wcześniej komponent przekazywał informacje o stanie przez funkcję renderującą, więc trzeba było pisać wyrażenia zwracające klasy w zależności od tego, czy element jest aktywny albo zaznaczony. Teraz stany trafiają na element jako atrybuty danych, a klasy warunkowe zapisujesz wprost w Tailwindzie. Kod robi się krótszy i czytelniejszy, a różnicę widać najmocniej przy listach, gdzie każda pozycja miała własną funkcję.
Druga zmiana to wbudowane pozycjonowanie elementów pływających. Menu i listy rozwijane same ustawiają się względem przycisku, uwzględniając brzegi okna, bez dokładania osobnej biblioteki. Wcześniej był to najczęstszy powód, dla którego ludzie porzucali tę bibliotekę na rzecz konkurencji.
Trzecia to przejścia bez osobnego komponentu opakowującego. Animację wejścia i wyjścia włącza się właściwością na samym elemencie, co usuwa poziom zagnieżdżenia, który wcześniej mylił wielu ludzi.
Doszły też komponenty, których brakowało: pole wyboru, elementy formularza oraz wirtualizacja długich list w polu z podpowiedziami, dzięki której lista kilku tysięcy pozycji nie zabija przeglądarki. Ta ostatnia zmiana jest ważniejsza, niż brzmi, bo wcześniej pole z podpowiedziami przy większym zbiorze danych trzeba było ograniczać sztucznie albo przepisywać na własne rozwiązanie, co w praktyce oznaczało rezygnację z całej dostępności, którą biblioteka zapewniała.
Gdzie kończy się odpowiedzialność biblioteki
To rozróżnienie warto zrozumieć, bo od niego zależy, czy Twój interfejs faktycznie będzie dostępny, czy tylko zbudowany z dostępnych klocków.
Biblioteka odpowiada za zachowanie pojedynczego komponentu. Gwarantuje, że lista rozwijana ma poprawne role, że strzałki przesuwają zaznaczenie, że fokus wraca tam, gdzie powinien, i że czytnik ekranu ogłosi zmianę stanu. To jest warstwa, w której najłatwiej popełnić błąd i najtrudniej go wykryć bez znajomości specyfikacji.
Za resztę odpowiadasz Ty i jest tego więcej, niż się zwykle zakłada. Kolejność elementów w kodzie decyduje o kolejności fokusa, więc układ zbudowany na zmianie porządku wizualnego może czytać się zupełnie inaczej niż wygląda. Kontrast tekstu wobec tła to Twój wybór kolorów, a nie sprawa biblioteki. Etykiety pól formularza, opisy błędów i powiązanie jednych z drugimi też są po Twojej stronie, bo biblioteka nie wie, co znaczą Twoje pola.
Jest jeszcze rzecz, o której łatwo zapomnieć przy komponentach bez stylów: widoczność fokusa. Skoro sam decydujesz o wyglądzie, to Ty musisz zadbać, żeby element z fokusem dało się odróżnić od pozostałych. Domyślny obrys przeglądarki znika, kiedy zresetujesz style, i wraca dopiero wtedy, gdy świadomie go przywrócisz.
Praktyczny test zajmuje kilka minut i wyłapuje większość problemów. Odłóż mysz, przejdź przez ekran samym tabulatorem i sprawdź trzy rzeczy: czy zawsze widać, gdzie jesteś, czy kolejność ma sens, i czy da się wyjść z każdego otwartego elementu klawiszem ucieczki. Jeśli te trzy warunki są spełnione, jesteś dalej niż większość wdrożeń.
Typowe błędy
Pierwszy to oczekiwanie, że komponent będzie wyglądał sensownie od razu. Nieostylowany element wygląda na zepsuty i regularnie prowadzi do wniosku, że biblioteka nie działa. To jest zamierzone zachowanie, a nie usterka.
Drugi to gubienie dostępności przy własnych stylach. Usunięcie obrysu fokusa, bo „brzydko wygląda", niweczy połowę pracy, którą biblioteka wykonuje za Ciebie. Jeśli domyślny obrys nie pasuje do projektu, zastąp go własnym wyróżnieniem, a nie pustką.
Trzeci to sięganie po własne rozwiązanie przy pierwszym oporze. Kiedy komponent nie ma potrzebnej funkcji, kuszące jest napisanie własnego okna dialogowego w trzydzieści minut. Te trzydzieści minut nie obejmuje pułapki fokusa, obsługi klawiatury ani zachowania na czytniku ekranu, więc realny koszt jest wielokrotnie wyższy i ujawnia się później.
Czwarty to mieszanie tej biblioteki z inną warstwą zachowań w jednym projekcie. Dwa systemy zarządzające fokusem potrafią wchodzić sobie w drogę przy zagnieżdżonych oknach dialogowych, a diagnozowanie tego zajmuje więcej czasu niż ujednolicenie od początku.
Piąty to poleganie na komponencie, którego w bibliotece nie ma. Zestaw jest wąski i brakuje w nim między innymi dymka podpowiedzi, suwaka i menu kontekstowego. Sprawdź listę komponentów przed decyzją, a nie w połowie projektu. Warto przy tym zrobić prosty przegląd: wypisz elementy interfejsu, które na pewno pojawią się w Twojej aplikacji, i zaznacz te, których biblioteka nie pokrywa. Jeśli po tej liście zostaje więcej niż dwie lub trzy pozycje do samodzielnego napisania, oszczędność wynikająca z dopasowania do Tailwinda przestaje równoważyć koszt utrzymania własnych komponentów.
Kod źródłowy i wydania znajdziesz w repozytorium Headless UI, a pełną dokumentację komponentów na stronie projektu.