Skip to main content
Technology & EngineeringUi Components Services363 lines

Headless UI

"Headless UI: unstyled accessible components for Tailwind, Dialog, Combobox, Listbox, Menu, Transition, render props, React/Vue"

Quick Summary18 lines
Headless UI provides completely unstyled, fully accessible UI components designed to integrate seamlessly with Tailwind CSS. Built by the Tailwind Labs team, it offers a small set of high-quality components that handle all the complex accessibility and interaction logic while leaving visual design entirely to you. Each component exposes its internal state through render props and data attributes, giving you full control over styling based on component state.

## Key Points

- Completely unstyled -- pairs naturally with Tailwind CSS
- WAI-ARIA compliant accessibility out of the box
- Render props expose state for conditional styling
- Available for both React and Vue
- Small focused API surface: fewer components, each done well
1. **Use the `as` prop** to control which HTML element is rendered. `<Menu as="div">` renders a div instead of a Fragment, useful for positioning context.
2. **Pair Transition with Dialog** for enter/exit animations. Use `TransitionChild` for independent animation of backdrop and panel.
3. **Use `data-[focus]` and `data-[selected]`** data attributes in v2+ instead of render props for simpler styling with Tailwind.
4. **Always provide a meaningful `onClose` handler** for Dialog. Headless UI calls this on Escape key and backdrop click.
5. **Use `multiple` prop on Listbox and Combobox** for multi-select scenarios. The value becomes an array.
6. **Use `nullable` on Combobox** to allow clearing the selection back to null.
7. **Keep the component tree flat** -- Headless UI components expect their child parts as direct or near-direct descendants.
skilldb get ui-components-services-skills/Headless UIFull skill: 363 lines
Paste into your CLAUDE.md or agent config

Headless UI Skill

Core Philosophy

Headless UI provides completely unstyled, fully accessible UI components designed to integrate seamlessly with Tailwind CSS. Built by the Tailwind Labs team, it offers a small set of high-quality components that handle all the complex accessibility and interaction logic while leaving visual design entirely to you. Each component exposes its internal state through render props and data attributes, giving you full control over styling based on component state.

Key principles:

  • Completely unstyled -- pairs naturally with Tailwind CSS
  • WAI-ARIA compliant accessibility out of the box
  • Render props expose state for conditional styling
  • Available for both React and Vue
  • Small focused API surface: fewer components, each done well

Setup

Installation

# React
npm install @headlessui/react

# Vue
npm install @headlessui/vue

# Tailwind CSS is expected to be configured in the project
npm install -D tailwindcss @tailwindcss/forms

Import Pattern

// React -- named imports from the single package
import {
  Dialog,
  DialogPanel,
  DialogTitle,
  Combobox,
  ComboboxInput,
  ComboboxOptions,
  ComboboxOption,
  Listbox,
  ListboxButton,
  ListboxOptions,
  ListboxOption,
  Menu,
  MenuButton,
  MenuItems,
  MenuItem,
  Transition,
  TransitionChild,
} from "@headlessui/react";

Key Techniques

Dialog (Modal)

import { useState } from "react";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";

export function ConfirmDeleteDialog() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)} className="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700">
        Delete Account
      </button>

      <Transition show={isOpen}>
        <Dialog onClose={() => setIsOpen(false)} className="relative z-50">
          {/* Backdrop */}
          <TransitionChild
            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/30" aria-hidden="true" />
          </TransitionChild>

          {/* Panel */}
          <div className="fixed inset-0 flex items-center justify-center p-4">
            <TransitionChild
              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-md rounded-xl bg-white p-6 shadow-xl">
                <DialogTitle className="text-lg font-semibold">Delete Account</DialogTitle>
                <p className="mt-2 text-sm text-gray-500">
                  Are you sure? This action cannot be undone. All your data will be permanently removed.
                </p>
                <div className="mt-6 flex justify-end gap-3">
                  <button onClick={() => setIsOpen(false)} className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50">
                    Cancel
                  </button>
                  <button onClick={() => setIsOpen(false)} className="rounded-md bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700">
                    Delete
                  </button>
                </div>
              </DialogPanel>
            </TransitionChild>
          </div>
        </Dialog>
      </Transition>
    </>
  );
}

