Building Accessible Design System Primitives with Radix UI and Tailwind CSS
A practical, code-heavy guide on building a fully accessible, type-safe dropdown menu component from scratch using unstyled Radix primitives and Tailwind CSS.
Building Accessible Design System Primitives with Radix UI and Tailwind CSS
When building a modern design system, teams often find themselves caught between two extremes: rolling custom components from scratch (and inevitably missing edge cases in accessibility and keyboard navigation), or using heavily opinionated UI libraries that fight against custom brand styles.
There is a better approach. By combining Radix UI primitives (which handle all the complex WAI-ARIA accessibility, focus management, and keyboard navigation out of the box) with Tailwind CSS (for utility-first, token-driven styling), you can build robust, type-safe, and fully accessible design system components tailored precisely to your needs.
In this guide, we will walk through building a production-ready Dropdown Menu component from scratch, complete with full TypeScript support, custom composition patterns, and polished Tailwind styling.
Why Radix UI + Tailwind CSS?
Radix UI provides unstyled, accessible React primitives. They are headless components that manage state, focus, and ARIA attributes according to the WAI-ARIA Design Pattern guidelines. However, they don’t ship with any CSS.
Tailwind CSS provides the styling layer. By passing Tailwind utility classes directly to Radix components (often via data-attributes that Radix exposes automatically), we achieve complete visual control without sacrificing a single ounce of accessibility.
The Architecture of Our Dropdown
To build our dropdown menu, we will use several Radix primitives:
DropdownMenu.Root: Manages the open/closed state.DropdownMenu.Trigger: The button that opens the menu.DropdownMenu.Portal: Renders menu content into a portal to avoid z-index clipping.DropdownMenu.Content: The wrapper for menu items.DropdownMenu.Item: Individual interactive rows.DropdownMenu.Separator: Visual dividers.
Step 1: Installing Dependencies
First, install the necessary Radix primitives and clsx/tailwind-merge utilities for handling conditional class names.
npm install @radix-ui/react-dropdown-menu clsx tailwind-merge
lucide-react
Next, let’s create a utility helper (src/utils/cn.ts) to safely merge Tailwind classes:
// src/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Step 2: Creating the Dropdown Menu Component
Let’s write our comprehensive DropdownMenu component. We will encapsulate the Radix primitives inside a clean API, expose strong TypeScript typings, and style them using modern Tailwind utility classes.
// src/components/DropdownMenu.tsx
import React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '../utils/cn';
// Root exports
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
// Styled Content
export interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
/** Optional offset from the trigger */
sideOffset?: number;
}
export const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
DropdownMenuContentProps
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1.5 text-slate-700 shadow-xl shadow-slate-900/10',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=top]:slide-in-from-bottom-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
// Styled Item
export interface DropdownMenuItemProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> {
inset?: boolean;
}
export const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuItemProps
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm font-medium outline-none transition-colors',
'focus:bg-indigo-50 focus:text-indigo-600',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
// Styled Separator
export const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1.5 my-1.5 h-px bg-slate-100', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
// Styled Label
export const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2.5 py-1.5 text-xs font-semibold uppercase tracking-wider text-slate-400',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
Step 3: Ensuring Robust Accessibility and Keyboard Navigation
Because we built on top of Radix primitives, our dropdown comes pre-packaged with compliance for the WAI-ARIA Menu Button pattern. Let’s examine what we get for free:
- Focus Trap & Management: When the menu opens, focus moves automatically to the first item. When closed, focus returns directly to the trigger button.
- Keyboard Support:
EnterorSpace: Opens the menu and activates focused items.Arrow Down/Arrow Up: Cycles through menu items.Escape: Closes the menu and returns focus to the trigger.- Type-ahead search: Typing characters when the menu is open jumps focus to items starting with those letters.
Design System Pro-Tip: Never override Radix’s built-in keyboard event listeners unless you are replacing them with identical WAI-ARIA compliant behaviors. Screen reader users rely heavily on these exact interaction patterns.
Step 4: Putting It Together in an Application
Now let’s consume our new primitive in a real-world user profile card setting:
// src/components/UserProfileMenu.tsx
import React from 'react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from './DropdownMenu';
import { User, Settings, CreditCard, LogOut, ChevronDown } from 'lucide-react';
export function UserProfileMenu() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full bg-slate-100 p-1.5 pr-3 text-sm font-medium text-slate-700 hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<img
src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&h=100&fit=crop&crop=faces"
alt="Jane Doe"
className="h-7 w-7 rounded-full object-cover"
/>
<span>Jane Doe</span>
<ChevronDown className="h-4 w-4 text-slate-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => console.log('Profile clicked')}>
<User className="h-4 w-4 text-slate-500" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => console.log('Settings clicked')}>
<Settings className="h-4 w-4 text-slate-500" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => console.log('Billing clicked')}>
<CreditCard className="h-4 w-4 text-slate-500" />
<span>Billing</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:bg-red-50 focus:text-red-600"
onClick={() => console.log('Logout clicked')}
>
<LogOut className="h-4 w-4 text-red-500" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Conclusion
By leveraging Radix UI primitives alongside Tailwind CSS, you no longer have to choose between accessibility and flexibility. Radix takes care of the intricate accessibility tree, focus traps, and keyboard listeners, while Tailwind gives you rapid, token-consistent styling capabilities.
Using this exact composition pattern, you can expand your design system to include Dialogs, Tooltips, Popovers, and Tabs with absolute confidence that every component will remain accessible, type-safe, and visually stunning.