shadcn/ui is not a component library in the traditional sense — you don’t install it as an npm package and import components from node_modules. Instead, it’s a registry of component source code that you copy into your own project. The CLI command npx shadcn@latest add button fetches the Button component’s source code from the registry, writes it to your components/ui/button.tsx file, installs any required dependencies (like Radix UI primitives), and updates your Tailwind CSS configuration if needed. You now own that code — you can modify it, extend it, or delete parts you don’t need, with no upstream dependency to manage.
The registry itself is a JSON manifest (registry.json or an HTTP API) that describes every available component: its name, source files, npm dependencies, Tailwind CSS requirements, and relationships to other components. When you add a component that depends on another (e.g., Dialog depends on DialogPrimitive from Radix and internally uses your project’s Button), the CLI resolves the dependency graph and adds all required pieces. This registry pattern enables a distribution model where the library author provides high-quality defaults but the consumer has full ownership and control.
An internal component library builds on this pattern to distribute a custom registry of 50+ search-specific components. Instead of publishing to npm, the team maintains a registry.json served via GitHub raw URLs. Internal projects (the merchant dashboard, the documentation site, merchant-facing embeds) consume components from this registry using the same shadcn CLI, but pointed at the custom registry URL. This gives each consuming project its own copy of the component source — styled to the project’s design tokens, modifiable without publishing a new library version, and free from the versioning headaches of shared npm packages.
The fundamental insight of shadcn/ui is that component code belongs in your project, not in node_modules. Traditional component libraries force you into their API surface — if you need a Button variant they don’t support, you override CSS, wrap the component, or open a PR upstream. With copy-paste architecture, the component source is yours from the moment it’s added. The CLI is a scaffolding tool, not a package manager. There’s no version to bump, no breaking changes to migrate, and no tree-shaking to worry about because unused components aren’t in your project at all.
The registry is a JSON file or API endpoint that maps component names to their metadata: source file paths, npm dependencies, peer component dependencies, required CSS variables, and Tailwind config extensions. When the CLI resolves add dialog, it reads the registry entry for “dialog,” finds that it requires @radix-ui/react-dialog as an npm dependency and button as a peer component, installs the npm package, and copies both the dialog and button source files if button isn’t already present. The registry is the single source of truth for the component graph.
shadcn/ui components are built on Radix UI primitives — unstyled, accessible, behavior-only components. The shadcn layer adds Tailwind CSS styling, variant props via class-variance-authority (CVA), and composition patterns. A shadcn Button is a Radix Slot-enabled component with predefined variants (default, destructive, outline, ghost) and sizes (sm, md, lg). Because you own the source, adding a new variant is a one-line addition to the CVA config, not a library PR.
CVA is a utility for creating type-safe component variants. You define a base class, variant groups (each with named options mapping to CSS classes), and default variants. CVA returns a function that accepts variant props and returns the correct class string. Combined with tailwind-merge (which deduplicates conflicting Tailwind classes), this creates a variant system where <Button variant="destructive" size="lg"> produces exactly the right classes with no conflicts or duplicates. The variant types are inferred automatically, so TypeScript catches invalid variant names at compile time.
shadcn/ui components reference CSS custom properties (design tokens) rather than hard-coded colors. The registry includes a globals.css template that defines tokens like --background, --foreground, --primary, --border, and --ring for both light and dark modes. Tailwind is configured to map these tokens to utility classes (bg-background, text-foreground). Theming the entire component set requires only changing the CSS custom properties — every component automatically picks up the new values.
The registry manifest describes each component with enough metadata for the CLI to resolve dependencies, install packages, and write files to the correct locations.
// registry.json
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "myapp-ui",
"homepage": "https://github.com/example-org/custom-ui-registry",
"items": [
{
"name": "button",
"type": "registry:ui",
"title": "Button",
"description": "A button component with variants and sizes",
"dependencies": [],
"registryDependencies": [],
"files": [
{
"path": "registry/ui/button.tsx",
"type": "registry:ui",
"target": "components/ui/button.tsx"
}
],
"tailwind": {
"config": {}
}
},
{
"name": "search-bar",
"type": "registry:ui",
"title": "Search Bar",
"description": "Autocomplete search input with keyboard navigation",
"dependencies": ["@radix-ui/react-popover", "cmdk"],
"registryDependencies": ["button", "popover", "command"],
"files": [
{
"path": "registry/ui/search-bar.tsx",
"type": "registry:ui",
"target": "components/ui/search-bar.tsx"
}
]
}
]
}
Each component uses class-variance-authority to define variant styles and tailwind-merge via the cn() utility to merge class names safely.
// components/ui/button.tsx
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";
const buttonVariants = cva(
// Base classes applied to every variant
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground 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-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
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 };
To consume components from a custom registry (like the internal component library), configure the components.json in the consuming project to point at your registry URL.
// components.json (in consuming project)
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"myregistry": {
"url": "https://raw.githubusercontent.com/example-org/custom-ui-registry/main/registry"
}
}
}
// Then add components from the custom registry:
// npx shadcn@latest add myregistry/search-bar
// npx shadcn@latest add myregistry/answer-panel
The internal component library maintains a registry of 50+ components purpose-built for the search product. These include domain-specific components like SearchBar (autocomplete with keyboard navigation and recent search history), AnswerPanel (streaming AI response display with markdown rendering and citations), FacetFilter (dynamic faceted search with checkbox groups and range sliders), and ResultCard (product/document cards with highlighted matching text). Each component is built on Radix primitives for accessibility, styled with Tailwind using the shared design tokens, and distributed via the custom registry.
The merchant dashboard consumes these components for its admin views and storefront widgets. When the design team updates a component (e.g., changing the AnswerPanel to support collapsible sections), they update the source in the library’s registry. Consuming projects pull the update with npx shadcn@latest add myregistry/answer-panel — the CLI overwrites the local copy with the updated version, and the developer reviews the diff before committing. This is deliberate: updates are opt-in and reviewable, unlike npm updates that happen silently via semver ranges.
The documentation site uses a special registry mode where components are both rendered as live examples and displayed as copyable source code. The registry’s file metadata includes the source path, so the docs site fetches the raw TypeScript source and renders it alongside the interactive preview — ensuring the displayed code always matches the actual component implementation.
Every shadcn/ui project includes a tiny utility that combines clsx (conditional class joining) with tailwind-merge (deduplication of conflicting Tailwind classes). This is the foundation of the variant system.
// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Usage: conflicting classes are resolved correctly
cn("px-4 py-2", "px-6") // → "py-2 px-6" (px-4 removed)
cn("text-red-500", "text-blue-500") // → "text-blue-500" (red removed)
cn("rounded-md", className) // → consumer's className wins for conflicts
Adding a new component to the custom registry follows a consistent pattern: build on a Radix primitive, style with CVA + Tailwind, document in the registry manifest.
// registry/ui/facet-filter.tsx
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
const facetItemVariants = cva(
"flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer transition-colors",
{
variants: {
state: {
unchecked: "hover:bg-accent/50",
checked: "bg-accent/20 text-accent-foreground",
disabled: "opacity-50 cursor-not-allowed",
},
},
defaultVariants: { state: "unchecked" },
}
);
interface FacetFilterProps {
title: string;
options: Array<{ label: string; value: string; count: number }>;
selected: string[];
onSelectionChange: (values: string[]) => void;
maxVisible?: number;
}
export function FacetFilter({
title,
options,
selected,
onSelectionChange,
maxVisible = 5,
}: FacetFilterProps) {
const [expanded, setExpanded] = React.useState(false);
const visible = expanded ? options : options.slice(0, maxVisible);
const toggle = (value: string) => {
onSelectionChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value]
);
};
return (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">{title}</h4>
{visible.map((opt) => (
<label
key={opt.value}
className={cn(facetItemVariants({
state: selected.includes(opt.value) ? "checked" : "unchecked",
}))}
>
<CheckboxPrimitive.Root
checked={selected.includes(opt.value)}
onCheckedChange={() => toggle(opt.value)}
className="h-4 w-4 rounded border border-input"
>
<CheckboxPrimitive.Indicator>
<Check className="h-3 w-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<span className="text-sm">{opt.label}</span>
<span className="ml-auto text-xs text-muted-foreground">{opt.count}</span>
</label>
))}
{options.length > maxVisible && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline"
>
{expanded ? "Show less" : `Show ${options.length - maxVisible} more`}
</button>
)}
</div>
);
}
git diff to review before accepting updates. For heavily customized components, consider them “detached” from the registry and maintain them manually.tailwind-merge in the cn() helper, consumer class overrides don’t work correctly. cn("px-4", "px-6") produces "px-4 px-6" without tailwind-merge (both apply, last wins in CSS specificity), but "px-6" with it (px-4 is removed). Always use cn() for class composition.registryDependencies field must list it. Otherwise, npx shadcn add dialog won’t install the Button, and the component will fail to compile with a missing import error.--primary) with different values, the last-loaded one wins. When using multiple registries, namespace your tokens (e.g., --myregistry-primary) or ensure all registries share the same token definitions.content config. If your component files are in an unexpected location (e.g., registry/ui/ instead of components/ui/), their classes get purged in production. Ensure your Tailwind config’s content array includes all paths where component source files live.