← All posts
·12 min read

Claude Code with Headless UI: Accessible Primitives Without ARIA Bugs

Claude CodeHeadless UIAccessibilityReact
Claude Code with Headless UI: Accessible primitives without ARIA bugs

Why Headless UI without CLAUDE.md ships components with broken keyboard navigation

Headless UI from Tailwind Labs is the accessibility-first primitive library that gives you unstyled, fully accessible components: Menu, Listbox, Dialog, Combobox, RadioGroup, Tabs, Disclosure, Popover, Switch, Transition. The promise is that you bring the Tailwind, the library brings the ARIA roles, the keyboard handlers, the focus management, the outside-click semantics. The promise is real. The footgun is that the components depend on a specific composition shape, and the moment you wrap a primitive in your own component without re-exposing the right props, the accessibility tree breaks and the keyboard navigation stops working.

Claude Code does not know any of this by default. It generates <Menu>...<Menu.Button>...<Menu.Items>...<Menu.Item>...</Menu>, ships an export, and stops. The result demos beautifully and breaks in three places: arrow-key navigation does not move focus because the items were wrapped in a non-forwarding parent, escape does not close the menu because the dialog ancestor swallowed the key event, and screen reader announcements collapse because the aria-haspopup got duplicated by a defensive ARIA attribute Claude added without realising the primitive already set it.

The most common Claude defaults that break Headless UI in production: components imported from @headlessui/react v1 syntax against v2 packages, the as prop set to a custom component without forwarding refs so focus management breaks, render-prop children mixed with regular children causing the primitive to render twice, controlled state passed without an onChange so the component falls into read-only mode, transitions wired with the legacy <Transition> instead of v2's built-in data-* attributes, and outside-click handlers added on top of Dialog that interfere with the focus trap.

This guide covers the CLAUDE.md configuration that locks Claude Code into Headless UI's v2 patterns: the new dot-free component imports, the as prop with proper ref forwarding, controlled vs uncontrolled with explicit decision rules, the data-attribute-based transitions, the anchor positioning API for floating components, and the focus management discipline. For the Tailwind variant integration that pairs cleanly with Headless UI's data-* attributes, Claude Code with Tailwind covers the variant patterns that work with Headless UI's state model.

The Headless UI CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Headless UI project it needs to declare: the package version, the v2 component composition rules, the as prop and ref forwarding patterns, the controlled vs uncontrolled decision, the data-attribute transitions, the anchor positioning, and the hard rules.

# Headless UI rules

## Stack
- @headlessui/react ^2.x (v2 with new composition API)
- @headlessui/tailwindcss for the data-attribute Tailwind plugin
- Tailwind CSS ^3.4 with the headlessui plugin enabled
- React 18 with Server Components, primitives rendered in client components

## Project structure
- src/ui/                      , app-specific UI primitives wrapping Headless UI
- src/ui/menu/                 , Menu compositions (action menus, dropdowns)
- src/ui/dialog/               , Dialog compositions (modals, drawers)
- src/ui/combobox/             , Combobox compositions (autocomplete, search)
- src/ui/listbox/              , Listbox compositions (select, multi-select)
- src/ui/tabs/                 , Tabs compositions (segmented controls)

## Component API (MANDATORY)
- Use v2 dot-free imports: Menu, MenuButton, MenuItems, MenuItem (NOT Menu.Button)
- Use the `as` prop to swap the underlying element, NEVER wrap in a parent
- Pass `as={Fragment}` to render-prop children when wrapping in your own component
- ALWAYS forward ref through your wrapper if the wrapped primitive accepts refs
- NEVER add aria-* attributes that the primitive already manages

## State (MANDATORY)
- Uncontrolled by default: let the primitive manage open/value/selection state
- Controlled ONLY when you need to sync with external state (URL, store, sibling)
- Controlled state: pass BOTH the value/open prop AND the onChange/onClose callback
- NEVER pass only `open` without `onClose` (component becomes uncontrollable)

## Transitions (MANDATORY)
- Use v2's built-in transitions via data-[state] Tailwind variants
- data-[closed] for closed state, data-[open] for open state
- data-[enter] data-[leave] for in-flight transitions
- NEVER use the legacy <Transition> component in v2 projects

## Anchor positioning (Menu, Listbox, Combobox, Popover)
- Use the `anchor` prop with placement strings: "bottom start", "top end", etc.
- Floating UI is bundled, do not add @floating-ui/react separately
- Modal anchoring via Dialog's built-in panel positioning

## Focus management
- Dialog handles focus trap automatically, do NOT add a custom trap
- Initial focus via initialFocus prop, points to a ref
- ALWAYS provide a focusable element inside Dialog (the close button counts)
- NEVER call .focus() manually on a primitive's child, the primitive routes focus

