Building Your Own Component Library
shadcn/ui is not a library in the classic sense, it is a catalog of copy-paste components built on Radix UI. Anyone who understands the copy-paste model can build a fully controllable, themeable component library for their own design system on top of it.
Table of Contents
- 1. Understanding the copy-paste model
- 2. Project setup: initializing shadcn/ui
- 3. Theming with CSS variables
- 4. Implementing dark mode correctly
- 5. Custom variants with cva and cn
- 6. Custom components built on Radix
- 7. Documentation with Storybook
- 8. Component libraries in a monorepo
- 9. shadcn/ui vs. classic component libraries
- 10. Summary
- 11. FAQ
1. Understanding the copy-paste model
shadcn/ui is fundamentally different from MUI, Ant Design or Chakra UI. It is not an npm package that you install and import. It is a catalog of ready-made components that you copy into your own project with a CLI command. The code then belongs entirely to the project, no dependency, no breaking changes on library updates, no constraints from a library API. Anyone running npx shadcn@latest add button gets a button.tsx file in their project that they can modify however they like.
This model has a decisive advantage over classic component libraries: control lies entirely with the developer. There are no props the library did not anticipate, no styling conflicts between library CSS and your own CSS, and no versioning dependencies that block upgrades. The price: updates from the shadcn/ui catalog do not arrive automatically. Anyone who wants bug fixes or new features has to merge them manually. That is a deliberate trade-off, and for most teams it is the right one.
shadcn/ui is built on two layers: Radix UI as accessible, unstyled primitive components (Dialog, Dropdown, Select, Slider, Toast, and so on) and Tailwind CSS for styling. Radix handles all the accessibility work (ARIA, keyboard navigation, focus management), while Tailwind handles the visual design. The components from shadcn/ui are essentially styled wrappers around Radix primitives, an abstraction layer you keep entirely in your own code.
2. Project setup: initializing shadcn/ui
Initializing shadcn/ui in an existing or new React project is done with npx shadcn@latest init. The CLI asks for the framework (Next.js, Vite, Remix, and so on), the style (Default or New York), the base color, and the path for the components. The result is a components.json in the project root containing all configuration parameters, plus a globals.css with the CSS variables for theming and a lib/utils.ts with the cn() helper function.
The cn() function combines clsx for conditional classes and tailwind-merge for correctly resolving Tailwind class conflicts. This is the heart of the shadcn/ui styling architecture: when a component has default classes and an application passes its own classes, tailwind-merge resolves conflicts in favor of the more specific class. That enables a clean override pattern without !important and without CSS specificity battles.
// lib/utils.ts - the foundation of shadcn/ui styling
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
// Merges Tailwind classes intelligently, later classes win conflicts
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// components/ui/button.tsx - example of a shadcn/ui component
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
// cva: define all visual variants declaratively
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-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
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;
}
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 };
3. Theming with CSS variables
The theming system of shadcn/ui is built entirely on CSS custom properties (CSS variables). None of the component colors are defined as fixed Tailwind colors, instead they are references to CSS variables: bg-primary becomes background-color: hsl(var(--primary)). The CSS variables themselves are defined in the :root selector inside globals.css and can be overridden for dark mode in the .dark selector.
For your own component library, this means: changing the CSS variables changes the entire visual appearance of every component without touching a single component file. A corporate design with its own primary and secondary colors gets implemented entirely through CSS variables. For multi-tenant applications that need to display different themes for different customers, themes can be implemented as sets of CSS variables that get switched dynamically via JavaScript or a CSS class.
/* globals.css - CSS variable theming system */
@layer base {
:root {
/* Base colors in HSL without the hsl() wrapper, enables opacity modifiers */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%; /* custom brand blue */
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem; /* global border-radius token */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%; /* lighter shade for dark background */
--primary-foreground: 222.2 47.4% 11.2%;
/* ... all tokens redefined for dark mode */
}
}
4. Implementing dark mode correctly
The shadcn/ui theming system makes dark mode comparatively simple: all color tokens are defined as CSS variables that get overridden in the .dark selector. The one moving part is that the .dark class has to be added to or removed from the html or body element at the right moment. In Next.js this is handled by the next-themes library, which stores the theme in localStorage and respects system preferences.
A common mistake with dark mode is the flash of unstyled content (FOUC): the page briefly loads in light mode before the saved dark theme from localStorage gets applied. This happens because JavaScript runs after HTML parsing. The fix: an inline <script> tag in the <head> that synchronously (blocking) reads the theme from localStorage and sets the class before browser rendering begins. next-themes implements this pattern automatically. For custom implementations without next-themes, this is the critical spot to get right.
5. Custom variants with cva and cn
class-variance-authority (cva) is the type-safe way to build components with multiple variants. Instead of chaining conditional classes with long ternary expressions, you define all variants declaratively in a single cva() call. The result is a type-safe function that turns props into classes, with full TypeScript support for every defined variant. Anyone working with shadcn/ui learns cva as a fundamental tool for their own component library.
Custom components for the design system are built following the same pattern as the shadcn/ui base components: cva() for variant definitions, cn() for class merging, VariantProps<typeof componentVariants> for type-safe props. Building custom components with this pattern makes them immediately compatible with the shadcn/ui theming system and automatically supports dark mode through the CSS variables.
6. Custom components built on Radix
The most important advantage of shadcn/ui as a foundation: you get direct access to Radix UI primitives for your own components. Radix provides unstyled, fully accessible primitives for all common UI patterns, from simple buttons and checkboxes to complex dropdown menus, modals, tooltips and date pickers. Every Radix primitive takes care of ARIA attributes, keyboard navigation and focus management, things that would take weeks to implement correctly from scratch.
Custom components built on Radix combine Radix primitives with cva variant definitions and CSS variables. A custom accordion, a custom navigation menu, or a combobox pattern all follow the same structure: Radix for logic and accessibility, Tailwind for styling, cva for variants, cn for merging. That produces a consistent component library where every component shares the same API philosophy and lives in the same theming system.
// Custom component built on Radix primitives - same pattern as shadcn/ui
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const tooltipContentVariants = cva(
// Base styles, uses CSS variables for theming + dark mode support
'z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
muted: 'bg-muted text-muted-foreground border border-border',
destructive: 'bg-destructive text-destructive-foreground',
},
},
defaultVariants: { variant: 'default' },
}
);
interface TooltipContentProps
extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
VariantProps<typeof tooltipContentVariants> {}
// Wrap Radix primitive with custom styling, Radix handles all a11y
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
TooltipContentProps
>(({ className, variant, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(tooltipContentVariants({ variant, className }))}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { TooltipContent };
7. Documentation with Storybook
Storybook is the standard tool for documenting React component libraries and complements shadcn/ui-based design systems perfectly. Every component gets a story file that visually documents all variants, states and use cases. In 2026, Storybook 8 with its new Controls system and automatic documentation generation from component docstrings is state of the art. Tailwind CSS integration happens through the official PostCSS addon.
For Storybook integration with shadcn/ui, the CSS variables need to be wired into the Storybook preview correctly. That happens via .storybook/preview.css, which imports the globals.css containing the variable definitions. Dark mode stories can be implemented with the @storybook/addon-themes addon: a theme switcher in the Storybook toolbar toggles between light and dark mode by toggling the .dark class on the story container.
8. Component libraries in a monorepo
When the shadcn/ui-based component library needs to be used by multiple projects, for instance in a monorepo with several Next.js apps and an Expo app, a dedicated package inside the monorepo is the recommended approach. With Turborepo or nx as the monorepo tool and a packages/ui package, the component library can be defined once and imported by every app. Since version 2, shadcn/ui has explicit monorepo documentation and supports the components.json path configuration parameter for monorepo setups.
The most important decision in a monorepo setup: whether Tailwind classes should be bundled inside the UI package, or whether each app provides its own Tailwind configuration for the UI package. The latter model is more flexible, every app can theme the design system, but requires all apps to reference the UI package's Tailwind configuration in their content array. The former model is simpler but less flexible for multi-branding requirements.
9. shadcn/ui vs. classic component libraries
The decision between shadcn/ui and classic component libraries depends on project requirements and the team. Both approaches have their merits, the following table helps with the decision.
| Criterion | shadcn/ui | MUI / Ant Design | Chakra UI |
|---|---|---|---|
| Control over code | Full | None (npm package) | None (npm package) |
| Theming effort | CSS variables, minimal | Theme object, complex | Theme object, medium |
| Bundle size | Only components in use | Tree-shaking, but larger | Medium |
| Automatic updates | No, manual merge | Yes (npm update) | Yes (npm update) |
| Number of components | ~50 base components | 200+ components | 80+ components |
10. Summary
shadcn/ui as the foundation for your own component library is one of the most popular approaches for React design systems in 2026, and for good reason. The copy-paste model gives teams full control over component code without having to give up the accessibility foundations of Radix UI or the powerful CSS-variable theming system. Custom components can be built following the same pattern with cva, cn and Radix primitives, and they integrate seamlessly into the existing theming.
The decisive advantage of shadcn/ui over classic libraries: no versioning dependencies, no styling conflicts, no theming overhead. The decisive downside: updates have to be merged manually, and for complex components that shadcn/ui does not cover, you have to reach directly for Radix primitives. For teams that want to build a professional design system with full control, this approach is one of the best options currently available.
shadcn/ui component library, the essentials at a glance
Copy-paste model
Code belongs to the project, no library dependency, no breaking changes, full control over every component.
CSS-variable theming
All color tokens as CSS custom properties, dark mode and multi-branding through variable overrides, no component changes needed.
cva + cn pattern
class-variance-authority for type-safe variants, tailwind-merge for conflict-free class overriding. Build custom components on the same pattern.
Radix UI primitives
Unstyled, fully accessible primitives for all common UI patterns. ARIA, keyboard navigation and focus management included.
Mironsoft
React design systems, shadcn/ui component libraries and Storybook documentation
Need your own React component library?
We build fully controllable, themeable component libraries for React projects on a shadcn/ui foundation, with Storybook documentation, monorepo setup and corporate design integration.
Design system
shadcn/ui foundation with your own corporate theme, CSS variables and dark mode support
Storybook setup
Complete component documentation with stories, controls and a dark mode preview
Monorepo integration
packages/ui setup with Turborepo and multi-app theming for complex monorepo structures