The ADA Title II deadline hits April 2026. If your React components fail WCAG 2.1 AA, you face legal exposure. This guide covers the three components that fail audits most often: dropdowns, modals, and tabs. Production-ready code using React 19, Tailwind CSS v4, and Framer Motion. All examples follow WAI-ARIA patterns and pass jest-axe testing.
TL;DR: Use native
<dialog>for modals, CVA + twMerge for Tailwind variants, Framer Motion for animations, and Radix/React Aria for complex widgets. Only animatetransformandopacityfor 60fps.
Quick Reference: React UI Component Patterns
| Component | Native HTML | Headless Library | Key ARIA Attributes |
|---|---|---|---|
| Modal/Dialog | <dialog> ✅ | Radix Dialog, React Aria | aria-modal, aria-labelledby |
| Dropdown Menu | Popover API | Radix DropdownMenu | aria-expanded, aria-haspopup, role="menu" |
| Tabs | None | Radix Tabs, Headless UI | role="tablist", role="tab", aria-selected |
| Tooltip | Popover API | Radix Tooltip | role="tooltip", aria-describedby |
| Accordion | <details> | Radix Accordion | aria-expanded, aria-controls |
The Modern Stack: React + Tailwind CSS v4 + CVA
What’s New in Tailwind CSS v4
/* tailwind.config.css - CSS-first configuration */
@import "tailwindcss";
@theme {
/* Define design tokens in CSS, not JavaScript */
--color-primary: oklch(0.7 0.15 250);
--color-primary-hover: oklch(0.65 0.15 250);
--radius-lg: 0.75rem;
--shadow-card: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
Key v4 changes for components:
- CSS-first config: Use
@themein CSS instead oftailwind.config.js - OKLCH colors: Perceptually uniform color space for better hover states
- 100x faster builds: Lightning CSS engine, sub-millisecond incremental builds
- Native cascade layers:
@layersupport without PostCSS plugins - Container queries: Built-in
@containersupport for component-scoped responsive design
Setting Up the cn() Utility
The cn() utility combines clsx for conditional classes and tailwind-merge to resolve conflicts - the pattern shadcn/ui uses:
// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Install the dependencies:
npm install clsx tailwind-merge class-variance-authority
Building a Button with CVA (Class Variance Authority)
CVA maps props directly to Tailwind classes with full TypeScript autocomplete. No conditional ternaries, and it’s the foundation of shadcn/ui.
// ❌ Before: Conditional spaghetti
className={variant === 'primary' ? 'bg-blue-600' : 'bg-gray-200'}
// ✅ After: Declarative schema
variants: { variant: { primary: 'bg-blue-600', secondary: 'bg-gray-200' } }
// components/Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
// Base classes applied to all variants
"inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary:
"bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500",
secondary:
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500",
ghost: "hover:bg-gray-100 hover:text-gray-900",
destructive:
"bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
Building Accessible Dropdown Menus
When to Build Custom vs. Use a Library
| Scenario | Recommendation |
|---|---|
| Learning/prototyping | Build custom (this guide) |
| Production app | Use Radix DropdownMenu or Headless UI Menu |
| Design system | Build on React Aria hooks |
| Simple tooltip/popover | Use native Popover API |
Custom Dropdown with Full Keyboard Support
This implementation follows the WAI-ARIA Menu Pattern:
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
interface DropdownItem {
label: string;
onClick: () => void;
disabled?: boolean;
}
interface DropdownProps {
trigger: React.ReactNode;
items: DropdownItem[];
align?: "start" | "end";
}
export function Dropdown({ trigger, items, align = "start" }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
!menuRef.current?.contains(target) &&
!triggerRef.current?.contains(target)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Close on Escape, navigate with arrows
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (!isOpen) {
if (
event.key === "Enter" ||
event.key === " " ||
event.key === "ArrowDown"
) {
event.preventDefault();
setIsOpen(true);
setActiveIndex(0);
}
return;
}
switch (event.key) {
case "Escape":
event.preventDefault();
setIsOpen(false);
triggerRef.current?.focus();
break;
case "ArrowDown":
event.preventDefault();
setActiveIndex((prev) => (prev + 1) % items.length);
break;
case "ArrowUp":
event.preventDefault();
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case "Home":
event.preventDefault();
setActiveIndex(0);
break;
case "End":
event.preventDefault();
setActiveIndex(items.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
if (activeIndex >= 0 && !items[activeIndex].disabled) {
items[activeIndex].onClick();
setIsOpen(false);
triggerRef.current?.focus();
}
break;
case "Tab":
setIsOpen(false);
break;
}
},
[isOpen, items, activeIndex],
);
// Focus active item when activeIndex changes
useEffect(() => {
if (isOpen && activeIndex >= 0) {
itemRefs.current[activeIndex]?.focus();
}
}, [isOpen, activeIndex]);
return (
<div className="relative inline-block">
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-expanded={isOpen}
aria-haspopup="menu"
aria-controls="dropdown-menu"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{trigger}
<svg
className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isOpen && (
<div
ref={menuRef}
id="dropdown-menu"
role="menu"
aria-orientation="vertical"
onKeyDown={handleKeyDown}
className={cn(
"absolute z-50 mt-1 min-w-[160px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg",
align === "end" ? "right-0" : "left-0",
)}
>
{items.map((item, index) => (
<button
key={index}
ref={(el) => (itemRefs.current[index] = el)}
role="menuitem"
tabIndex={activeIndex === index ? 0 : -1}
disabled={item.disabled}
onClick={() => {
if (!item.disabled) {
item.onClick();
setIsOpen(false);
triggerRef.current?.focus();
}
}}
className={cn(
"flex w-full items-center px-4 py-2 text-left text-sm",
activeIndex === index && "bg-gray-100",
item.disabled
? "cursor-not-allowed text-gray-400"
: "text-gray-700 hover:bg-gray-100",
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
Key accessibility features: aria-expanded announces open/closed state, aria-haspopup="menu" tells screen readers a menu will appear, role="menu" and role="menuitem" provide semantic structure, arrow keys navigate, Enter/Space select, Escape closes, focus returns to trigger when menu closes.
Building Modals with Native HTML Dialog
Use native <dialog> first. It handles the hard parts automatically:
- Focus trapping: Built-in, no library needed
- Backdrop:
::backdroppseudo-element with CSS styling - Escape to close: Native behavior, zero JavaScript
- Top layer: Always renders above other content, no z-index wars
- Screen readers: Native
role="dialog"announcement
Only reach for Radix Dialog or React Aria for custom exit animations, nested modals, or non-modal sheets.
Native Dialog vs Custom Modals
| Feature | Native <dialog> | Custom Modal |
|---|---|---|
| Focus trapping | ✅ Built-in | ❌ Requires focus-trap library |
| Backdrop | ✅ ::backdrop pseudo-element | ❌ Manual overlay div |
| Escape to close | ✅ Built-in | ❌ Manual keydown handler |
| Top layer | ✅ Always on top | ❌ z-index management |
| Screen reader support | ✅ Native role="dialog" | ⚠️ Manual ARIA |
| Browser support | ✅ All modern browsers | ✅ Universal |
Modal Component with Native Dialog
import { useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
className?: string;
}
export function Modal({
isOpen,
onClose,
title,
description,
children,
className,
}: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
dialog.showModal(); // Opens as modal with backdrop
} else {
dialog.close();
}
}, [isOpen]);
// Handle native close event (Escape key, form submission)
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => onClose();
dialog.addEventListener("close", handleClose);
return () => dialog.removeEventListener("close", handleClose);
}, [onClose]);
// Close on backdrop click
const handleBackdropClick = (event: React.MouseEvent) => {
if (event.target === dialogRef.current) {
onClose();
}
};
return (
<dialog
ref={dialogRef}
onClick={handleBackdropClick}
aria-labelledby="modal-title"
aria-describedby={description ? "modal-description" : undefined}
className={cn(
"w-full max-w-md rounded-xl border-0 bg-white p-0 shadow-xl backdrop:bg-black/50",
"open:animate-in open:fade-in-0 open:zoom-in-95",
className,
)}
>
<div className="p-6">
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">
{title}
</h2>
{description && (
<p id="modal-description" className="mt-2 text-sm text-gray-600">
{description}
</p>
)}
<div className="mt-4">{children}</div>
</div>
<button
onClick={onClose}
aria-label="Close modal"
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</dialog>
);
}
Using the inert Attribute for Focus Management
The inert attribute makes an element and its descendants non-interactive, removing them from the accessibility tree and preventing focus.
// App layout with inert support
function AppLayout({ children }: { children: React.ReactNode }) {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
{/* Main content becomes inert when modal is open */}
<main inert={modalOpen ? "" : undefined}>
{children}
<button onClick={() => setModalOpen(true)}>Open Modal</button>
</main>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title="Example"
>
<p>Modal content here</p>
</Modal>
</>
);
}
Note: TypeScript may not recognize inert yet. Add this to your types:
// types/react.d.ts
declare module "react" {
interface HTMLAttributes<T> {
inert?: "" | undefined;
}
}
Building Accessible Tab Components
Tabs follow the WAI-ARIA Tabs Pattern with proper roles, keyboard navigation, and focus management.
Tab Component with Compound Components Pattern
Compound components share implicit state through React Context, providing flexible, composable APIs (used by Radix UI, Headless UI, and React Aria).
import { createContext, useContext, useState, useCallback, useId } from "react";
import { cn } from "@/lib/utils";
// Context for sharing state between Tab components
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
baseId: string;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tab components must be used within <Tabs>");
}
return context;
}
// Root component
interface TabsProps {
defaultValue: string;
children: React.ReactNode;
className?: string;
onChange?: (value: string) => void;
}
export function Tabs({
defaultValue,
children,
className,
onChange,
}: TabsProps) {
const [activeTab, setActiveTabState] = useState(defaultValue);
const baseId = useId();
const setActiveTab = useCallback(
(id: string) => {
setActiveTabState(id);
onChange?.(id);
},
[onChange],
);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab, baseId }}>
<div className={cn("w-full", className)}>{children}</div>
</TabsContext.Provider>
);
}
// Tab list container
interface TabListProps {
children: React.ReactNode;
className?: string;
}
export function TabList({ children, className }: TabListProps) {
return (
<div
role="tablist"
aria-orientation="horizontal"
className={cn(
"inline-flex items-center gap-1 rounded-lg bg-gray-100 p-1",
className,
)}
>
{children}
</div>
);
}
// Individual tab trigger
interface TabTriggerProps {
value: string;
children: React.ReactNode;
disabled?: boolean;
className?: string;
}
export function TabTrigger({
value,
children,
disabled,
className,
}: TabTriggerProps) {
const { activeTab, setActiveTab, baseId } = useTabsContext();
const isActive = activeTab === value;
const handleKeyDown = (event: React.KeyboardEvent) => {
const tabs =
event.currentTarget.parentElement?.querySelectorAll('[role="tab"]');
if (!tabs) return;
const tabArray = Array.from(tabs) as HTMLElement[];
const currentIndex = tabArray.findIndex(
(tab) => tab === event.currentTarget,
);
let newIndex: number | null = null;
switch (event.key) {
case "ArrowRight":
newIndex = (currentIndex + 1) % tabArray.length;
break;
case "ArrowLeft":
newIndex = (currentIndex - 1 + tabArray.length) % tabArray.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabArray.length - 1;
break;
}
if (newIndex !== null) {
event.preventDefault();
tabArray[newIndex].focus();
// Optionally activate on arrow key (automatic activation)
const newValue = tabArray[newIndex].getAttribute("data-value");
if (newValue) setActiveTab(newValue);
}
};
return (
<button
role="tab"
id={`${baseId}-tab-${value}`}
aria-selected={isActive}
aria-controls={`${baseId}-panel-${value}`}
tabIndex={isActive ? 0 : -1}
disabled={disabled}
data-value={value}
onClick={() => setActiveTab(value)}
onKeyDown={handleKeyDown}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
isActive
? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900",
className,
)}
>
{children}
</button>
);
}
// Tab panel content
interface TabPanelProps {
value: string;
children: React.ReactNode;
className?: string;
}
export function TabPanel({ value, children, className }: TabPanelProps) {
const { activeTab, baseId } = useTabsContext();
const isActive = activeTab === value;
if (!isActive) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${value}`}
aria-labelledby={`${baseId}-tab-${value}`}
tabIndex={0}
className={cn("mt-4 focus-visible:outline-none", className)}
>
{children}
</div>
);
}
Usage Example
<Tabs defaultValue="account" onChange={(value) => console.log(value)}>
<TabList>
<TabTrigger value="account">Account</TabTrigger>
<TabTrigger value="password">Password</TabTrigger>
<TabTrigger value="notifications" disabled>
Notifications
</TabTrigger>
</TabList>
<TabPanel value="account">
<h3 className="font-medium">Account Settings</h3>
<p className="text-gray-600">Manage your account preferences.</p>
</TabPanel>
<TabPanel value="password">
<h3 className="font-medium">Change Password</h3>
<p className="text-gray-600">Update your password here.</p>
</TabPanel>
</Tabs>
Adding Animations with Framer Motion
For an in-depth walkthrough of Framer Motion including layout animations and scroll effects, see our Framer Motion complete guide.
Animation Library Comparison (2026)
| Library | Bundle Size | Best For | Learning Curve |
|---|---|---|---|
| Framer Motion | ~55KB | UI animations, layout transitions, gestures | Low |
| React Spring | ~20KB | Physics-based animations, complex gestures | Medium |
| Motion One | ~4KB | Simple animations, performance-critical | Low |
| CSS Animations | 0KB | Hover states, simple transitions | Low |
Animating the Modal with Framer Motion
import { motion, AnimatePresence } from "framer-motion";
interface AnimatedModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function AnimatedModal({
isOpen,
onClose,
title,
children,
}: AnimatedModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-50 bg-black/50"
/>
{/* Modal */}
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-6 shadow-xl"
>
<h2 id="modal-title" className="text-lg font-semibold">
{title}
</h2>
<div className="mt-4">{children}</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
Staggered List Animations
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
function AnimatedList({ items }: { items: string[] }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-2"
>
{items.map((item, index) => (
<motion.li
key={index}
variants={itemVariants}
className="rounded-lg border p-4"
>
{item}
</motion.li>
))}
</motion.ul>
);
}
Respecting prefers-reduced-motion
Approximately 35% of adults experience motion sensitivity. WCAG 2.1 SC 2.3.3 requires alternatives for motion-based interactions.
import { useReducedMotion, motion } from "framer-motion";
function AccessibleAnimation({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0.01 : 0.3 }}
>
{children}
</motion.div>
);
}
Or use MotionConfig to apply globally:
import { MotionConfig } from "framer-motion";
function App() {
return (
<MotionConfig reducedMotion="user">
{/* All motion components respect user preference */}
<YourApp />
</MotionConfig>
);
}
WCAG 2.2 Accessibility Requirements
WCAG 2.2 (October 2023) adds nine new success criteria. Key requirements for interactive components:
Critical Requirements for Interactive Components
| Criterion | Requirement | Implementation |
|---|---|---|
| 2.4.11 Focus Not Obscured | Focus indicator must be at least partially visible | Don’t cover focused elements with sticky headers/modals |
| 2.4.12 Focus Not Obscured (Enhanced) | Focus indicator must be fully visible | Scroll focused elements into view |
| 2.4.13 Focus Appearance | 2px+ outline, 3:1 contrast ratio | Use focus-visible:ring-2 with sufficient contrast |
| 2.5.7 Dragging Movements | Provide non-dragging alternative | Add buttons for drag-and-drop actions |
| 2.5.8 Target Size (Minimum) | 24×24px minimum touch target | Use min-h-6 min-w-6 or larger |
Focus Indicator Implementation
// Tailwind classes for WCAG 2.2 compliant focus indicators
const focusClasses = cn(
// Remove default outline
"focus:outline-none",
// Add visible ring on keyboard focus only
"focus-visible:ring-2",
"focus-visible:ring-blue-500",
"focus-visible:ring-offset-2",
// Ensure sufficient contrast
"focus-visible:ring-offset-white",
"dark:focus-visible:ring-offset-gray-900",
);
Minimum Target Size
// Ensure 24×24px minimum for all interactive elements
function IconButton({ icon, label, onClick }: IconButtonProps) {
return (
<button
onClick={onClick}
aria-label={label}
className={cn(
// Minimum 24×24px target size (WCAG 2.5.8)
"min-h-6 min-w-6 p-2",
// Or use explicit sizing
"h-10 w-10",
"inline-flex items-center justify-center rounded-lg",
"hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-blue-500",
)}
>
{icon}
</button>
);
}
Screen Reader Testing Checklist
Test with VoiceOver (macOS/iOS), NVDA (Windows), or ChromeVox (Chrome extension).
Verify:
- Component role is announced (“button”, “menu”, “dialog”)
- State changes are announced (“expanded”, “selected”)
- Labels are descriptive and unique
- Focus order matches visual order
Performance Optimization
Animating the wrong CSS properties causes jank:
- GPU-accelerated (fast):
transform,opacity- run on compositor thread, 60fps guaranteed - Paint-only (medium):
background,color,box-shadow- acceptable for small elements - Layout-triggering (slow):
width,height,margin,padding,top,left- cause reflow, never animate
The rule: Only animate transform and opacity. Use scale instead of width/height, translate instead of top/left.
Compositor-Only Properties
| Property Type | Examples | Performance | Use For |
|---|---|---|---|
| Compositor (GPU) | transform, opacity | ✅ 60fps | All animations |
| Paint | background, color, box-shadow | ⚠️ Causes repaint | Small elements only |
| Layout | width, height, margin, padding, top, left | ❌ Causes reflow | Never animate |
// ❌ Bad: Animating layout properties
<motion.div
animate={{ width: isOpen ? 300 : 0, height: isOpen ? 200 : 0 }}
/>
// ✅ Good: Using transform for size changes
<motion.div
animate={{ scale: isOpen ? 1 : 0 }}
style={{ transformOrigin: "top left" }}
/>
// ✅ Good: Using transform for position
<motion.div
animate={{ x: isOpen ? 0 : -100, y: isOpen ? 0 : -50 }}
/>
React Performance Patterns
For a comprehensive React hooks reference including React 19 additions, see our complete guide to mastering React hooks.
import { memo, useCallback, useMemo } from "react";
// Memoize components that receive callbacks
const DropdownItem = memo(function DropdownItem({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button onClick={onClick} className="px-4 py-2 hover:bg-gray-100">
{label}
</button>
);
});
// Stabilize callbacks with useCallback
function Dropdown({ items }: { items: Item[] }) {
const handleItemClick = useCallback((id: string) => {
console.log("Clicked:", id);
}, []);
// Memoize derived data
const sortedItems = useMemo(
() => items.sort((a, b) => a.label.localeCompare(b.label)),
[items],
);
return (
<div>
{sortedItems.map((item) => (
<DropdownItem
key={item.id}
label={item.label}
onClick={() => handleItemClick(item.id)}
/>
))}
</div>
);
}
Debouncing Rapid State Changes
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage: Debounce search input
function SearchDropdown() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Only fetch when debounced value changes
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
Profiling with React DevTools
- Open React DevTools, Profiler tab
- Click “Record” and interact with your component
- Look for components with long render times (yellow/red)
- Check “Why did this render?” for unnecessary re-renders
Common culprits: unstable callback references, inline object/array props, context providers with frequently changing values, missing key props.
Testing Interactive Components
Test behavior from the user’s perspective using React Testing Library. Focus on what users see and do, not implementation details.
Setting Up Tests
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest
Testing the Dropdown Component
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dropdown } from "./Dropdown";
describe("Dropdown", () => {
const items = [
{ label: "Edit", onClick: vi.fn() },
{ label: "Delete", onClick: vi.fn() },
];
beforeEach(() => {
vi.clearAllMocks();
});
it("opens on click and shows menu items", async () => {
const user = userEvent.setup();
render(<Dropdown trigger="Options" items={items} />);
// Menu should be closed initially
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
// Click trigger to open
await user.click(screen.getByRole("button", { name: /options/i }));
// Menu should be visible with items
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /edit/i })).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: /delete/i }),
).toBeInTheDocument();
});
it("closes on Escape key", async () => {
const user = userEvent.setup();
render(<Dropdown trigger="Options" items={items} />);
await user.click(screen.getByRole("button", { name: /options/i }));
expect(screen.getByRole("menu")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
it("navigates with arrow keys", async () => {
const user = userEvent.setup();
render(<Dropdown trigger="Options" items={items} />);
await user.click(screen.getByRole("button", { name: /options/i }));
// First item should be focused
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: /edit/i })).toHaveFocus();
// Arrow down to second item
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: /delete/i })).toHaveFocus();
// Arrow down wraps to first item
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("menuitem", { name: /edit/i })).toHaveFocus();
});
it("calls onClick and closes when item selected", async () => {
const user = userEvent.setup();
render(<Dropdown trigger="Options" items={items} />);
await user.click(screen.getByRole("button", { name: /options/i }));
await user.click(screen.getByRole("menuitem", { name: /edit/i }));
expect(items[0].onClick).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("has correct ARIA attributes", async () => {
const user = userEvent.setup();
render(<Dropdown trigger="Options" items={items} />);
const trigger = screen.getByRole("button", { name: /options/i });
// Closed state
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
// Open state
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
});
});
Accessibility Testing with jest-axe
npm install -D jest-axe @types/jest-axe
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Dropdown } from "./Dropdown";
expect.extend(toHaveNoViolations);
describe("Dropdown accessibility", () => {
it("has no accessibility violations when closed", async () => {
const { container } = render(
<Dropdown
trigger="Options"
items={[{ label: "Edit", onClick: () => {} }]}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("has no accessibility violations when open", async () => {
const user = userEvent.setup();
const { container } = render(
<Dropdown
trigger="Options"
items={[{ label: "Edit", onClick: () => {} }]}
/>,
);
await user.click(screen.getByRole("button"));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
When to Use Headless UI Libraries
Building components from scratch teaches fundamentals, but production apps should use battle-tested libraries.
Headless Library Comparison
| Library | Maintainer | Best For | Bundle Impact |
|---|---|---|---|
| Radix UI | WorkOS | Full design systems, complex components | ~5-15KB per component |
| React Aria | Adobe | Maximum accessibility, hooks-based | ~3-10KB per hook |
| Headless UI | Tailwind Labs | Tailwind projects, simpler needs | ~2-8KB per component |
| Ark UI | Chakra team | State machines, multi-framework | ~5-12KB per component |
Example: Radix UI Dropdown
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
function RadixDropdown() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="rounded-lg border px-4 py-2">
Options
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="rounded-lg border bg-white p-1 shadow-lg">
<DropdownMenu.Item className="cursor-pointer rounded px-4 py-2 hover:bg-gray-100">
Edit
</DropdownMenu.Item>
<DropdownMenu.Item className="cursor-pointer rounded px-4 py-2 hover:bg-gray-100">
Duplicate
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-px bg-gray-200" />
<DropdownMenu.Item className="cursor-pointer rounded px-4 py-2 text-red-600 hover:bg-red-50">
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
Why Radix over custom? Handles edge cases, tested across browsers and assistive technologies, manages focus/keyboard/ARIA automatically, collision detection for positioning, maintained by a dedicated team.
The shadcn/ui Approach
shadcn/ui isn’t a component library - it’s copy-paste components built on Radix UI with Tailwind CSS.
# Add a component to your project
npx shadcn-ui@latest add dropdown-menu
Copies the component source into your project at components/ui/dropdown-menu.tsx, giving you full control.
Recommended Video Resources
Conclusion
Native HTML first, headless libraries for production, CVA for variants, compositor-only animations.
Checklist:
- Native
<dialog>for modals - focus trapping and backdrop for free - CVA + twMerge for type-safe Tailwind variants
- WAI-ARIA patterns for keyboard navigation
transformandopacityonly for 60fps animations- Radix or React Aria when you outgrow custom implementations
- React Testing Library + jest-axe to catch regressions
Related Resources:
- Building Design Systems from Scratch - Design system architecture
- Framer Motion Complete Guide - Layout animations and scroll effects
- Accessibility for Design Engineers - WCAG 2.2 compliance guide
- React Performance Optimization - Keep components fast
- TypeScript for React Developers - Type-safe component patterns