toolchains/ui/components/headlessui/SKILL.md
Headless UI - Unstyled, fully accessible UI components for React and Vue with built-in ARIA patterns
npx skillsauth add bobmatnyc/claude-mpm-skills headlessuiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Headless UI provides completely unstyled, fully accessible UI components designed to integrate beautifully with Tailwind CSS. Built by the Tailwind Labs team, it offers production-ready accessibility without imposing design decisions.
Key Features:
Installation:
# React
npm install @headlessui/react
# Vue
npm install @headlessui/vue
Accessible dropdown menus with keyboard navigation and ARIA support.
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
function DropdownMenu() {
return (
<Menu>
<MenuButton className="inline-flex items-center gap-2 rounded-md bg-gray-800 py-1.5 px-3 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:outline-none data-[hover]:bg-gray-700 data-[open]:bg-gray-700 data-[focus]:outline-1 data-[focus]:outline-white">
Options
<ChevronDownIcon className="size-4 fill-white/60" />
</MenuButton>
<MenuItems
transition
anchor="bottom end"
className="w-52 origin-top-right rounded-xl border border-white/5 bg-white/5 p-1 text-sm/6 text-white transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 data-[focus]:bg-white/10">
Edit
</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
</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">
Delete
</button>
</MenuItem>
</MenuItems>
</Menu>
)
}
Menu Features:
Custom select/dropdown component with full keyboard support.
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid'
import { useState } from 'react'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
]
function SelectExample() {
const [selected, setSelected] = useState(people[0])
return (
<Listbox value={selected} onChange={setSelected}>
<ListboxButton className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
<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" aria-hidden="true" />
</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 sm:text-sm">
{people.map((person) => (
<ListboxOption
key={person.id}
value={person}
className="relative cursor-default select-none py-2 pl-10 pr-4 data-[focus]:bg-amber-100 data-[focus]:text-amber-900"
>
{({ 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-amber-600">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
)
}
Listbox Features:
Searchable select component with filtering.
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/react'
import { useState } from 'react'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
]
function AutocompleteExample() {
const [selected, setSelected] = useState(people[0])
const [query, setQuery] = useState('')
const filtered =
query === ''
? people
: people.filter((person) =>
person.name.toLowerCase().includes(query.toLowerCase())
)
return (
<Combobox value={selected} onChange={setSelected}>
<ComboboxInput
className="w-full rounded-lg border-none bg-white/5 py-1.5 pr-8 pl-3 text-sm/6 text-white focus:outline-none data-[focus]:outline-2 data-[focus]:-outline-offset-2 data-[focus]:outline-white/25"
displayValue={(person) => person?.name}
onChange={(event) => setQuery(event.target.value)}
/>
<ComboboxOptions className="w-[var(--input-width)] rounded-xl border border-white/5 bg-white/5 p-1 [--anchor-gap:var(--spacing-1)] empty:invisible">
{filtered.map((person) => (
<ComboboxOption
key={person.id}
value={person}
className="group flex cursor-default items-center gap-2 rounded-lg py-1.5 px-3 select-none data-[focus]:bg-white/10"
>
<CheckIcon className="invisible size-4 fill-white group-data-[selected]:visible" />
<div className="text-sm/6 text-white">{person.name}</div>
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
)
}
Combobox Features:
Accessible modal dialogs with focus trapping.
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { Fragment, useState } from 'react'
function ModalExample() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open dialog</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={() => setIsOpen(false)}>
<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/25" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<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-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6 text-gray-900">
Payment successful
</DialogTitle>
<div className="mt-2">
<p className="text-sm text-gray-500">
Your payment has been successfully submitted.
</p>
</div>
<div className="mt-4">
<button
type="button"
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={() => setIsOpen(false)}
>
Got it, thanks!
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</>
)
}
Dialog Features:
Floating panels for tooltips, dropdowns, and more.
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
function PopoverExample() {
return (
<Popover className="relative">
<PopoverButton className="inline-flex items-center gap-2 rounded-md bg-gray-800 py-1.5 px-3 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:outline-none data-[hover]:bg-gray-700 data-[focus]:outline-1 data-[focus]:outline-white">
Solutions
</PopoverButton>
<PopoverPanel
transition
anchor="bottom"
className="divide-y divide-white/5 rounded-xl bg-white/5 text-sm/6 transition duration-200 ease-in-out [--anchor-gap:var(--spacing-5)] data-[closed]:-translate-y-1 data-[closed]:opacity-0"
>
<div className="p-3">
<a className="block rounded-lg py-2 px-3 transition hover:bg-white/5" href="#">
<p className="font-semibold text-white">Insights</p>
<p className="text-white/50">Measure actions your users take</p>
</a>
<a className="block rounded-lg py-2 px-3 transition hover:bg-white/5" href="#">
<p className="font-semibold text-white">Automations</p>
<p className="text-white/50">Create your own targeted content</p>
</a>
</div>
</PopoverPanel>
</Popover>
)
}
Popover Features:
Accessible radio button groups.
import { RadioGroup, RadioGroupOption, RadioGroupLabel } from '@headlessui/react'
import { useState } from 'react'
const plans = [
{ name: 'Startup', ram: '12GB', cpus: '6 CPUs', disk: '160 GB SSD disk' },
{ name: 'Business', ram: '16GB', cpus: '8 CPUs', disk: '512 GB SSD disk' },
{ name: 'Enterprise', ram: '32GB', cpus: '12 CPUs', disk: '1024 GB SSD disk' },
]
function RadioExample() {
const [selected, setSelected] = useState(plans[0])
return (
<RadioGroup value={selected} onChange={setSelected}>
<RadioGroupLabel className="sr-only">Server size</RadioGroupLabel>
<div className="space-y-2">
{plans.map((plan) => (
<RadioGroupOption
key={plan.name}
value={plan}
className="relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-md focus:outline-none data-[focus]:outline-2 data-[focus]:outline-white/75 data-[checked]:bg-sky-900/75"
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="text-sm">
<RadioGroupLabel as="p" className="font-medium text-white">
{plan.name}
</RadioGroupLabel>
<div className="flex gap-2 text-white/50">
<div>{plan.ram}</div>
<div aria-hidden="true">·</div>
<div>{plan.cpus}</div>
<div aria-hidden="true">·</div>
<div>{plan.disk}</div>
</div>
</div>
</div>
</div>
</RadioGroupOption>
))}
</div>
</RadioGroup>
)
}
RadioGroup Features:
Accessible toggle switches.
import { Switch } from '@headlessui/react'
import { useState } from 'react'
function SwitchExample() {
const [enabled, setEnabled] = useState(false)
return (
<Switch
checked={enabled}
onChange={setEnabled}
className="group inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition data-[checked]:bg-blue-600"
>
<span className="size-4 translate-x-1 rounded-full bg-white transition group-data-[checked]:translate-x-6" />
</Switch>
)
}
Switch Features:
Accessible tab navigation.
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
function TabExample() {
const categories = [
{
name: 'Recent',
posts: [
{ id: 1, title: 'Does drinking coffee make you smarter?' },
{ id: 2, title: "So you've bought coffee... now what?" },
],
},
{
name: 'Popular',
posts: [
{ id: 1, title: 'Is tech making coffee better or worse?' },
{ id: 2, title: 'The most innovative things happening in coffee' },
],
},
]
return (
<TabGroup>
<TabList className="flex space-x-1 rounded-xl bg-blue-900/20 p-1">
{categories.map((category) => (
<Tab
key={category.name}
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] data-[focus]:outline-1"
>
{category.name}
</Tab>
))}
</TabList>
<TabPanels className="mt-2">
{categories.map((category, 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>
{category.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>
</li>
))}
</ul>
</TabPanel>
))}
</TabPanels>
</TabGroup>
)
}
Tab Features:
Expandable content sections.
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'
import { ChevronUpIcon } from '@heroicons/react/20/solid'
function DisclosureExample() {
return (
<Disclosure>
{({ open }) => (
<>
<DisclosureButton className="flex w-full justify-between rounded-lg bg-purple-100 px-4 py-2 text-left text-sm font-medium text-purple-900 hover:bg-purple-200 focus:outline-none focus-visible:ring focus-visible:ring-purple-500/75">
<span>What is your refund policy?</span>
<ChevronUpIcon
className={`${open ? 'rotate-180 transform' : ''} h-5 w-5 text-purple-500`}
/>
</DisclosureButton>
<DisclosurePanel className="px-4 pb-2 pt-4 text-sm text-gray-500">
If you're unhappy with your purchase for any reason, email us within 90 days and we'll refund you in full, no questions asked.
</DisclosurePanel>
</>
)}
</Disclosure>
)
}
Disclosure Features:
Animation component for enter/leave transitions.
import { Transition } from '@headlessui/react'
import { useState } from 'react'
function TransitionExample() {
const [isShowing, setIsShowing] = useState(false)
return (
<>
<button onClick={() => setIsShowing(!isShowing)}>Toggle</button>
<Transition
show={isShowing}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="rounded-md bg-blue-500 p-4 text-white">
I will fade in and out
</div>
</Transition>
</>
)
}
Transition Features:
Access component state for custom rendering.
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
function RenderPropsExample() {
return (
<Listbox value={selected} onChange={setSelected}>
{({ open }) => (
<>
<ListboxButton>
Options {open ? '▲' : '▼'}
</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">
{({ selected, focus }) => (
<div className={focus ? 'bg-blue-500' : ''}>
{selected && '✓'} Option A
</div>
)}
</ListboxOption>
</ListboxOptions>
</>
)}
</Listbox>
)
}
Full control over component state.
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
import { useState } from 'react'
function ControlledTabs() {
const [selectedIndex, setSelectedIndex] = useState(0)
return (
<TabGroup selectedIndex={selectedIndex} onChange={setSelectedIndex}>
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
<button onClick={() => setSelectedIndex(0)}>Reset to first tab</button>
</TabGroup>
)
}
Render components outside DOM hierarchy.
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
import { createPortal } from 'react-dom'
function PortalMenu() {
return (
<Menu>
<MenuButton>Options</MenuButton>
{createPortal(
<MenuItems>
<MenuItem>
<button>Edit</button>
</MenuItem>
<MenuItem>
<button>Delete</button>
</MenuItem>
</MenuItems>,
document.body
)}
</Menu>
)
}
Use with form libraries like React Hook Form.
import { Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'
import { useForm, Controller } from 'react-hook-form'
function FormExample() {
const { control, handleSubmit } = useForm()
const onSubmit = (data) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="country"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Listbox {...field}>
<ListboxButton>Select country</ListboxButton>
<ListboxOptions>
<ListboxOption value="us">United States</ListboxOption>
<ListboxOption value="ca">Canada</ListboxOption>
<ListboxOption value="mx">Mexico</ListboxOption>
</ListboxOptions>
</Listbox>
)}
/>
<button type="submit">Submit</button>
</form>
)
}
Headless UI works identically in Vue 3.
<script setup>
import { ref } from 'vue'
import {
Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
} from '@headlessui/vue'
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
]
const selectedPerson = ref(people[0])
</script>
<template>
<Listbox v-model="selectedPerson">
<ListboxButton>{{ selectedPerson.name }}</ListboxButton>
<ListboxOptions>
<ListboxOption
v-for="person in people"
:key="person.id"
:value="person"
v-slot="{ active, selected }"
>
<li :class="{ 'bg-blue-500': active }">
{{ selected ? '✓' : '' }} {{ person.name }}
</li>
</ListboxOption>
</ListboxOptions>
</Listbox>
</template>
Full type safety with TypeScript.
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
interface User {
id: number
name: string
role: 'admin' | 'user'
}
interface UserMenuProps {
user: User
onEdit: (user: User) => void
onDelete: (userId: number) => void
}
function UserMenu({ user, onEdit, onDelete }: UserMenuProps) {
return (
<Menu as="div" className="relative">
<MenuButton className="btn">{user.name}</MenuButton>
<MenuItems className="menu">
<MenuItem>
{({ focus }) => (
<button
className={focus ? 'bg-blue-500' : ''}
onClick={() => onEdit(user)}
>
Edit
</button>
)}
</MenuItem>
<MenuItem>
{({ focus }) => (
<button
className={focus ? 'bg-red-500' : ''}
onClick={() => onDelete(user.id)}
>
Delete
</button>
)}
</MenuItem>
</MenuItems>
</Menu>
)
}
Headless UI is designed for Tailwind CSS.
Headless UI v2 uses data attributes for state styling.
// Modern approach with data attributes
<MenuButton className="data-[active]:bg-blue-500 data-[disabled]:opacity-50">
Options
</MenuButton>
// Available states
// data-[active] - Element is active/focused
// data-[selected] - Element is selected
// data-[disabled] - Element is disabled
// data-[open] - Element/panel is open
// data-[focus] - Element has focus
// data-[checked] - Element is checked (Switch)
Configure Tailwind for Headless UI states.
// tailwind.config.js
module.exports = {
plugins: [
require('@headlessui/tailwindcss')
]
}
Now use modifiers:
<MenuButton className="ui-active:bg-blue-500 ui-disabled:opacity-50">
Options
</MenuButton>
All ARIA attributes managed automatically:
aria-expanded on disclosure buttonsaria-selected on tab/option elementsaria-checked on switchesaria-labelledby for associationsaria-describedby for descriptionsrole attributes (menu, listbox, dialog, etc.)Full keyboard support built-in:
Automatic focus handling:
Tested with:
Fully compatible with Next.js, Remix, and other SSR frameworks.
// app/page.tsx (Next.js 13+)
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
export default function Page() {
return (
<Menu>
<MenuButton>Options</MenuButton>
<MenuItems>
<MenuItem>
<button>Edit</button>
</MenuItem>
</MenuItems>
</Menu>
)
}
No special configuration needed - components work identically on server and client.
❌ Missing Tailwind classes for states:
// WRONG - no visual feedback
<MenuButton>Options</MenuButton>
// CORRECT
<MenuButton className="data-[active]:bg-blue-500 data-[open]:bg-blue-600">
Options
</MenuButton>
❌ Not using Fragment for render props:
// WRONG - adds extra div
<Transition show={isOpen}>
<div>Content</div>
</Transition>
// CORRECT
<Transition show={isOpen} as={Fragment}>
<div>Content</div>
</Transition>
❌ Forgetting to handle controlled state:
// WRONG - onChange does nothing
<Listbox value={selected}>
<ListboxOptions>...</ListboxOptions>
</Listbox>
// CORRECT
<Listbox value={selected} onChange={setSelected}>
<ListboxOptions>...</ListboxOptions>
</Listbox>
development
Optimize web performance using Core Web Vitals, modern patterns (View Transitions, Speculation Rules), and framework-specific techniques
development
Best practices for documenting APIs and code interfaces, eliminating redundant documentation guidance per agent.
development
Comprehensive API design patterns covering REST, GraphQL, gRPC, versioning, authentication, and modern API best practices
development
Visual verification workflow for UI changes to accelerate code review and catch ...