Creating a reusable, accessible dropdown menu is a common requirement for modern web apps. In this guide you’ll see how to combine Radix UI’s accessible primitives, TypeScript’s type safety, Tailwind CSS’s utility‑first styling, and Cypress for end‑to‑end validation. By the end, you’ll own a component you can drop into any project and trust will work for all users.
Why Accessibility Matters in Dropdowns
Accessible dropdowns enhance usability for keyboard‑only and screen‑reader users. An accessible menu must:
- Support full keyboard navigation (Tab, Arrow keys, Escape).
- Expose proper ARIA roles (menu, menuitem, button).
- Provide clear focus indication and visible outlines.
- Maintain correct tabindex flow in the document tree.
Setting Up the Project
We’ll bootstrap a small React project with Vite, TypeScript, and Tailwind. Run the following commands:
npm create vite@latest my-dropdown-demo -- --template react-ts
cd my-dropdown-demo
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install @radix-ui/react-dropdown-menu
npm install cypress --save-dev
Configure Tailwind in tailwind.config.js by adding the React src path under content:
module.exports = {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}'
],
theme: {
extend: {},
},
plugins: [],
}
Creating the Dropdown with Radix UI
Radix UI’s DropdownMenu primitives give you a zero‑styling, fully accessible base. Wrap the trigger and content inside a main component and expose props for customization.
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { FC, ReactNode } from 'react';
interface DropdownProps {
trigger: ReactNode;
items: Array<{ id: string; label: string; onSelect: () => void }>;
}
export const AccessibleDropdown: FC<DropdownProps> = ({ trigger, items }) => (
{trigger}
{items.map((item) => (
{item.label}
))}
);
TypeScript Types & Props
The items prop is typed as an array of objects, ensuring compile‑time safety. Using ReactNode for the trigger allows any renderable element—button, icon, etc.
Styling with Tailwind CSS
Tailwind facilitates quick styling: bg-white sets the background, rounded-md for rounded corners, shadow-lg for depth, and p-1 w-48 controls padding and width. These classes are easily overridden if you prefer custom themes.
Adding Keyboard Navigation & ARIA
Radix UI automatically handles keyboard navigation and ARIA attributes. To verify, inspect the generated markup in a browser; you should see role="menu" and role="menuitem" attributes. If you extend the component, keep the asChild prop on the trigger and content to preserve native semantics.
Writing Cypress E2E Tests
Cypress gives confidence that the dropdown behaves as expected for real users.
Test Cases Overview
- Render the component and confirm the trigger is visible.
- Click the trigger and assert the menu opens.
- Navigate items using arrow keys and press Enter to select.
- Ensure pressing Esc closes the menu.
- Verify that selecting an item calls the appropriate callback.
Sample Test Code
describe('AccessibleDropdown', () => {
it('opens on trigger click and closes on escape', () => {
cy.mount(<AccessibleDropdown
trigger={}
items={[{ id: '1', label: 'Item 1', onSelect: () => console.log('1') }]}
/>);
cy.get('button').click();
cy.get('[role="menu"]').should('be.visible');
cy.get('body').type('{esc}');
cy.get('[role="menu"]').should('not.exist');
});
});
Common Trade‑offs & Performance Tips
Because Radix delays mounting the content until open, you avoid unnecessary DOM nodes. However, if you have a very large list, consider virtualising with react-window and pass the rendered menu content as a portal.
Be mindful of focus trap behaviour: Radix prevents focus from escaping when the menu is open, which is ideal for accessibility but may need adjustment if you nest complex components.
Integrating the Component into Your App
Drop the AccessibleDropdown into any page:
const menuItems = [
{ id: 'profile', label: 'Profile', onSelect: () => goToProfile() },
{ id: 'settings', label: 'Settings', onSelect: () => openSettings() },
];
function Header() {
return (
My App
<AccessibleDropdown trigger={} items={menuItems} />
);
}
Because the component is fully typed, you’ll get inline IntelliSense for item callbacks and trigger content.
Conclusion
By leveraging Radix UI’s accessible primitives, TypeScript’s safety, Tailwind’s rapid styling, and Cypress’s robust testing, you now own a reusable dropdown component that works for everyone. Implement it today, run Cypress tests, and notice how accessibility bumps up your conversion and user satisfaction.