Headless UI Testing Guide: Combobox, Listbox, and Transition Testing
Headless UI, maintained by the Tailwind Labs team, provides fully accessible unstyled components for React and Vue. Unlike Radix UI, Headless UI has a smaller surface area — focused on the most common interactive patterns. Testing it requires understanding its approach to state management and how it exposes that state for assertions.
Headless UI's Testing Characteristics
Headless UI differs from Radix UI in a few important ways:
- State via props: Components use controlled state — you manage
open,value, andselectedstate yourself asprop polymorphism: Components render asdivby default but can render as any element viaas- Transition component: Headless UI includes a
Transitioncomponent that conditionally renders and animates content data-headlessui-state: State is exposed viadata-headlessui-stateattribute (e.g.,"open","checked")
Setup
// vitest.setup.ts
import "@testing-library/jest-dom";
// Headless UI uses ResizeObserver for positioning
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};Headless UI requires fewer mocks than Radix UI — it doesn't use portals by default and avoids heavy browser API dependencies.
Testing Combobox (Autocomplete)
Combobox is the most complex Headless UI component — it handles search filtering, keyboard navigation, and custom display values:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { Combobox } from "@headlessui/react";
const frameworks = [
{ id: 1, name: "React" },
{ id: 2, name: "Vue" },
{ id: 3, name: "Svelte" },
{ id: 4, name: "Angular" },
{ id: 5, name: "SolidJS" },
];
function FrameworkCombobox({
onChange,
}: {
onChange: (framework: { id: number; name: string } | null) => void;
}) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<{ id: number; name: string } | null>(null);
const filtered =
query === ""
? frameworks
: frameworks.filter((f) =>
f.name.toLowerCase().includes(query.toLowerCase())
);
function handleChange(value: { id: number; name: string } | null) {
setSelected(value);
onChange(value);
}
return (
<Combobox value={selected} onChange={handleChange} nullable>
<Combobox.Input
aria-label="Choose framework"
displayValue={(f: { name: string } | null) => f?.name ?? ""}
onChange={(e) => setQuery(e.target.value)}
/>
<Combobox.Options>
{filtered.length === 0 ? (
<li role="option" aria-disabled>No frameworks found</li>
) : (
filtered.map((f) => (
<Combobox.Option key={f.id} value={f}>
{f.name}
</Combobox.Option>
))
)}
</Combobox.Options>
</Combobox>
);
}
it("shows all options when opened with no query", async () => {
const user = userEvent.setup();
render(<FrameworkCombobox onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox", { name: "Choose framework" }));
await waitFor(() => {
expect(screen.getByRole("option", { name: "React" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Vue" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Svelte" })).toBeInTheDocument();
});
});
it("filters options based on typed query", async () => {
const user = userEvent.setup();
render(<FrameworkCombobox onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox", { name: "Choose framework" }));
await user.type(screen.getByRole("combobox", { name: "Choose framework" }), "s");
await waitFor(() => {
expect(screen.getByRole("option", { name: "Svelte" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "SolidJS" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "React" })).not.toBeInTheDocument();
});
});
it("shows 'No frameworks found' when query has no matches", async () => {
const user = userEvent.setup();
render(<FrameworkCombobox onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
await user.type(screen.getByRole("combobox"), "zzz");
await waitFor(() => {
expect(screen.getByText("No frameworks found")).toBeInTheDocument();
});
});
it("calls onChange with selected option", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<FrameworkCombobox onChange={handleChange} />);
await user.click(screen.getByRole("combobox", { name: "Choose framework" }));
await user.click(screen.getByRole("option", { name: "Vue" }));
expect(handleChange).toHaveBeenCalledWith({ id: 2, name: "Vue" });
});
it("displays selected value in input after selection", async () => {
const user = userEvent.setup();
render(<FrameworkCombobox onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
await user.click(screen.getByRole("option", { name: "Svelte" }));
expect(screen.getByRole("combobox")).toHaveValue("Svelte");
});
it("navigates options with arrow keys", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<FrameworkCombobox onChange={handleChange} />);
await user.click(screen.getByRole("combobox"));
await user.keyboard("{ArrowDown}"); // First option (React)
await user.keyboard("{ArrowDown}"); // Second option (Vue)
await user.keyboard("{Enter}"); // Select Vue
expect(handleChange).toHaveBeenCalledWith({ id: 2, name: "Vue" });
});Testing Listbox (Single/Multi Select)
Listbox is the simpler Select equivalent. Test single and multiple selection modes:
import { Listbox } from "@headlessui/react";
function PriorityListbox({ onChange }: { onChange: (priority: string) => void }) {
const [selected, setSelected] = useState("medium");
const priorities = ["low", "medium", "high", "critical"];
return (
<Listbox
value={selected}
onChange={(v) => { setSelected(v); onChange(v); }}
>
<Listbox.Button aria-label="Select priority">
{selected}
</Listbox.Button>
<Listbox.Options>
{priorities.map((p) => (
<Listbox.Option key={p} value={p}>
{p}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
);
}
it("shows current value in button", () => {
render(<PriorityListbox onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "Select priority" })).toHaveTextContent("medium");
});
it("opens options on button click", async () => {
const user = userEvent.setup();
render(<PriorityListbox onChange={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Select priority" }));
await waitFor(() => {
expect(screen.getByRole("listbox")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(4);
});
});
it("selects new value and calls onChange", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<PriorityListbox onChange={handleChange} />);
await user.click(screen.getByRole("button", { name: "Select priority" }));
await user.click(screen.getByRole("option", { name: "critical" }));
expect(handleChange).toHaveBeenCalledWith("critical");
expect(screen.getByRole("button", { name: "Select priority" })).toHaveTextContent("critical");
});
it("marks current value as selected", async () => {
const user = userEvent.setup();
render(<PriorityListbox onChange={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Select priority" }));
const selectedOption = screen.getByRole("option", { name: "medium" });
expect(selectedOption).toHaveAttribute("aria-selected", "true");
});Testing Dialog
Headless UI's Dialog is simpler than Radix's — you control open state directly:
import { Dialog } from "@headlessui/react";
function AlertDialog({
open,
onClose,
onConfirm,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Dialog open={open} onClose={onClose}>
<Dialog.Panel>
<Dialog.Title>Unsaved Changes</Dialog.Title>
<Dialog.Description>
You have unsaved changes. Leave without saving?
</Dialog.Description>
<button onClick={onConfirm}>Leave</button>
<button onClick={onClose}>Stay</button>
</Dialog.Panel>
</Dialog>
);
}
it("renders when open is true", () => {
render(<AlertDialog open={true} onClose={vi.fn()} onConfirm={vi.fn()} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Unsaved Changes")).toBeVisible();
});
it("does not render when open is false", () => {
render(<AlertDialog open={false} onClose={vi.fn()} onConfirm={vi.fn()} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("calls onClose when clicking outside", async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
const { container } = render(
<div>
<AlertDialog open={true} onClose={handleClose} onConfirm={vi.fn()} />
<div data-testid="backdrop" />
</div>
);
// Headless UI calls onClose when clicking the backdrop
await user.keyboard("{Escape}");
expect(handleClose).toHaveBeenCalled();
});Testing Switch (Toggle)
import { Switch } from "@headlessui/react";
function NotificationSwitch({ label, onChange }: { label: string; onChange: (v: boolean) => void }) {
const [enabled, setEnabled] = useState(false);
return (
<Switch.Group>
<Switch.Label>{label}</Switch.Label>
<Switch
checked={enabled}
onChange={(v) => { setEnabled(v); onChange(v); }}
aria-label={label}
/>
</Switch.Group>
);
}
it("renders as unchecked by default", () => {
render(<NotificationSwitch label="Email alerts" onChange={vi.fn()} />);
expect(screen.getByRole("switch", { name: "Email alerts" })).toHaveAttribute("aria-checked", "false");
});
it("toggles on click and calls onChange", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<NotificationSwitch label="Email alerts" onChange={handleChange} />);
await user.click(screen.getByRole("switch", { name: "Email alerts" }));
expect(handleChange).toHaveBeenCalledWith(true);
expect(screen.getByRole("switch", { name: "Email alerts" })).toHaveAttribute("aria-checked", "true");
});
it("toggles with Space key", async () => {
const user = userEvent.setup();
render(<NotificationSwitch label="Email alerts" onChange={vi.fn()} />);
await user.tab(); // Focus the switch
await user.keyboard(" "); // Space toggles it
expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
});Testing Transition
Headless UI's Transition component conditionally renders children and applies CSS classes during enter/leave. In tests, transitions complete immediately:
import { Transition } from "@headlessui/react";
function FadePanel({ show, children }: { show: boolean; children: React.ReactNode }) {
return (
<Transition
show={show}
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div>{children}</div>
</Transition>
);
}
it("renders children when show is true", () => {
render(<FadePanel show={true}><p>Panel content</p></FadePanel>);
expect(screen.getByText("Panel content")).toBeInTheDocument();
});
it("does not render children when show is false (after transition)", async () => {
const { rerender } = render(<FadePanel show={true}><p>Panel content</p></FadePanel>);
rerender(<FadePanel show={false}><p>Panel content</p></FadePanel>);
// Transition uses CSS — in jsdom, it completes instantly
await waitFor(() => {
expect(screen.queryByText("Panel content")).not.toBeInTheDocument();
});
});
it("applies enter class during transition", () => {
render(<FadePanel show={true}><p>Content</p></FadePanel>);
// Transition is complete in jsdom — check final state
expect(screen.getByText("Content")).toBeInTheDocument();
});Testing data-headlessui-state Attributes
Headless UI exposes state via data-headlessui-state. Use this for assertions when ARIA attributes aren't sufficient:
it("marks open combobox with data-headlessui-state=open", async () => {
const user = userEvent.setup();
render(<FrameworkCombobox onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
expect(input.closest("[data-headlessui-state]")).not.toHaveAttribute(
"data-headlessui-state",
expect.stringContaining("open")
);
await user.click(input);
await waitFor(() => {
expect(input.closest("[data-headlessui-state]") ?? input).toHaveAttribute(
"data-headlessui-state",
expect.stringContaining("open")
);
});
});Summary
Testing Headless UI components effectively:
- Control state in tests — Headless UI is controlled; wrap components with state management in test helpers
- Use
waitForafter all interactions — options and dialog content appear asynchronously - Query by roles —
combobox,option,listbox,switch,dialogare all available - Test keyboard paths — Arrow keys, Enter, Space, and Escape are all load-bearing for Headless UI
- Filter test: type a query, then assert filtered options — this is Combobox's most important behavior
- Transition: test pre- and post-transition state; CSS animations run instantly in jsdom