The ADA Title II deadline hits April 2026. If your React components fail WCAG 2.1 AA, your organization faces legal exposure. Most teams are scrambling, but the fix isn’t complicated. It’s about using the right primitives: native <dialog> instead of div soup, proper ARIA roles instead of guessing, and keyboard navigation that actually works.
This guide covers the three components that fail accessibility audits most often: dropdowns, modals, and tabs. You’ll get production-ready code using React 19, Tailwind CSS v4, and Framer Motion. These are the same patterns shadcn/ui uses, which has become the default component approach for React teams in 2026. Every example follows WAI-ARIA patterns and passes automated testing with jest-axe.
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 performance.
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
Why this combination? React handles component logic and state. Tailwind CSS v4 (released January 2025) provides utility-first styling with 100x faster builds. CVA (Class Variance Authority) manages variant classes without conditional spaghetti. Together, they create components that are fast to build, easy to maintain, and impossible to style incorrectly.
What’s New in Tailwind CSS v4
Tailwind v4 is a ground-up rewrite with significant changes for component development:
/* 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
Every modern React + Tailwind project uses the cn() utility, a combination of clsx for conditional classes and tailwind-merge to resolve conflicts. This is the exact 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)
What is CVA (Class Variance Authority)?
CVA is a TypeScript utility for managing Tailwind CSS component variants declaratively. Instead of conditional className logic, you define a schema that maps props to classes.
- Type-safe: Full autocomplete for variant names and values
- Zero conditionals: No
variant === 'primary' ? ... : ...ternaries - Industry standard: Used by shadcn/ui, the most popular React component pattern in 2026
Example transformation:
// ❌ 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}
/>
);
}
Why CVA matters:
- Type-safe variants with autocomplete
- No runtime conditional logic
- Easy to extend with new variants
- Consistent with shadcn/ui patterns
Building Accessible Dropdown Menus
What is an accessible dropdown menu? A dropdown menu is a UI component that reveals a list of actions when triggered. Accessible dropdowns must support keyboard navigation (arrow keys, Enter, Escape), announce state changes to screen readers, and close when clicking outside.
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-expandedannounces open/closed statearia-haspopup="menu"tells screen readers a menu will appearrole="menu"androle="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
Should I use native HTML dialog or a React modal library?
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 when you need custom exit animations, nested modals, or non-modal sheets/drawers.
Why Use Native Dialog Over 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
What is the inert attribute? The inert attribute makes an element and all its descendants non-interactive. They can’t receive focus, clicks, or screen reader announcements. It’s a “reversed focus trap” that’s often simpler than trapping focus inside a modal.
// 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
What is an accessible tab component? Tabs are a UI pattern where selecting a tab shows its associated panel while hiding others. Accessible tabs follow the WAI-ARIA Tabs Pattern with proper roles, keyboard navigation, and focus management.
Tab Component with Compound Components Pattern
What is the compound components pattern? Compound components are a set of components that work together, sharing implicit state through React Context. This pattern (used by Radix UI, Headless UI, and React Aria) provides flexible, composable APIs.
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
What animation library should I use with React in 2026?
It depends on your bundle budget and animation complexity. For an in-depth walkthrough of Framer Motion’s full API including layout animations, scroll-triggered effects, and advanced gestures, see our Framer Motion complete guide for React and Next.js developers.
- Framer Motion (~55KB): Best for most UI work. Declarative API,
AnimatePresencefor exit animations, gesture support. Use this by default. - React Spring (~20KB): Physics-based animations, complex spring dynamics. Use for natural-feeling interactions.
- Motion One (~4KB): Minimal footprint, Web Animations API. Use when bundle size is critical.
- CSS only (0KB): Hover states, simple transitions. Use when you don’t need exit animations or gesture control.
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
Why does reduced motion matter? Approximately 35% of adults experience motion sensitivity. WCAG 2.1 Success Criterion 2.3.3 requires providing alternatives for motion-based interactions. Framer Motion 12+ includes automatic reduced-motion support.
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
What is WCAG 2.2? WCAG 2.2 (Web Content Accessibility Guidelines) is the latest W3C standard for web accessibility, published October 2023. It adds nine new success criteria, with key requirements for interactive components. The ADA Title II deadline requiring WCAG 2.1 AA compliance is April 2026. For a deeper dive into building inclusive interfaces from the ground up, see our guide on accessibility for design engineers.
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# Focus Indicator Best Practices
// 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
Before shipping interactive components, test with:
- VoiceOver (macOS/iOS): Press
Cmd + F5to enable - NVDA (Windows): Free download from nvaccess.org
- ChromeVox (Chrome extension): For quick testing
What to verify:
- Component role is announced (“button”, “menu”, “dialog”)
- State changes are announced (“expanded”, “selected”, “checked”)
- Labels are descriptive and unique
- Focus order matches visual order
- No content is skipped or repeated
Performance Optimization
What causes poor React animation performance?
Animating the wrong CSS properties. Here’s the breakdown:
- GPU-accelerated (fast):
transform,opacity. Run on compositor thread, 60fps guaranteed. - Paint-only (medium):
background,color,box-shadow. Repaint but no reflow, acceptable for small elements. - Layout-triggering (slow):
width,height,margin,padding,top,left. Cause reflow, block main thread, cause jank.
The rule: Only animate transform and opacity. Use scale instead of width/height, use translate instead of top/left.
The Golden Rule: 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
These patterns rely heavily on React hooks like memo, useCallback, and useMemo. If you want a comprehensive reference for all built-in hooks including the newer React 19 additions, check out 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
Before optimizing, identify actual bottlenecks:
- 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?” to find unnecessary re-renders
Common culprits:
- Unstable callback references (missing
useCallback) - Inline object/array props (create new reference each render)
- Context providers with frequently changing values
- Missing
keyprops causing full list re-renders
Testing Interactive Components
How should you test React components? Test behavior from the user’s perspective using React Testing Library. Focus on what users see and do, not implementation details like state values or component internals.
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. Here’s when to reach for each:
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 you haven’t thought of
- Tested across browsers and assistive technologies
- Manages focus, keyboard navigation, and ARIA automatically
- Collision detection for positioning
- Maintained by a dedicated team
The shadcn/ui Approach
shadcn/ui isn’t a component library. It’s a collection of copy-paste components built on Radix UI and styled with Tailwind CSS. You own the code, so you can customize everything. If you’re thinking about scaling this approach into a full design system, our guide on building design systems from scratch covers architecture, token management, and team workflows.
# Add a component to your project
npx shadcn-ui@latest add dropdown-menu
This copies the component source into your project at components/ui/dropdown-menu.tsx, giving you full control while starting from a solid foundation.
Recommended Video Resources
Conclusion
Native HTML first, headless libraries for production, CVA for variants, compositor-only animations. That’s the stack that passes audits and ships fast.
The 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