Radix UI Testing Guide: Dialog, Select, Dropdown, and Keyboard Navigation
Radix UI provides unstyled, accessible React primitives that handle complex interaction patterns — keyboard navigation, focus management, ARIA attributes — so you don't have to. Testing Radix UI components effectively means verifying these accessibility behaviors work in your application context, while also validating your custom logic and styling don't break the primitives underneath.
Understanding Radix UI's Testing Characteristics
Radix UI components have specific behaviors that affect testing:
- Portals: Dialog, Select, DropdownMenu render in portals (appended to
document.bodyby default). Testing Library handles this automatically —screen.getByRole()searches the entire document. - Animation: Components use CSS transitions. In tests, animations complete instantly since jsdom doesn't run CSS.
- Data attributes: Radix exposes
[data-state],[data-open],[data-highlighted]for styling. These are useful selectors in tests. - Browser APIs: Radix uses
ResizeObserver,PointerEvent, andMutationObserver. Mock these before testing.
Setup
// vitest.setup.ts
import "@testing-library/jest-dom";
// Required for Radix UI
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Radix UI Tooltip/Popover
Element.prototype.hasPointerCapture = () => false;
Element.prototype.releasePointerCapture = () => {};
Element.prototype.setPointerCapture = () => {};
HTMLElement.prototype.scrollIntoView = () => {};Testing Dialog
Radix Dialog manages open/close state, focus trapping, and ARIA roles automatically. Test that these work in your specific implementation:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as Dialog from "@radix-ui/react-dialog";
function ConfirmationDialog({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
}) {
const [open, setOpen] = React.useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button>Delete Account</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content aria-describedby="confirm-desc">
<Dialog.Title>Delete Account</Dialog.Title>
<Dialog.Description id="confirm-desc">
This action cannot be undone.
</Dialog.Description>
<button
onClick={() => {
onConfirm();
setOpen(false);
}}
>
Yes, delete
</button>
<Dialog.Close asChild>
<button onClick={onCancel}>Cancel</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
it("opens dialog and shows correct content", async () => {
const user = userEvent.setup();
render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Delete Account" }));
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(screen.getByText("This action cannot be undone.")).toBeVisible();
});
it("calls onConfirm and closes on confirmation", async () => {
const user = userEvent.setup();
const handleConfirm = vi.fn();
render(<ConfirmationDialog onConfirm={handleConfirm} onCancel={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Delete Account" }));
await user.click(screen.getByRole("button", { name: "Yes, delete" }));
expect(handleConfirm).toHaveBeenCalledOnce();
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("calls onCancel and closes on cancel", async () => {
const user = userEvent.setup();
const handleCancel = vi.fn();
render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={handleCancel} />);
await user.click(screen.getByRole("button", { name: "Delete Account" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(handleCancel).toHaveBeenCalledOnce();
});
it("closes on Escape key", async () => {
const user = userEvent.setup();
render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Delete Account" }));
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
it("has correct ARIA attributes", async () => {
const user = userEvent.setup();
render(<ConfirmationDialog onConfirm={vi.fn()} onCancel={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Delete Account" }));
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-describedby", "confirm-desc");
expect(screen.getByRole("heading", { name: "Delete Account" })).toBeInTheDocument();
});Testing Select
Select keyboard navigation is a common test gap:
import * as Select from "@radix-ui/react-select";
function LanguageSelect({ onChange }: { onChange: (lang: string) => void }) {
return (
<Select.Root onValueChange={onChange}>
<Select.Trigger aria-label="Select language">
<Select.Value placeholder="Choose language" />
</Select.Trigger>
<Select.Portal>
<Select.Content>
<Select.Viewport>
<Select.Item value="en"><Select.ItemText>English</Select.ItemText></Select.Item>
<Select.Item value="fr"><Select.ItemText>French</Select.ItemText></Select.Item>
<Select.Item value="de"><Select.ItemText>German</Select.ItemText></Select.Item>
<Select.Item value="ja" disabled>
<Select.ItemText>Japanese (coming soon)</Select.ItemText>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
it("selects option via mouse click", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<LanguageSelect onChange={onChange} />);
await user.click(screen.getByRole("combobox", { name: "Select language" }));
await user.click(screen.getByRole("option", { name: "French" }));
expect(onChange).toHaveBeenCalledWith("fr");
});
it("navigates options with keyboard", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<LanguageSelect onChange={onChange} />);
// Open with keyboard
await user.click(screen.getByRole("combobox"));
// Arrow down to first option, then down again to French
await user.keyboard("{ArrowDown}");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
expect(onChange).toHaveBeenCalledWith("fr");
});
it("skips disabled options during keyboard navigation", async () => {
const user = userEvent.setup();
render(<LanguageSelect onChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
const disabledOption = screen.getByRole("option", { name: /Japanese/ });
expect(disabledOption).toHaveAttribute("aria-disabled", "true");
});Testing DropdownMenu
DropdownMenu supports submenus, checkboxes, and radio groups. Test each:
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
function ActionsMenu({ onAction }: { onAction: (action: string) => void }) {
const [bookmarked, setBookmarked] = React.useState(false);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button>Actions</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => onAction("edit")}>
Edit
</DropdownMenu.Item>
<DropdownMenu.CheckboxItem
checked={bookmarked}
onCheckedChange={setBookmarked}
>
Bookmarked
</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => onAction("delete")}
className="text-red-600"
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
it("opens menu and lists all items", async () => {
const user = userEvent.setup();
render(<ActionsMenu onAction={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument();
expect(screen.getByRole("menuitemcheckbox", { name: "Bookmarked" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
});
it("calls onAction when item is selected", async () => {
const user = userEvent.setup();
const handleAction = vi.fn();
render(<ActionsMenu onAction={handleAction} />);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
expect(handleAction).toHaveBeenCalledWith("edit");
});
it("toggles checkbox item state", async () => {
const user = userEvent.setup();
render(<ActionsMenu onAction={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Actions" }));
const checkbox = screen.getByRole("menuitemcheckbox", { name: "Bookmarked" });
expect(checkbox).toHaveAttribute("aria-checked", "false");
await user.click(checkbox);
// Reopen menu after selection closes it
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(screen.getByRole("menuitemcheckbox", { name: "Bookmarked" }))
.toHaveAttribute("aria-checked", "true");
});
it("closes menu when clicking outside", async () => {
const user = userEvent.setup();
render(
<div>
<ActionsMenu onAction={vi.fn()} />
<div data-testid="outside">Outside content</div>
</div>
);
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(screen.getByRole("menu")).toBeInTheDocument();
await user.click(screen.getByTestId("outside"));
await waitFor(() => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});Testing Keyboard Navigation Comprehensively
Build a keyboard navigation test helper to verify full keyboard flows:
async function navigateWithKeyboard(
user: ReturnType<typeof userEvent.setup>,
keys: string[],
assertions: (() => void)[]
) {
for (let i = 0; i < keys.length; i++) {
await user.keyboard(keys[i]);
if (assertions[i]) assertions[i]();
}
}
it("fully navigates DropdownMenu with keyboard only", async () => {
const user = userEvent.setup();
const handleAction = vi.fn();
render(<ActionsMenu onAction={handleAction} />);
// Focus the trigger
await user.tab();
expect(screen.getByRole("button", { name: "Actions" })).toHaveFocus();
// Open with Enter
await user.keyboard("{Enter}");
expect(screen.getByRole("menu")).toBeInTheDocument();
// Navigate down to "Delete"
await user.keyboard("{ArrowDown}"); // Edit
await user.keyboard("{ArrowDown}"); // Bookmarked
await user.keyboard("{ArrowDown}"); // Delete
await user.keyboard("{Enter}"); // Select Delete
expect(handleAction).toHaveBeenCalledWith("delete");
});Testing Tooltip
import * as Tooltip from "@radix-ui/react-tooltip";
function IconButton({ label }: { label: string }) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button aria-label={label}>?</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
it("shows tooltip on hover", async () => {
const user = userEvent.setup();
render(<IconButton label="More information" />);
await user.hover(screen.getByRole("button", { name: "More information" }));
await waitFor(() => {
expect(screen.getByRole("tooltip", { name: "More information" })).toBeInTheDocument();
});
});
it("hides tooltip on unhover", async () => {
const user = userEvent.setup();
render(<IconButton label="More information" />);
await user.hover(screen.getByRole("button", { name: "More information" }));
await user.unhover(screen.getByRole("button"));
await waitFor(() => {
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
});Summary
Testing Radix UI primitives effectively:
- Mock browser APIs (ResizeObserver, matchMedia, pointer capture) before any tests run
- Query by ARIA roles —
getByRole("dialog"),getByRole("option"),getByRole("menu")— not by CSS classes or Radix data attributes - Always
waitForafter interactions that open/close portaled content - Test keyboard navigation explicitly — it's Radix UI's core value proposition
- Check
aria-disabledfor disabled items — they're rendered in the DOM but not interactive - Test checkbox and radio items by their state attributes:
aria-checked,aria-selected