Tailwind CSS 53 🎨 Integrating Tailwind with Component Architectures (shadcn/ui Pattern)
A component library is usually something you install. You add a dependency, import a Button, and accept whatever the library decided a button should be. The shadcn/ui pattern inverts this. Instead of installing a package, you copy the component’s source into your own project. The code becomes yours. You can edit it, extend it, and shape it to your design system without fighting a package’s opinions or waiting for a maintainer to merge a change. This chapter examines the architecture behind that pattern: how components are distributed, how they are built on headless primitives, how Tailwind and cva supply the styling layer, and how the pieces fit together so that the result feels like a library while behaving like your own code.
Key point: The shadcn/ui pattern is not a library you depend on. It is a collection of component source files you own. The distribution model is the innovation, not the components themselves.
Why the copy-paste pattern exists
The dependency problem. A traditional component library is a black box. Its internals are invisible, its styling is fixed, and its API is whatever the maintainers decided. When the library’s design does not match the application’s, the developer either accepts the mismatch, overrides it with fragile CSS, or forks the package. All three outcomes cost time and create friction.
The ownership problem. When a component lives in node_modules, changing it means either patching the package, publishing a fork, or waiting for an upstream release. None of these is practical for a design system that needs to evolve with the product. Copying the component into the project makes it a first-class part of the codebase, editable on the same day the need arises.
The primitives problem. Accessible components are hard. A dropdown needs keyboard navigation, focus management, and correct ARIA attributes. Most teams should not write this from scratch. The shadcn/ui pattern solves this by building on headless primitives, typically Radix UI, which provide behavior and accessibility without visual opinion. The copied component wraps the primitive and adds the styling layer.
The styling problem. The styling layer must be editable because it expresses the design system. If the styling lives in a package, changing a border radius means changing a dependency. If the styling lives in the project’s own source, changing a border radius is a one-line edit in a file the team owns.
The consistency problem. A project with copied components still needs consistency. The components should share conventions: the same cva pattern, the same cn helper, the same naming, the same variant names. The pattern provides these conventions even though the code is local.
The update problem. Copying code means the project does not receive upstream fixes automatically. This is the trade-off, and it is deliberate. The team can diff against the upstream when it chooses, and merge changes selectively. For most applications, this is a better trade than being locked to a version they cannot modify.
a. The architecture layers
A shadcn/ui component is a stack of four layers. Understanding the layers makes the pattern reproducible for any primitive.
The primitive layer
The primitive is a headless component from Radix UI, or a similar library, that provides behavior and accessibility. It handles keyboard interaction, focus management, and ARIA attributes. It has no visual styling.
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
<DropdownMenu.Root>
<DropdownMenu.Trigger>Open</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>Profile</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
The primitive renders a trigger and a content container. It manages open and closed state, keyboard navigation, and focus. It renders plain elements with ARIA attributes and data attributes. It ships no CSS.
The wrapper layer
The wrapper is the copied component. It wraps the primitive, adds the styling layer, and re-exports the primitive’s parts with a consistent API.
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 shadow-md',
className
)}
{...props}
/>
));
The wrapper forwards the ref, passes through props, and supplies default classes that the consumer can override.
The styling layer
The styling layer is Tailwind plus cva. Variants are defined with cva, and the consumer’s className is merged with tailwind-merge via the cn helper.
const itemVariants = cva(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
{
variants: {
inset: {
true: 'pl-8',
false: '',
},
},
defaultVariants: {
inset: false,
},
}
);
The variants cover the cases the component’s API exposes. The consumer can override any utility with className.
The token layer
The token layer is CSS variables that the Tailwind configuration maps to utility classes. This is what makes the components themeable without editing each component.
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--radius: 0.5rem;
}
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
border: 'hsl(var(--border))',
},
borderRadius: {
lg: 'var(--radius)',
},
},
},
};
Components use bg-primary, text-primary-foreground, and rounded-lg rather than literal colors. Changing the CSS variable changes every component at once.
b. The cn helper and file structure
Two conventions make the pattern practical across many components: a single merge helper, and a predictable directory layout.
The cn helper
The cn helper is a small function that combines clsx and tailwind-merge. It accepts any number of class values, flattens them, and resolves conflicts.
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Every component imports cn from the same place. This ensures that conditional classes and consumer overrides are handled consistently.
<div className={cn('rounded border p-4', isActive && 'border-blue-500', className)} />
The clsx part handles conditional values, arrays, and objects. The twMerge part resolves conflicts.
The directory layout
The pattern uses a flat components/ui directory for the copied components and a components directory for application-level compositions.
src/
components/
ui/
button.tsx
dropdown-menu.tsx
dialog.tsx
input.tsx
app-header.tsx
user-menu.tsx
lib/
utils.ts
The ui directory contains only copied, unstyled-pattern components. The application-level components compose them. The distinction keeps the design system separate from the product code.
The component file structure
Each component file follows the same internal order: imports, variant definitions, sub-components, and named exports. This consistency makes any file navigable without reading it from the top.
// 1. Imports
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
// 2. Variants
const buttonVariants = cva('...', { variants: { ... } });
// 3. Sub-components
const Button = React.forwardRef(...);
// 4. Exports
export { Button, buttonVariants };
The buttonVariants export lets consumers compose the variant classes into their own components when needed.
c. Building a component end to end
This section builds one component using the pattern: a Button. The same steps apply to every component in the library.
Step 1: Define the variants
The variants map the component’s API to class strings. The base classes describe the shared appearance; the variant options describe the differences.
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
The variant and size names are the component’s public API. Consumers select them by prop.
Step 2: Define the component
The component uses forwardRef to forward the ref to the underlying element, extracts the variant props from the cva configuration, and merges the consumer’s className with cn.
import * as React from 'react';
import { cn } from '@/lib/utils';
import { buttonVariants } from './button-variants';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
The asChild prop uses the Slot from Radix UI to let the consumer replace the default button element with a link or another element. The forwardRef keeps the component compatible with libraries that need a ref. The VariantProps type infers the variant and size unions from the cva configuration.
Step 3: Compose with variants
A consumer component composes the Button and its variants. It does not fork the Button file.
import { Button } from '@/components/ui/button';
export function SaveButton({ onSave }: { onSave: () => void }) {
return (
<Button variant="default" size="lg" onClick={onSave}>
Save changes
</Button>
);
}
If the consumer needs different styling for a specific instance, it passes a className. The variant still supplies the base, and cn resolves the conflicts.
<Button variant="outline" className="w-full justify-start">
Continue with email
</Button>
The w-full and justify-start override the default width and alignment without touching the component file.
Step 4: Extend with a wrapper
When a consumer needs a reusable variation, it creates a wrapper component rather than editing the Button. The wrapper composes the Button and its props.
import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface IconButtonProps extends ButtonProps {
icon: React.ReactNode;
}
export function IconButton({ icon, className, children, ...props }: IconButtonProps) {
return (
<Button className={cn('gap-2', className)} {...props}>
{icon}
{children}
</Button>
);
}
The wrapper composes the existing component. It does not copy its source.
Step 5: Theme with tokens
The component uses token-based classes such as bg-primary and text-primary-foreground. Changing the theme means changing the CSS variables, not the component.
:root {
--primary: 240 5.9% 10%;
}
.dark {
--primary: 0 0% 98%;
}
// No change to the component:
<Button variant="default">Save</Button>
The same button now renders with different colors in light and dark mode. No component file was edited.
Complete Example Session
// ============================================
// PART 1: THE UTILS HELPER
// ============================================
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// ============================================
// PART 2: THE VARIANTS
// ============================================
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3',
lg: 'h-11 px-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
// ============================================
// PART 3: THE COMPONENT
// ============================================
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
// ============================================
// PART 4: CONSUMING THE COMPONENT
// ============================================
import { Button } from '@/components/ui/button';
export function Toolbar() {
return (
<div className="flex gap-2">
<Button>Save</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost" size="sm">Help</Button>
</div>
);
}
// ============================================
// PART 5: OVERRIDING WITH className
// ============================================
<Button variant="outline" className="w-full justify-start">
Continue with email
</Button>
// The base classes come from the cva variant.
// w-full and justify-start override the defaults.
// cn resolves the conflicts.
// No component file was edited.
// ============================================
// PART 6: COMPOSING A WRAPPER
// ============================================
import { Button, type ButtonProps } from '@/components/ui/button';
interface IconButtonProps extends ButtonProps {
icon: React.ReactNode;
}
export function IconButton({ icon, children, className, ...props }: IconButtonProps) {
return (
<Button className={cn('gap-2', className)} {...props}>
{icon}
{children}
</Button>
);
}
// ============================================
// PART 7: THEMING WITH CSS VARIABLES
// ============================================
// app/globals.css
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--border: 240 3.7% 15.9%;
}
// tailwind.config.ts
export default {
darkMode: ['class'],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
border: 'hsl(var(--border))',
},
borderRadius: {
md: 'var(--radius)',
},
},
},
};
// ============================================
// PART 8: A DIALOG COMPONENT
// ============================================
// components/ui/dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog';
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = 'DialogContent';
export { DialogContent };
// ============================================
// PART 9: COMPOSING THE DIALOG
// ============================================
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
export function ConfirmDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="ghost">Cancel</Button>
<Button variant="destructive">Delete</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ============================================
// PART 10: THE PATTERN IN PRACTICE
// ============================================
// 1. The components live in the project's src/components/ui.
// 2. They wrap Radix primitives for behavior.
// 3. They use cva for variants.
// 4. They use cn for merging.
// 5. They use CSS variable tokens for theming.
// 6. Consumers compose them; they do not fork them.
// 7. Wrappers extend them; they do not edit them.
// 8. Updates are pulled from upstream by diffing, not by upgrading a package.
The ten parts progressed from the cn helper through the button component, consumer usage, overrides, wrapper composition, theming, a dialog example, and the pattern summary. The same button and dialog scenarios ran through every part, showing how the layers fit together.
Quick Reference
The Four Layers
| Layer | Responsibility | Owner |
|---|---|---|
| Primitive | Behavior, accessibility | Radix UI |
| Wrapper | Re-export, ref forwarding, className | Copied into project |
| Styling | cva variants, Tailwind classes | Copied into project |
| Token | CSS variables, Tailwind config | Project theme |
The cn Helper
| Part | Role |
|---|---|
clsx | Flatten conditionals, arrays, objects |
twMerge | Resolve conflicting utilities |
cn | Combine both into one function |
Component File Conventions
| Convention | Purpose |
|---|---|
forwardRef | Ref compatibility |
VariantProps | TypeScript inference from cva |
asChild | Element replacement via Slot |
| Named exports | Import consistency |
displayName | DevTools readability |
Directory Layout
| Path | Contents |
|---|---|
components/ui | Copied primitives and variants |
components | Application-level compositions |
lib/utils.ts | The cn helper |
app/globals.css | CSS variable tokens |
tailwind.config | Token-to-utility mapping |
Best Practices
✅ Do This:
// Use cn for every className merge.
className={cn(buttonVariants({ variant, size }), className)} // ✅
// Forward refs to the underlying element.
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(...); // ✅
// Use VariantProps to infer types from cva.
interface ButtonProps extends VariantProps<typeof buttonVariants> {} // ✅
// Use token-based classes instead of literal colors.
className="bg-primary text-primary-foreground" // ✅
// Compose wrappers instead of editing components.
<Button className={cn('gap-2', className)} {...props} /> // ✅
// Support asChild for element replacement.
<DialogTrigger asChild><Button>Open</Button></DialogTrigger> // ✅
// Keep the ui directory as the design system layer.
src/components/ui/button.tsx // ✅
❌ Don’t Do This:
// Don't concatenate class strings without cn.
className={`px-4 ${className}`} // ❌ Conflicts unresolved
// Don't use literal colors in components.
className="bg-gray-900 text-white" // ❌ Not themeable
// Don't fork a component for a variation.
// Copy button.tsx into a custom file. // ❌ Use a wrapper
// Don't skip forwardRef.
const Button = (props) => <button {...props} /> // ❌ No ref support
// Don't mix tokens and literals in the same component.
className="bg-primary text-gray-100" // ❌ Inconsistent theming
// Don't edit the primitive's internals.
// The wrapper exists for that. // ❌
// Don't place application compositions in the ui directory.
src/components/ui/user-menu.tsx // ❌ Belongs in components/
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Override does not win | Missing cn merge | Use cn in every component |
| Theme change requires edits | Literal colors in components | Use token-based classes |
| Ref not forwarded | forwardRef omitted | Wrap the component in forwardRef |
| Variant types wrong | Manual unions | Use VariantProps |
| Updates lost | No upstream diff process | Track upstream and merge selectively |
| Inconsistent conventions | Each file written differently | Follow the shared file structure |
| ui directory polluted | Application components mixed in | Keep ui for design system only |
| asChild breaks styles | Slot does not merge className | Merge via cn in the Slot call |
Real-World Examples
1. The cn Helper
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
2. A Simple Component
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input ref={ref} className={cn('h-10 rounded-md border px-3', className)} {...props} />
)
);
3. A Variant
const badgeVariants = cva('rounded-full px-2.5 text-xs', {
variants: { variant: { default: 'bg-primary text-primary-foreground' } },
});
4. Consuming a Component
<Button variant="outline" size="sm">Cancel</Button>
5. Overriding with className
<Button className="w-full justify-start">Continue</Button>
6. Composing a Wrapper
export function IconButton({ icon, ...props }) {
return <Button className="gap-2" {...props}>{icon}</Button>;
}
7. asChild for a Link
<Button asChild>
<a href="/dashboard">Dashboard</a>
</Button>
8. Theming with Tokens
:root { --primary: 240 5.9% 10%; }
.dark { --primary: 0 0% 98%; }
9. Radix Primitive Wrapping
<DialogPrimitive.Content ref={ref} className={cn('rounded-lg p-6', className)} {...props} />
10. Exporting Variants
export { Button, buttonVariants };
Visual
The Four Layers
┌────────────────────────────────────────────────────────────┐
│ COMPONENT ARCHITECTURE LAYERS │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PRIMITIVE (Radix UI) │ │
│ │ Behavior, keyboard, ARIA, focus │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ WRAPPER (copied into project) │ │
│ │ forwardRef, prop passthrough, className │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ STYLING (cva + Tailwind) │ │
│ │ Variants, base classes, cn merge │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ TOKENS (CSS variables) │ │
│ │ bg-primary, text-foreground, rounded-md │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Each layer is replaceable without touching the others. │
│ │
└────────────────────────────────────────────────────────────┘
Ownership vs Dependency
┌────────────────────────────────────────────────────────────┐
│ PACKAGE vs COPIED COMPONENT │
│ │
│ PACKAGE (node_modules) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ import { Button } from 'some-library' │ │
│ │ Read-only. Version-locked. Fork to change. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ COPIED (src/components/ui) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ import { Button } from '@/components/ui/button' │ │
│ │ Editable. Yours. Change today, not upstream. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The trade: no automatic updates, full control. │
│ Updates are merged manually by diffing upstream. │
│ │
└────────────────────────────────────────────────────────────┘
The cn Merge Flow
┌────────────────────────────────────────────────────────────┐
│ cn() MERGE FLOW │
│ │
│ Inputs: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ buttonVariants({ variant, size }) │ │
│ │ → "h-10 px-4 bg-primary text-primary-fg" │ │
│ │ │ │
│ │ className │ │
│ │ → "px-8 w-full" │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ clsx(...) │ │
│ │ Flattens into one string. │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ twMerge(...) │ │
│ │ Resolves conflicts: │ │
│ │ px-4 vs px-8 → px-8 wins │ │
│ │ h-10, py-2 kept │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Final: "h-10 px-8 py-2 bg-primary text-primary- │ │
│ │ fg w-full" │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
Theming with Tokens
┌────────────────────────────────────────────────────────────┐
│ TOKEN-BASED THEMING │
│ │
│ Component: className="bg-primary text-primary-foreground"│
│ │ │
│ v │
│ Tailwind: bg-primary = hsl(var(--primary)) │
│ │ │
│ v │
│ CSS var: --primary: 240 5.9% 10% │
│ │
│ Change the CSS variable: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ :root { --primary: 240 5.9% 10%; } ← light │ │
│ │ .dark { --primary: 0 0% 98%; } ← dark │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Every component using bg-primary updates at once. │
│ No component file was edited. │
│ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| shadcn/ui pattern | Copy component source into the project instead of installing |
| Primitive | Headless behavior from Radix UI |
| Wrapper | Copied component with ref forwarding and className |
| Styling | cva variants plus Tailwind utilities |
| Token | CSS variables mapped to Tailwind theme |
cn helper | clsx plus twMerge for conditional and conflict-safe merges |
forwardRef | Ref compatibility for consumers |
VariantProps | TypeScript inference from cva configuration |
asChild | Slot-based element replacement |
components/ui | Design system layer |
components | Application composition layer |
| Update model | Manual diff against upstream |
Key takeaways:
- The pattern is about ownership, not components. The value is that the component source lives in the project and can be edited without forking a package. The distribution model is the innovation.
- Four layers stack cleanly. The primitive provides behavior, the wrapper re-exports it with a consistent API, the styling layer supplies variants and classes, and the token layer makes everything themeable.
- One
cnhelper everywhere. Every component merges its classes withcn, so conditional classes and consumer overrides resolve consistently. There is no per-component merge logic to keep in sync. - Tokens make theming central. Components use
bg-primaryandtext-foreground, never literal colors. Changing a CSS variable re-themes every component at once. - Variants are the component’s API. The
variantandsizeprops are the public surface. Adding a variant means adding an option to the cva configuration, and TypeScript updates automatically. - Compose, do not fork. When a variation is needed, the consumer wraps the component rather than editing its source. The wrapper inherits the behavior, accessibility, and theming.
- The update trade-off is deliberate. Copied components do not receive upstream fixes automatically. The team merges changes selectively when it chooses. For most applications, control is worth more than automatic updates.
- Conventions carry the pattern. Consistent file structure, naming, exports, and the shared
cnhelper are what make dozens of copied components feel like a coherent library.
Remember: The shadcn/ui pattern is a distribution model. It ships source code, not a package. Each component is a wrapper around a headless primitive, styled with cva and Tailwind, themed with CSS variables, and merged with a shared cn helper. The consumer owns the code, edits it freely, and composes wrappers rather than forking files. The result behaves like a library, because the conventions are consistent, but it evolves like the product, because the source lives in the project. The trade is explicit: you give up automatic updates and gain full control. For a design system that needs to evolve with the application, that is the right trade.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!