Combobox (Autocomplete)

import { useState } from "react";
import { Combobox, ComboboxInput, ComboboxButton, ComboboxOptions, ComboboxOption } from "@headlessui/react";
import { Check, ChevronsUpDown } from "lucide-react";

interface Person {
  id: number;
  name: string;
}

const people: Person[] = [
  { 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" },
];

export function PeopleCombobox() {
  const [selected, setSelected] = useState<Person | null>(null);
  const [query, setQuery] = useState("");

  const filtered = query === ""
    ? people
    : people.filter((person) =>
        person.name.toLowerCase().includes(query.toLowerCase())
      );

  return (
    <Combobox value={selected} onChange={setSelected} onClose={() => setQuery("")}>
      <div className="relative w-72">
        <div className="relative">
          <ComboboxInput
            className="w-full rounded-lg border py-2 pl-3 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
            displayValue={(person: Person | null) => person?.name ?? ""}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search people..."
          />
          <ComboboxButton className="absolute inset-y-0 right-0 flex items-center pr-2">
            <ChevronsUpDown className="h-4 w-4 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 border">
          {filtered.length === 0 && query !== "" ? (
            <div className="px-4 py-2 text-sm text-gray-500">Nothing found.</div>
          ) : (
            filtered.map((person) => (
              <ComboboxOption
                key={person.id}
                value={person}
                className="relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm data-[focus]:bg-blue-50 data-[selected]:font-medium"
              >
                {({ selected }) => (
                  <>
                    <span>{person.name}</span>
                    {selected && (
                      <span className="absolute inset-y-0 left-0 flex items-center pl-3">
                        <Check className="h-4 w-4 text-blue-600" />
                      </span>
                    )}
                  </>
                )}
              </ComboboxOption>
            ))
          )}
        </ComboboxOptions>
      </div>
    </Combobox>
  );
}

Listbox (Select)

import { useState } from "react";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from "@headlessui/react";
import { Check, ChevronsUpDown } from "lucide-react";

const statuses = [
  { id: 1, name: "Active", color: "bg-green-400" },
  { id: 2, name: "Paused", color: "bg-yellow-400" },
  { id: 3, name: "Inactive", color: "bg-gray-400" },
  { id: 4, name: "Archived", color: "bg-red-400" },
];

export function StatusSelect() {
  const [selected, setSelected] = useState(statuses[0]);

  return (
    <Listbox value={selected} onChange={setSelected}>
      <div className="relative w-56">
        <ListboxButton className="relative w-full cursor-pointer rounded-lg border bg-white py-2 pl-3 pr-10 text-left text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
          <span className="flex items-center gap-2">
            <span className={`inline-block h-2 w-2 rounded-full ${selected.color}`} />
            {selected.name}
          </span>
          <span className="absolute inset-y-0 right-0 flex items-center pr-2">
            <ChevronsUpDown className="h-4 w-4 text-gray-400" />
          </span>
        </ListboxButton>

        <ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg border z-10">
          {statuses.map((status) => (
            <ListboxOption
              key={status.id}
              value={status}
              className="relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm data-[focus]:bg-blue-50"
            >
              {({ selected: isSelected }) => (
                <>
                  <span className="flex items-center gap-2">
                    <span className={`inline-block h-2 w-2 rounded-full ${status.color}`} />
                    {status.name}
                  </span>
                  {isSelected && (
                    <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-600">
                      <Check className="h-4 w-4" />
                    </span>
                  )}
                </>
              )}
            </ListboxOption>
          ))}
        </ListboxOptions>
      </div>
    </Listbox>
  );
}

Menu (Dropdown)

import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
import { Edit, Trash, Copy, MoreVertical } from "lucide-react";

export function ActionsMenu() {
  return (
    <Menu as="div" className="relative">
      <MenuButton className="rounded-full p-1 hover:bg-gray-100">
        <MoreVertical className="h-5 w-5 text-gray-500" />
      </MenuButton>

      <MenuItems className="absolute right-0 mt-1 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg border z-10">
        <MenuItem>
          <button className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100">
            <Edit className="h-4 w-4" /> Edit
          </button>
        </MenuItem>
        <MenuItem>
          <button className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 data-[focus]:bg-gray-100">
            <Copy className="h-4 w-4" /> Duplicate
          </button>
        </MenuItem>
        <MenuItem>
          <button className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 data-[focus]:bg-red-50">
            <Trash className="h-4 w-4" /> Delete
          </button>
        </MenuItem>
      </MenuItems>
    </Menu>
  );
}

Transition

import { Transition } from "@headlessui/react";
import { useState } from "react";

export function NotificationBanner() {
  const [show, setShow] = useState(true);

  return (
    <>
      <Transition
        show={show}
        enter="transition-all duration-300 ease-out"
        enterFrom="opacity-0 -translate-y-4"
        enterTo="opacity-100 translate-y-0"
        leave="transition-all duration-200 ease-in"
        leaveFrom="opacity-100 translate-y-0"
        leaveTo="opacity-0 -translate-y-4"
      >
        <div className="rounded-lg bg-blue-50 border border-blue-200 p-4 flex items-center justify-between">
          <p className="text-sm text-blue-800">A new software update is available.</p>
          <button onClick={() => setShow(false)} className="text-sm font-medium text-blue-600 hover:text-blue-800">
            Dismiss
          </button>
        </div>
      </Transition>
      {!show && (
        <button onClick={() => setShow(true)} className="text-sm text-blue-600 underline">
          Show notification
        </button>
      )}
    </>
  );
}

Data Attributes for Styling (v2+)

// Headless UI v2 exposes data attributes for styling without render props
<ComboboxOption
  value={item}
  className="py-2 px-4 text-sm
    data-[focus]:bg-blue-100
    data-[selected]:font-bold
    data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
>
  {item.name}
</ComboboxOption>

// You can also use render props for more complex logic
<ComboboxOption value={item}>
  {({ focus, selected, disabled }) => (
    <div className={`py-2 px-4 ${focus ? "bg-blue-100" : ""} ${selected ? "font-bold" : ""}`}>
      {item.name}
    </div>
  )}
</ComboboxOption>

Best Practices

  1. Use the as prop to control which HTML element is rendered. <Menu as="div"> renders a div instead of a Fragment, useful for positioning context.
  2. Pair Transition with Dialog for enter/exit animations. Use TransitionChild for independent animation of backdrop and panel.
  3. Use data-[focus] and data-[selected] data attributes in v2+ instead of render props for simpler styling with Tailwind.
  4. Always provide a meaningful onClose handler for Dialog. Headless UI calls this on Escape key and backdrop click.
  5. Use multiple prop on Listbox and Combobox for multi-select scenarios. The value becomes an array.
  6. Use nullable on Combobox to allow clearing the selection back to null.
  7. Keep the component tree flat -- Headless UI components expect their child parts as direct or near-direct descendants.

Anti-Patterns

  1. Adding your own keyboard handlers for arrow keys, Enter, Escape, etc. Headless UI manages all keyboard interactions internally. Adding your own will cause conflicts.
  2. Using CSS display: none to hide dropdowns instead of letting Headless UI manage mount/unmount. The component handles focus trapping and cleanup on unmount.
  3. Wrapping MenuItems or ListboxOptions in extra divs that break the parent-child relationship. This disrupts keyboard navigation and ARIA tree.
  4. Forgetting to add z-index to dropdown/popover content. Since these render in-place (no portal by default), they need appropriate z-index to appear above surrounding content.
  5. Using Headless UI just for styling when you only need simple interactive elements. If you do not need the accessibility behavior, plain HTML with Tailwind is lighter.
  6. Mixing controlled and uncontrolled patterns by providing both value and defaultValue. Pick one approach and stick with it.

Install this skill directly: skilldb add ui-components-services-skills

Get CLI access →