shadcn/ui: Building Your Own Component Library
AI generated
</>
{ }
React · shadcn/ui · Radix UI · Tailwind CSS · Design System
shadcn/ui:
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.

15 min read shadcn/ui · Radix UI · CSS variables · Dark Mode · cva React 18 · React 19 · Tailwind CSS v4 · Next.js

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

11. FAQ: shadcn/ui component library

1What is shadcn/ui and how is it different from MUI?
shadcn/ui is not an npm package, it is a copy-paste catalog. The code belongs to the project. MUI is a package with automatic updates but limited customizability.
2How does theming work?
CSS custom properties in the :root selector. All color tokens are variables. Dark mode by overriding the same variables in the .dark selector.
3What is cva?
class-variance-authority, type-safe component variants. Declarative variant definition instead of chaining ternary classes, with full TypeScript support.
4What is Radix UI?
Unstyled, fully accessible React primitives. Handles ARIA, keyboard navigation and focus management. shadcn/ui is a styled wrapper around Radix primitives.
5Preventing the dark mode flash of unstyled content?
An inline script in the head reads localStorage synchronously and sets the dark class before browser rendering. next-themes does this automatically for Next.js.
6shadcn/ui in a monorepo?
Yes. packages/ui package with Turborepo or nx. components.json configures the paths. All apps import from the central UI package.
7How do I update shadcn/ui components?
Manually with the diff command. Changes from the catalog get merged into your own components. A deliberate trade-off of the copy-paste model.
8What does cn() do?
clsx for conditional classes plus tailwind-merge for conflict resolution. Passed-in classes win against default classes, without !important.
9Storybook documentation for shadcn/ui?
Import CSS variables in preview.css, @storybook/addon-themes for dark mode. Stories for all variants and states. Autodocs from TypeScript types.
10shadcn/ui outside of Next.js too?
Yes, Vite, Remix, Astro and every React setup are supported. CLI configures framework-specific paths. Some Next.js-specific parts need to be adapted.