## Hard rules
- NEVER use v1 dot-syntax (Menu.Button) with v2 packages
- NEVER wrap a primitive in a non-forwarding parent component
- NEVER add controlled `open` without `onClose`
- NEVER duplicate ARIA attributes the primitive already manages
- NEVER manually trap focus inside a Dialog (the primitive does it)
- ALWAYS forward refs through wrapper components
- ALWAYS use data-* variants for transitions in v2 projects

Five rules here prevent the majority of incidents Claude generates without them.

The v2 dot-free import rule prevents the silent API drift. Headless UI v2 changed the public API from <Menu.Button> to <MenuButton>. The v1 dot syntax still compiles against v2 because the static properties exist for backwards-compat, but several behaviours changed: anchor positioning is now a prop on the items container, transitions are now data-attribute-driven, and refs forward differently. Claude reaches for v1 patterns from its training data and produces code that runs but misbehaves. The rule "use v2 dot-free imports" forces the new API by name.

The as prop rule prevents the focus management break. Headless UI's primitives render as specific elements (button, ul, li, etc.) and attach event handlers and ARIA attributes directly to that element. Wrapping the primitive in your own component (<MyButton><MenuButton>...</MenuButton></MyButton>) puts a layer between the primitive and the DOM, which breaks ref forwarding and shifts the focus target. The correct pattern is <MenuButton as={MyButton}>, which makes the primitive render MyButton directly. The rule makes this explicit.

The controlled state rule prevents the read-only trap. A <Dialog open={isOpen}> without an onClose callback makes the primitive controlled but unable to close itself: pressing escape, clicking outside, or pressing the close button calls a callback that does not exist. The dialog stays open until the parent's state changes externally. The rule "controlled state: pass BOTH the value/open prop AND the onChange/onClose callback" forces the pair.

The data-attribute transition rule prevents the dual-transition bug. Headless UI v2 ships its own transition system based on data-[open], data-[closed], data-[enter], data-[leave] attributes that Tailwind variants can target. Engineers reach for the legacy <Transition> wrapper from v1, which is still exported but conflicts with the new system: both fire on state change, and the visual result is a transition that runs twice. The rule "use v2's built-in transitions" eliminates the conflict.

The focus trap rule prevents the keyboard-stuck bug. Dialog includes a focus trap that catches Tab presses inside the modal and routes them through the focusable descendants. Engineers see modals and reach for focus-trap-react because that is the canonical solution outside Headless UI. Two focus traps active simultaneously fight each other, and the user gets stuck on the wrong element. The rule "Dialog handles focus trap automatically" prevents the duplicate.

Install and basic Menu composition

Install Headless UI v2 and the Tailwind plugin:

pnpm add @headlessui/react @headlessui/tailwindcss

Add the plugin to tailwind.config.js:

// tailwind.config.js
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: { extend: {} },
  plugins: [
    require('@headlessui/tailwindcss'),
  ],
};

A complete Menu component using v2 patterns:

// src/ui/menu/ActionMenu.tsx
'use client';

import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
import { Fragment, type ReactNode } from 'react';

type Action = {
  id: string;
  label: string;
  onSelect: () => void;
  disabled?: boolean;
};

type Props = {
  trigger: ReactNode;
  actions: Action[];
};

export function ActionMenu({ trigger, actions }: Props) {
  return (
    <Menu>
      <MenuButton as={Fragment}>{trigger}</MenuButton>
      <MenuItems
        anchor="bottom end"
        className="
          mt-2 w-56 rounded-lg bg-white shadow-lg ring-1 ring-black/10
          focus:outline-none
          transition duration-150 ease-out
          data-[closed]:opacity-0 data-[closed]:scale-95
          data-[open]:opacity-100 data-[open]:scale-100
        "
      >
        {actions.map((action) => (
          <MenuItem
            key={action.id}
            disabled={action.disabled}
            as="button"
            type="button"
            onClick={action.onSelect}
            className="
              flex w-full items-center px-4 py-2 text-sm text-zinc-700
              data-[focus]:bg-zinc-100 data-[focus]:text-zinc-900
              data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed
            "
          >
            {action.label}
          </MenuItem>
        ))}
      </MenuItems>
    </Menu>
  );
}

Three things in this snippet that the CLAUDE.md rule enforces. The imports are the v2 dot-free names: Menu, MenuButton, MenuItems, MenuItem. The MenuButton uses as={Fragment} so the consumer-provided trigger renders directly without an extra DOM wrapper. The MenuItems uses the anchor prop for positioning, which is v2's bundled Floating UI integration. The transitions are data-attribute Tailwind variants, not the legacy <Transition> component.

