Radix UI & shadcn/ui Accessible Component Systems

Frontend Engineering intermediate Building with AI ~30 min read

Overview

Building UI components that are both visually polished and fully accessible is one of the hardest problems in frontend engineering. Browser-native elements like <select> and <dialog> handle keyboard navigation, screen reader announcements, and focus trapping correctly, but they are notoriously difficult to style. Custom-built components look exactly as designed but almost always break accessibility — missing ARIA attributes, incorrect keyboard behavior, broken focus management, and no screen reader support. Radix UI solves this by providing unstyled, accessible component primitives that handle all the behavior correctly while leaving the visual layer entirely to the consumer.

The shadcn/ui project takes this a step further by providing a collection of beautifully designed components built on top of Radix primitives, styled with Tailwind CSS, and managed with Class Variance Authority (CVA) for type-safe variant definitions. Unlike traditional component libraries that ship as npm packages with opaque styling, shadcn/ui components are copied directly into your project as source files — you own them completely and can modify them freely. A typical search application codebase uses this pattern extensively, composing Radix primitives with project-specific Tailwind classes to build the search interface, settings dialogs, dropdown menus, and tooltip system.

The key architectural insight is the separation of concerns: Radix handles behavior (keyboard events, focus management, ARIA attributes, portal rendering, animation states), Tailwind handles visual styling (colors, spacing, typography, responsive breakpoints), and CVA handles variant logic (mapping prop combinations to class strings). This three-layer architecture means each concern can be modified independently — you can restyle a component without touching its accessibility behavior, or add a new variant without modifying the underlying primitive.

Core Concepts

Headless UI Primitives

Radix UI components are “headless” — they provide behavior, state management, and accessibility without rendering any visual styles. A Dialog.Root manages open/close state, traps focus inside the dialog, handles Escape to close, and announces the dialog to screen readers, but renders no visible UI. The consumer wraps Radix’s structural elements (Dialog.Trigger, Dialog.Content, Dialog.Title) with styled elements to create the final visual component. This inversion of control means the library never fights your design system.

Compound Component Pattern

Radix components use the compound component pattern where a parent component (Select.Root) provides context to child components (Select.Trigger, Select.Content, Select.Item) via React Context. This gives consumers full control over the DOM structure and styling of each sub-element while the parent coordinates state and behavior across them. You can insert arbitrary elements between compound children, reorder them, or conditionally render them — the context-based communication still works.

Class Variance Authority (CVA)

CVA is a utility for defining component variants as a mapping from prop values to Tailwind class strings. Instead of writing conditional class logic with ternaries and template literals, you define a cva() schema that specifies base classes, variant axes (size, color, state), and compound variants (specific combinations). CVA returns a function that accepts variant props and returns the correct class string — fully type-safe with TypeScript inference from the schema definition.

Copy-Paste Ownership Model

Unlike npm-installed component libraries where you depend on the library’s update cycle and fight its styling opinions, shadcn/ui components are scaffolded into your codebase via a CLI (npx shadcn-ui add button). The generated file becomes your source code — you can modify it, delete parts you don’t need, and compose it with project-specific logic. This eliminates the “ejection” problem where customizing a library component requires forking the entire library.

WAI-ARIA Compliance by Default

Radix primitives implement the WAI-ARIA Authoring Practices — the W3C’s official guidance on how interactive widgets should behave for assistive technologies. A Radix Tabs component automatically sets role="tablist", role="tab", aria-selected, aria-controls, and handles arrow key navigation between tabs. A Radix Dialog sets role="dialog", aria-modal="true", aria-labelledby, and traps focus. These behaviors are invisible to sighted users but essential for screen reader and keyboard users.

How It Works

Radix Primitive Composition

A Radix component like Dialog exports several sub-components that you compose together. Each sub-component handles a specific piece of the dialog’s behavior and accessibility contract.

import * as Dialog from '@radix-ui/react-dialog';
import { X } from 'lucide-react';

function SettingsDialog({ children }: { children: React.ReactNode }) {
  return (
    <Dialog.Root>
      {/* Trigger manages aria-expanded and toggles the dialog */}
      <Dialog.Trigger asChild>
        <button className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-200
                          hover:bg-zinc-700 transition-colors">
          Settings
        </button>
      </Dialog.Trigger>

      {/* Portal renders the dialog outside the DOM tree to avoid z-index issues */}
      <Dialog.Portal>
        {/* Overlay: click-to-close backdrop */}
        <Dialog.Overlay className="fixed inset-0 bg-black/60
                                   data-[state=open]:animate-in
                                   data-[state=closed]:animate-out
                                   data-[state=open]:fade-in-0
                                   data-[state=closed]:fade-out-0" />

        {/* Content: focus-trapped, escape-to-close container */}
        <Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2
                                    -translate-y-1/2 w-full max-w-lg rounded-xl
                                    bg-zinc-900 border border-zinc-700 p-6 shadow-xl
                                    data-[state=open]:animate-in
                                    data-[state=closed]:animate-out
                                    data-[state=open]:fade-in-0
                                    data-[state=closed]:fade-out-0
                                    data-[state=open]:zoom-in-95
                                    data-[state=closed]:zoom-out-95">

          {/* Title: linked to content via aria-labelledby */}
          <Dialog.Title className="text-lg font-semibold text-zinc-100">
            Application Settings
          </Dialog.Title>

          <Dialog.Description className="text-sm text-zinc-400 mt-1">
            Configure search behavior, appearance, and API settings.
          </Dialog.Description>

          <div className="mt-6">{children}</div>

          {/* Close button with accessible label */}
          <Dialog.Close asChild>
            <button className="absolute top-4 right-4 text-zinc-400
                              hover:text-zinc-200" aria-label="Close">
              <X size={16} />
            </button>
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

CVA Variant Definitions

CVA defines a schema for component variants that maps prop combinations to Tailwind class strings. The returned function is type-safe — TypeScript infers the allowed variant values from the schema.

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-md text-sm 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: {
        default: 'bg-indigo-600 text-white hover:bg-indigo-700',
        destructive: 'bg-red-600 text-white hover:bg-red-700',
        outline: 'border border-zinc-700 bg-transparent text-zinc-200 hover:bg-zinc-800',
        ghost: 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
        link: 'text-indigo-400 underline-offset-4 hover:underline',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        default: 'h-10 px-4',
        lg: 'h-12 px-6 text-base',
        icon: 'h-10 w-10',
      },
    },
    // Compound variants: override styles for specific prop combinations
    compoundVariants: [
      {
        variant: 'outline',
        size: 'icon',
        className: 'border-zinc-600',
      },
    ],
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

// Type-safe props derived from the CVA schema
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
}

// Usage: fully type-checked — invalid variants are compile errors
<Button variant="destructive" size="lg">Delete</Button>
<Button variant="ghost" size="icon"><Search /></Button>

The cn() Utility for Class Merging

The cn() utility combines clsx (conditional class joining) with tailwind-merge (intelligent Tailwind class deduplication). This ensures that when a consumer passes a className prop, it properly overrides conflicting base classes rather than creating broken CSS specificity conflicts.

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Without cn(): "bg-indigo-600 bg-red-500" — both applied, last wins by source order (fragile)
// With cn():    "bg-red-500" — tailwind-merge removes the conflicting bg-indigo-600

// Example: consumer overrides the button's background
<Button className="bg-emerald-600 hover:bg-emerald-700">
  Custom Color
</Button>
// cn() merges: removes default bg-indigo-600, keeps bg-emerald-600

Practical Applications

For instance, a search interface might use Radix Dialog for the advanced search settings panel, Popover for the filter dropdown, Tabs for switching between search results and analytics views, Tooltip for explaining relevance scores on hover, and Select for the index picker. Each of these is wrapped with project-specific Tailwind classes that match the dark theme design system, but the underlying accessibility behavior comes entirely from Radix.

The CVA pattern is used for all shared components — buttons, badges, input fields, and card variants. The search results list uses a resultCard CVA that defines variants for relevance (high/medium/low, affecting the left border accent color) and expanded (collapsed summary vs. full content view). This keeps the variant logic colocated with the component rather than scattered across conditional ternaries in the JSX.

The copy-paste ownership model proved valuable when the team needed to modify the Command component (used for the search command palette) to support streaming search suggestions. Because the component source lived in the project, they could add a loading spinner state, debounce the input handler, and integrate with the useAnswerStream hook — changes that would have required a fork or feature request with a traditional npm component library.

Implementation Patterns

asChild Prop for Flexible Rendering

Radix components accept an asChild prop that merges the primitive’s behavior onto the consumer’s child element instead of rendering an additional wrapper. &lt;Dialog.Trigger asChild>&lt;MyButton />&lt;/Dialog.Trigger> makes MyButton the trigger without wrapping it in an extra &lt;button>. This uses the Slot pattern internally — Radix clones the child and merges props (event handlers, ARIA attributes, refs) onto it.

Data Attributes for Animation States

Radix exposes component state via data-state attributes (data-state="open", data-state="closed"). Combined with Tailwind’s data attribute modifiers (data-[state=open]:animate-in), this enables CSS-driven enter/exit animations without JavaScript animation libraries. The component manages the state; CSS handles the visual transition.

Forwarded Refs for Composition

All shadcn/ui components forward their ref to the underlying DOM element using React.forwardRef. This is essential for integration with form libraries (React Hook Form needs refs for registration), tooltip positioning (needs the trigger element’s bounding rect), and focus management (parent components need to programmatically focus inputs).

Common Pitfalls

Further Reading