Component-driven development in React has matured past monolithic UI frameworks. Over the past several years, the industry shifted from heavy npm UI libraries (Material UI, Ant Design, Chakra UI) to unstyled headless primitives and copy-paste component registries like shadcn/ui.
The core premise of shadcn/ui is straightforward: you own your component code. There is no package wrapper, no closed stylesheet, and no dependency lock-in.
However, as projects grow from simple prototypes to production applications with dozens of contributors, maintaining clarity, type safety, and token consistency requires deliberate architectural boundaries.
Here is a pragmatic guide to structuring a modern React 19 and Tailwind CSS v4 component architecture.
1. Directory Structure: Separating Primitives from Features
The most common failure mode in growing codebases is placing every single component in a flat /components directory. Over time, button primitives mix with complex billing tables and user authentication drawers.
A clean separation splits UI into three clear tiers:
├── components/
│ ├── ui/ # Tier 1: Primitives (Zero domain logic)
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ └── dropdown-menu.tsx
│ └── shared/ # Tier 2: Application-wide compound layouts
│ ├── navbar.tsx
│ ├── site-footer.tsx
│ └── theme-toggle.tsx
├── features/ # Tier 3: Domain-specific modules
│ ├── billing/
│ │ ├── components/
│ │ │ ├── subscription-card.tsx
│ │ │ └── invoice-table.tsx
│ │ ├── api/
│ │ └── hooks/
│ └── settings/
└── lib/
├── utils.ts # cn helper (clsx + tailwind-merge)
└── tokens.tsThe Invariant of /components/ui
Components inside components/ui/ must remain completely unaware of business logic. They should accept standard HTML attributes, headless primitive props, and CVA variant options. They should never import store states, API clients, or domain types.
2. Managing Component Variants with CVA
class-variance-authority (CVA) remains the standard for declaring type-safe variant maps in Tailwind-based component libraries.
When building primitives with CVA, keep your base styles focused on layout, focus rings, and transitions, while delegating semantic colors to variants:
// components/ui/button.tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
)
Button.displayName = "Button"3. Tailwind CSS v4 CSS-First Theming
Tailwind CSS v4 moved away from tailwind.config.js to pure CSS @theme directives. This aligns with modern CSS variable specifications and simplifies dark mode switching.
In your globals.css:
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--radius-md: var(--radius);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(1 0 0 / 10%);
}Using OKLCH color space ensures consistent perceptual brightness across light and dark modes.
4. Compound Components vs Configuration Props
When building complex cards, tables, or modals, avoid passing dozens of props into a single monolithic component. Instead, expose compound sub-components.
❌ The Monolithic Anti-Pattern
<SettingsCard
title="API Keys"
description="Manage your secret keys"
buttonText="Create Key"
showWarning={true}
warningMessage="Keep keys confidential"
status="active"
items={keysList}
/>✅ The Declarative Compound Pattern
<Card>
<CardHeader>
<CardTitle>API Keys</CardTitle>
<CardDescription>Manage your secret keys</CardDescription>
</CardHeader>
<CardContent>
<KeyList items={keysList} />
</CardContent>
<CardFooter className="justify-between">
<span className="text-xs text-muted-foreground">Updated 2m ago</span>
<Button size="sm">Create Key</Button>
</CardFooter>
</Card>This pattern provides consumers full control over layout, slot composition, and accessible hierarchies without modifying internal component code.
5. React 19 Server & Client Component Boundaries
In React 19 and Next.js App Router, keep components server-rendered by default:
- Keep UI Primitives Stateless: Pure styling primitives (
Button,Card,Badge,Separator) do not require"use client". They render statically on the server with zero client bundle overhead. - Isolate Interactive Portals: Only components requiring event listeners or React state (
Dialog,DropdownMenu,Tooltip,Sheet) need the"use client"directive. - Pass Server Content as Children: Wrap interactive client components around server-rendered child trees to minimize client bundle serialization costs.
Summary
A scalable shadcn/ui architecture comes down to three fundamental practices:
- Strict separation: Keep primitive UI decoupled from domain features.
- Compound composition: Use sub-components instead of endless prop flags.
- CSS variables: Leverage Tailwind v4
@themeand OKLCH color tokens for effortless theming.