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

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

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 @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

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

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

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: ::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 when you need custom exit animations, nested modals, or non-modal sheets/drawers.

Why Use Native Dialog Over 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

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, AnimatePresence for 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)

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

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

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# 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:

  1. VoiceOver (macOS/iOS): Press Cmd + F5 to enable
  2. NVDA (Windows): Free download from nvaccess.org
  3. 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 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

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:

  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?” 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 key props 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

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 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.

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:

  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