Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds
Volver a colecciones
Guide40 min read

Headless UI - Kompletny Przewodnik po Unstyled Components

Headless UI is unstyled components from Tailwind Labs with full WAI-ARIA accessibility. Learn Menu, Dialog, Listbox, Combobox, Switch, Tabs, Disclosure, Popover and Radio Group.

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

CechaHeadless UIRadix UIReact AriaMaterial UI
StylowanieZero stylówZero stylówZero stylówGotowe style
FrameworkReact + VueReact onlyReact onlyReact only
TwórcaTailwind LabsWorkOSAdobeGoogle
Komponenty1030+40+70+
Rozmiar~15KB~50KB~80KB~300KB
DostępnośćWAI-ARIAWAI-ARIAWAI-ARIAWCAG 2.1
TailwindNatywneDobraDobraSłaba
LicencjaMITMITApache 2.0MIT

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

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: [],
}

TypeScript Support

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>
  )
}

Data Attributes (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 Linkami 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) - Custom 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 Multiple Selection

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 - Autocomplete Select

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 Ikony

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 Radio Buttons

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>
  )
}

Vue.js Integration

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>

Best Practices

1. Zawsze używaj semantycznego HTML

Code
TypeScript
// ✅ Dobrze - Menu.Button renderuje button
<MenuButton>Options</MenuButton>

// ❌ Źle - div bez semantyki
<div onClick={openMenu}>Options</div>

2. Customizuj z as prop

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>

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

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

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! 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

FeatureHeadless UIRadix UIReact AriaMaterial UI
StylingZero stylesZero stylesZero stylesBuilt-in styles
FrameworkReact + VueReact onlyReact onlyReact only
CreatorTailwind LabsWorkOSAdobeGoogle
Components1030+40+70+
Size~15KB~50KB~80KB~300KB
AccessibilityWAI-ARIAWAI-ARIAWAI-ARIAWCAG 2.1
TailwindNativeGoodGoodPoor
LicenseMITMITApache 2.0MIT

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

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

Basic configuration with 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: [],
}

TypeScript support

Headless UI is written in TypeScript and ships with full type definitions:

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 offers two ways to style component states:

Render props (traditional)

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>
  )
}

Data attributes (new - recommended)

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 work seamlessly with Tailwind CSS:

  • data-[open] - Menu is open
  • data-[focus] - Element has focus
  • data-[active] - Element is active
  • data-[selected] - Element is selected
  • data-[disabled] - Element is disabled
  • data-[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

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 with links and router

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 with groups

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) - Complete guide

Dialog is a modal component with full focus management and accessibility.

Basic 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={() => {
                  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

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 with a form

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) - Custom select

Listbox is an accessible alternative to the native <select>, giving you full control over the appearance.

Basic 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 with multiple selection

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 - Autocomplete select

Combobox combines a text input with a dropdown list - perfect for search and autocomplete scenarios.

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) - Toggle switch

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 with icons

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 - Tabbed interfaces

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 on steroids

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 - Radio button group

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 - Animations

Headless UI provides a Transition component for smooth animations.

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>
  )
}

Integration with 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>
  )
}

Vue.js integration

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>

Best practices

1. Always use semantic HTML

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

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

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

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>

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.