Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik25 min czytania

Headless UI, komponenty bez stylów od Tailwind Labs

Headless UI daje dostępne komponenty bez stylów dla Reacta i Vue. Wersja 2, stan rozwoju projektu, atrybuty danych i porównanie z Radix UI.

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

CechaHeadless UIRadix UIArk UIReact Aria
Obsługiwane frameworkiReact, Vue z zastrzeżeniemReactReact, Vue, Solid, SvelteReact
Liczba komponentówkilkanaścieokoło trzydziestuokoło czterdziestuponad czterdzieści
Pozycjonowanie elementów pływającychwbudowane od wersji 2wbudowanewbudowaneosobny pakiet
Tempo rozwojuwyraźnie zwolnioneaktywneaktywneaktywne
LicencjaMITMITMITApache 2.0
Integracja z Tailwindemnajlepsza w stawcedobradobradobra

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

Code
Bash
# npm
npm install @headlessui/react

# yarn
yarn add @headlessui/react

# pnpm
pnpm add @headlessui/react

Vue 3

Code
Bash
# npm
npm install @headlessui/vue

# yarn
yarn add @headlessui/vue

# pnpm
pnpm add @headlessui/vue

Podstawowa konfiguracja z Tailwind CSS

JStailwind.config.js
JavaScript
// 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:

Code
TypeScript
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)

Code
TypeScript
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)

Code
TypeScript
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 otwarte
  • data-[focus] - Element ma focus
  • data-[active] - Element jest aktywny
  • data-[selected] - Element jest wybrany
  • data-[disabled] - Element jest wyłączony
  • data-[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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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.

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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.

Code
TypeScript
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

Code
TypeScript
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

Code
VUE
<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

Code
TypeScript
// 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

Code
TypeScript
// 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

Code
TypeScript
// 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:

Code
TypeScript
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.