The as="button" type="button" on MenuItem is the right pattern for action menus: the item is a real button, the click is the action, the keyboard works for free because Headless UI's roving tabindex and arrow-key handling target the underlying element.

Authoring a Dialog with focus management

Dialog is the modal primitive. It handles focus trap, escape-to-close, outside-click-to-close, scroll lock, and ARIA roles automatically. The right composition pattern keeps all of that working.

// src/ui/dialog/ConfirmDialog.tsx
'use client';

import {
  Dialog,
  DialogPanel,
  DialogTitle,
  DialogBackdrop,
} from '@headlessui/react';
import { useRef } from 'react';

type Props = {
  open: boolean;
  onClose: () => void;
  title: string;
  description: string;
  confirmLabel: string;
  cancelLabel: string;
  onConfirm: () => void;
};

export function ConfirmDialog({
  open,
  onClose,
  title,
  description,
  confirmLabel,
  cancelLabel,
  onConfirm,
}: Props) {
  const cancelRef = useRef<HTMLButtonElement>(null);

  return (
    <Dialog
      open={open}
      onClose={onClose}
      initialFocus={cancelRef}
      className="relative z-50"
    >
      <DialogBackdrop
        transition
        className="
          fixed inset-0 bg-black/50
          transition duration-200 ease-out
          data-[closed]:opacity-0 data-[open]:opacity-100
        "
      />
      <div className="fixed inset-0 grid place-items-center p-4">
        <DialogPanel
          transition
          className="
            w-full max-w-md rounded-xl bg-white p-6 shadow-xl
            transition duration-200 ease-out
            data-[closed]:opacity-0 data-[closed]:scale-95
            data-[open]:opacity-100 data-[open]:scale-100
          "
        >
          <DialogTitle className="text-lg font-semibold text-zinc-900">
            {title}
          </DialogTitle>
          <p className="mt-2 text-sm text-zinc-600">{description}</p>
          <div className="mt-6 flex justify-end gap-3">
            <button
              ref={cancelRef}
              type="button"
              onClick={onClose}
              className="rounded-md px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100"
            >
              {cancelLabel}
            </button>
            <button
              type="button"
              onClick={onConfirm}
              className="rounded-md bg-zinc-900 px-4 py-2 text-sm text-white hover:bg-zinc-800"
            >
              {confirmLabel}
            </button>
          </div>
        </DialogPanel>
      </div>
    </Dialog>
  );
}

Three things in this snippet that the CLAUDE.md rule enforces. The Dialog is controlled with both open and onClose. The initialFocus prop points the focus trap at the cancel button on mount, which is the right default for confirm dialogs (the user can press escape to back out). The transition prop on Backdrop and Panel enables the v2 data-attribute transition system, paired with Tailwind variants that target data-[closed] and data-[open].

Add a Dialog rule to CLAUDE.md:

## Dialog composition

- Dialog open + onClose pair is mandatory
- initialFocus points to a ref for the element to focus on mount
- DialogBackdrop with transition for the backdrop fade
- DialogPanel with transition for the panel transform
- DialogTitle is required for screen reader announcement
- NEVER add aria-modal, aria-labelledby manually (Dialog manages these)

Combobox: autocomplete that respects keyboard

Combobox is the searchable select primitive. It combines a text input, a filtered list, and keyboard navigation. The composition pattern is more involved because the input and the list are both first-class.

// src/ui/combobox/UserCombobox.tsx
'use client';

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

type User = { id: string; name: string; email: string };

type Props = {
  users: User[];
  value: User | null;
  onChange: (user: User | null) => void;
};

