React + Tailwind: Build Dropdowns, Modals & Tabs

Build accessible React components with Tailwind CSS v4. Production-ready dropdowns, modals, and tabs with CVA patterns and complete WCAG 2.2 compliance.

Inzimam Ul Haq
Inzimam Ul Haq
· 8 min read · Updated
React interactive UI components with Tailwind CSS showing dropdown, modal, and tab patterns
Photo by Pankaj Patel on Unsplash

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 animate transform and opacity for 60fps.

Quick Reference: React UI Component Patterns

ComponentNative HTMLHeadless LibraryKey ARIA Attributes
Modal/Dialog<dialog>Radix Dialog, React Ariaaria-modal, aria-labelledby
Dropdown MenuPopover APIRadix DropdownMenuaria-expanded, aria-haspopup, role="menu"
TabsNoneRadix Tabs, Headless UIrole="tablist", role="tab", aria-selected
TooltipPopover APIRadix Tooltiprole="tooltip", aria-describedby
Accordion<details>Radix Accordionaria-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 @theme in CSS instead of tailwind.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: @layer support without PostCSS plugins
  • Container queries: Built-in @container support 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

ScenarioRecommendation
Learning/prototypingBuild custom (this guide)
Production appUse Radix DropdownMenu or Headless UI Menu
Design systemBuild on React Aria hooks
Simple tooltip/popoverUse 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: ::backdrop pseudo-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

FeatureNative <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
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)

LibraryBundle SizeBest ForLearning Curve
Framer Motion~55KBUI animations, layout transitions, gesturesLow
React Spring~20KBPhysics-based animations, complex gesturesMedium
Motion One~4KBSimple animations, performance-criticalLow
CSS Animations0KBHover states, simple transitionsLow

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

CriterionRequirementImplementation
2.4.11 Focus Not ObscuredFocus indicator must be at least partially visibleDon’t cover focused elements with sticky headers/modals
2.4.12 Focus Not Obscured (Enhanced)Focus indicator must be fully visibleScroll focused elements into view
2.4.13 Focus Appearance2px+ outline, 3:1 contrast ratioUse focus-visible:ring-2 with sufficient contrast
2.5.7 Dragging MovementsProvide non-dragging alternativeAdd buttons for drag-and-drop actions
2.5.8 Target Size (Minimum)24×24px minimum touch targetUse 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 TypeExamplesPerformanceUse For
Compositor (GPU)transform, opacity✅ 60fpsAll animations
Paintbackground, color, box-shadow⚠️ Causes repaintSmall elements only
Layoutwidth, height, margin, padding, top, left❌ Causes reflowNever 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

  1. Open React DevTools, Profiler tab
  2. Click “Record” and interact with your component
  3. Look for components with long render times (yellow/red)
  4. 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

LibraryMaintainerBest ForBundle Impact
Radix UIWorkOSFull design systems, complex components~5-15KB per component
React AriaAdobeMaximum accessibility, hooks-based~3-10KB per hook
Headless UITailwind LabsTailwind projects, simpler needs~2-8KB per component
Ark UIChakra teamState 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.

Conclusion

Native HTML first, headless libraries for production, CVA for variants, compositor-only animations.

Checklist:

  1. Native <dialog> for modals - focus trapping and backdrop for free
  2. CVA + twMerge for type-safe Tailwind variants
  3. WAI-ARIA patterns for keyboard navigation
  4. transform and opacity only for 60fps animations
  5. Radix or React Aria when you outgrow custom implementations
  6. React Testing Library + jest-axe to catch regressions

Related Resources:

Frequently Asked Questions

What is the best way to build React UI components with Tailwind CSS?
Use CVA (Class Variance Authority) with twMerge for type-safe variant management, Radix UI or React Aria for accessible primitives, and Tailwind CSS v4 for styling. The cn() utility combining clsx and twMerge is the industry standard pattern used by shadcn/ui.
Should I use native HTML dialog or a React modal library?
Use native HTML <dialog> element first. It provides built-in focus trapping, backdrop, Escape key handling, and top-layer rendering without JavaScript. Only reach for libraries like Radix Dialog or React Aria when you need features beyond native capabilities.
How do I make React dropdown menus accessible?
Implement WAI-ARIA menu pattern with role='menu' and role='menuitem', support arrow key navigation, close on Escape and outside click, manage focus when opening/closing, and use aria-expanded and aria-haspopup attributes. Consider using Radix UI or Headless UI for production.
What animation library should I use with React in 2026?
Framer Motion (~55KB) for most UI animations with its declarative API and AnimatePresence for exit animations. Use React Spring (~20KB) for physics-based animations and complex gestures. Use Motion One (~4KB) for simple, lightweight animations.
How do I optimize React component performance with Tailwind?
Only animate transform and opacity properties (GPU-accelerated), use React.memo for frequently re-rendering components, debounce rapid state changes, and leverage Tailwind CSS v4's 100x faster builds. Profile with React DevTools before optimizing.
What is CVA and why should I use it for React components?
CVA (Class Variance Authority) is a utility for managing Tailwind CSS class variants in a type-safe, declarative way. It eliminates conditional className logic, provides TypeScript autocomplete for variants, and is the pattern used by shadcn/ui for all components.
How do I meet WCAG 2.2 requirements for interactive components?
Ensure focus indicators are always visible (2.4.11), provide 24×24px minimum target sizes (2.5.8), support keyboard navigation for all interactions, respect prefers-reduced-motion, and test with screen readers. The ADA Title II deadline is April 2026.

Sources & References