Skip to content
Component Library

Headless UI — Component Library Guide

Completely unstyled, fully accessible UI components designed to integrate beautifully with Tailwind CSS. Built by the Tailwind CSS team.

TypeScript MIT
3.4M weekly downloads
25.2k GitHub stars
~35kB bundle size
v2.2.0 latest version
tailwindaccessibleunstyledheadlessdropdown

TL;DR: Headless UI gives you accessible interactive components (Dialog, Listbox, Combobox, Popover) with zero default styles. Built by the Tailwind team - it handles WAI-ARIA and keyboard navigation, you handle appearance with Tailwind classes. Official docs: headlessui.com.

What is Headless UI?

Headless UI provides accessible interactive components - Dialog, Listbox, Combobox, Menu, Popover, Switch, Tab - with zero default styles. It's built by the Tailwind CSS team and is the official complement to Tailwind: Headless UI handles behaviour and accessibility, you handle appearance with Tailwind classes.

When to use it

Use Headless UI if you're on Tailwind CSS and want accessible components without fighting a styling API. If you're not using Tailwind, Radix UI offers a broader component set with a similar headless approach.

Headless UI vs Radix UI: Both are headless and accessible. Headless UI has a slightly simpler API; Radix UI covers more components and provides more composability granularity. They're interchangeable for most use cases.

Key Features

  • Tailwind-first - every example in docs uses Tailwind utility classes
  • Transition API - built-in Transition component for animated enter/leave states
  • Combobox - autocomplete/search select with full keyboard support
  • v2 improvements - React 18 first, data-* attribute API for state-driven Tailwind

Installation

npm install @headlessui/react

Basic Usage

import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'

function DropdownMenu() {
  return (
    <Menu as="div" className="relative">
      <MenuButton className="px-4 py-2 bg-indigo-600 text-white rounded-lg">
        Options
      </MenuButton>
      <MenuItems className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg ring-1 ring-black/5">
        <MenuItem>
          {({ focus }) => (
            <a
              href="/profile"
              className={`block px-4 py-2 text-sm ${focus ? 'bg-indigo-50' : ''}`}
            >
              Profile
            </a>
          )}
        </MenuItem>
        <MenuItem>
          {({ focus }) => (
            <button
              className={`block w-full text-left px-4 py-2 text-sm text-red-600 ${focus ? 'bg-red-50' : ''}`}
              onClick={() => signOut()}
            >
              Sign out
            </button>
          )}
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}

Combobox (Autocomplete)

import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/react'
import { useState } from 'react'

function CountryPicker({ countries }: { countries: string[] }) {
  const [query, setQuery] = useState('')
  const [selected, setSelected] = useState(countries[0])

  const filtered = query === ''
    ? countries
    : countries.filter(c => c.toLowerCase().includes(query.toLowerCase()))

  return (
    <Combobox value={selected} onChange={setSelected}>
      <ComboboxInput
        onChange={(e) => setQuery(e.target.value)}
        className="w-full border rounded-lg px-3 py-2"
      />
      <ComboboxOptions className="absolute mt-1 w-full bg-white shadow-lg rounded-lg">
        {filtered.map(country => (
          <ComboboxOption key={country} value={country} className="px-3 py-2 cursor-pointer data-[focus]:bg-indigo-50">
            {country}
          </ComboboxOption>
        ))}
      </ComboboxOptions>
    </Combobox>
  )
}

TypeScript Tips

Use the as prop to render as your custom component types:

import { MenuItem } from '@headlessui/react'
import Link from 'next/link'

<MenuItem as={Link} href="/settings">
  Settings
</MenuItem>

Common Gotchas

  • data-* attributes (v2) - v2 replaced render props with data-headlessui-state and standard data-focus, data-selected attributes. Use Tailwind's data-[focus]: variant syntax instead of the render prop pattern
  • Portal rendering - MenuItems, ListboxOptions, etc. render inside the component tree by default (not a portal). For nested scroll contexts, wrap with <Float> from @headlessui-float/react
  • Radix UI - broader headless component set with more granular composability; not tied to Tailwind CSS
  • shadcn/ui - pre-styled component library built on Radix UI; useful when you want less configuration
  • CSS Container Queries Guide - Tailwind CSS and container queries work naturally together for adaptive component layouts

Dialog (Modal)

import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'
import { useState } from 'react'

function ConfirmDialog() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Delete account</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-sm rounded-xl bg-white p-6 shadow-xl">
            <DialogTitle className="text-lg font-semibold">Confirm deletion</DialogTitle>
            <p className="mt-2 text-sm text-gray-500">This action cannot be undone.</p>
            <div className="mt-4 flex gap-3">
              <button onClick={() => setIsOpen(false)} className="flex-1 rounded-lg border px-4 py-2 text-sm">
                Cancel
              </button>
              <button className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm text-white">
                Delete
              </button>
            </div>
          </DialogPanel>
        </div>
      </Dialog>
    </>
  )
}

Dialog automatically traps focus, closes on Escape, and sets aria-modal. No additional ARIA attributes needed.

Migrating from v1 to v2

The main breaking change in v2 is the styling API. Render props are replaced by Tailwind data-attribute variants:

v1 render propv2 data attribute
{({ active }) => <li className={active ? 'bg-blue-50' : ''}>}<li className="data-[focus]:bg-blue-50">
{({ selected }) => <span>{selected && <CheckIcon />}</span>}<span className="hidden data-[selected]:block">

Vue support was dropped in v2 (React-only). The official v2 migration guide covers all changes. Use the anchor prop on MenuItems or ListboxOptions for automatic Floating UI positioning instead of manual absolute positioning.

Frequently Asked Questions

Can I use Headless UI without Tailwind CSS?

Yes, but the documentation only shows Tailwind examples. You can pass any className string or style object. If you are not using Tailwind, Radix UI is a similar headless alternative with more styling flexibility and a larger component set.

What changed in Headless UI v2?

Headless UI v2 dropped support for Vue and focused on React. The styling API moved from render props to data attributes. Instead of using the function-as-child pattern, you now style components with Tailwind variants like data-[focus]:bg-indigo-50 directly on the element.

Is Headless UI accessible by default?

Yes. All components implement the relevant WAI-ARIA patterns. Dialog traps focus and manages aria-modal, Menu implements the ARIA menu button pattern, Combobox follows the combobox ARIA spec with full keyboard navigation.

How does Headless UI handle portal rendering for dropdowns?

By default, Headless UI components like MenuItems render in place in the DOM. If your dropdown is inside an overflow-hidden container, use the anchor prop introduced in v2 which positions the floating content relative to the trigger using Floating UI.

What is the asChild equivalent in Headless UI?

Use the as prop to render a component as a different element or component. For example, MenuItem as={Link} renders the menu item as a Next.js Link while keeping all accessibility behaviour.