export function UserCombobox({ users, value, onChange }: Props) {
  const [query, setQuery] = useState('');
  const filtered = useMemo(() => {
    if (!query) return users;
    const q = query.toLowerCase();
    return users.filter(
      (u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
    );
  }, [users, query]);

  return (
    <Combobox value={value} onChange={onChange}>
      <div className="relative">
        <ComboboxInput
          aria-label="User"
          displayValue={(u: User | null) => u?.name ?? ''}
          onChange={(e) => setQuery(e.target.value)}
          className="
            w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm
            focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500
          "
        />
        <ComboboxButton className="absolute inset-y-0 right-2 grid place-items-center">
          <span aria-hidden="true" className="text-zinc-500">v</span>
        </ComboboxButton>
        <ComboboxOptions
          anchor="bottom"
          className="
            mt-1 max-h-60 w-[var(--button-width)] overflow-auto
            rounded-md bg-white shadow-lg ring-1 ring-black/10
            focus:outline-none
          "
        >
          {filtered.length === 0 && query !== '' ? (
            <div className="px-3 py-2 text-sm text-zinc-500">No matches</div>
          ) : (
            filtered.map((user) => (
              <ComboboxOption
                key={user.id}
                value={user}
                className="
                  flex cursor-pointer items-center px-3 py-2 text-sm text-zinc-700
                  data-[focus]:bg-zinc-100
                  data-[selected]:font-semibold
                "
              >
                <div>
                  <div>{user.name}</div>
                  <div className="text-xs text-zinc-500">{user.email}</div>
                </div>
              </ComboboxOption>
            ))
          )}
        </ComboboxOptions>
      </div>
    </Combobox>
  );
}

Three things in this snippet that the CLAUDE.md rule enforces. The Combobox is controlled with value and onChange, which is the right pattern for a select that contributes to a form. The ComboboxInput uses displayValue to render the selected user's name in the input, which is the canonical Headless UI pattern for object-valued selects. The ComboboxOptions uses anchor="bottom" with w-[var(--button-width)] to match the input width, which is the v2 anchor positioning API exposing CSS variables for layout.

The query state is local to the component, which is intentional: the query is ephemeral UI state, not a value that survives unmount. The useMemo filter is the right granularity because filtering runs on every keystroke.

Common Claude Code mistakes with Headless UI

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. v1 dot syntax against v2 packages

Claude generates <Menu.Button> and <Menu.Items> from training data that pre-dates v2. The code compiles because the static properties are still exported, but anchor positioning, transitions, and ref forwarding all behave differently.

Correct pattern: <MenuButton> and <MenuItems> as standalone imports.

2. Wrapping a primitive without as or ref forwarding

Claude generates:

function MyButton({ children, ...props }) {
  return <button {...props}>{children}</button>;
}
<MenuButton><MyButton>Open</MyButton></MenuButton>

The MyButton swallows the props and ref the primitive needs to attach. Focus management breaks.

Correct pattern: <MenuButton as={MyButton}>Open</MenuButton> with MyButton using forwardRef.

3. Controlled open without onClose

Claude generates <Dialog open={isOpen}> and forgets the onClose. The dialog cannot dismiss itself.

Correct pattern: <Dialog open={isOpen} onClose={() => setIsOpen(false)}>.

4. Legacy Transition in v2 projects

Claude generates a <Transition> wrapper around <DialogPanel>. The v2 Panel runs its own transition via data attributes, the legacy wrapper runs another. Two transitions, conflicting timing.

Correct pattern: transition prop on DialogPanel plus Tailwind variants targeting data-[closed] and data-[open].

5. Manual ARIA attributes

Claude generates <MenuButton aria-haspopup="menu" aria-expanded={isOpen}>. The primitive already sets these. The duplicate makes the accessibility tree inconsistent.

Correct pattern: trust the primitive and only add ARIA the primitive does not manage.

6. Custom focus trap on top of Dialog

Claude generates <Dialog>...<FocusTrap>...</FocusTrap>...</Dialog>. Two focus traps fight for control, the user gets stuck.

Correct pattern: use Dialog's built-in trap, configure with initialFocus if needed.

Add this list to CLAUDE.md with the corrected form for each.

Permission hooks for Headless UI projects

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(pnpm add @headlessui/*)",
      "Bash(pnpm test*)",
      "Bash(pnpm tsc --noEmit*)",
      "Bash(pnpm vitest*)"
    ],
    "deny": [
      "Bash(pnpm remove @headlessui/react*)",
      "Bash(rm -rf src/ui*)"
    ]
  }
}

Building accessible primitives that work for keyboards

The Headless UI CLAUDE.md in this guide produces components where every primitive uses the v2 dot-free import names, every wrapper uses the as prop or forwardRef so ref forwarding keeps working, every controlled state pair includes both the value and the callback so the primitive can update itself, every transition uses the v2 data-attribute system so animations do not run twice, and every Dialog trusts the built-in focus trap so the user can keyboard out of the modal.

The underlying principle is the same as any accessibility-first primitive library with Claude Code. Headless UI without a CLAUDE.md produces components that look correct in screenshots and fail every accessibility audit: keyboard navigation that does not work because focus is trapped in the wrong layer, screen reader announcements that duplicate because ARIA attributes were added on top of the primitive's own, dialogs that cannot close because the controlled state pair was incomplete, and transitions that flash because the legacy Transition wrapper fights the v2 data-attribute system.

For the Tailwind variant integration that pairs cleanly with Headless UI's data-* attributes, Claude Code with Tailwind covers the variant patterns that work with the state model. For the form composition layer that wraps comboboxes and selects in form fields, Claude Code with React Hook Form covers the patterns that suit controlled primitives. Claudify includes a Headless UI v2 CLAUDE.md template with the new import rules, the as prop discipline, the focus management contracts, and all six common-mistake rules pre-configured.

Get Claudify. Ship Headless UI components that pass accessibility audits.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame