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.
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.
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.
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.
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.
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.
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 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 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
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.
Radix components accept an asChild prop that merges the primitive’s behavior onto the consumer’s child element instead of rendering an additional wrapper. <Dialog.Trigger asChild><MyButton /></Dialog.Trigger> makes MyButton the trigger without wrapping it in an extra <button>. This uses the Slot pattern internally — Radix clones the child and merges props (event handlers, ARIA attributes, refs) onto it.
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.
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).
Dialog warns in development if no Dialog.Title is rendered inside Dialog.Content. Without it, screen readers announce the dialog without a label, making it unusable for visually impaired users. If you intentionally want a titleless dialog, use Dialog.Title with className="sr-only" to provide an accessible label without visual text.clsx alone without twMerge, conflicting Tailwind classes accumulate instead of resolving. clsx("bg-blue-500", "bg-red-500") produces both classes — which one wins depends on Tailwind’s generated CSS order, not the argument order. twMerge ensures the last conflicting class wins predictably.Dialog.Content, Dialog.Overlay) and use the data-state attributes for state-dependent styles.onClick to Dialog.Trigger that calls e.preventDefault(), you break the trigger’s built-in toggle behavior. Always test custom event handlers to ensure they don’t suppress Radix’s internal handlers — use the onOpenChange callback on Dialog.Root for custom open/close logic instead.