shadcn/ui Library-40% Slower Without Shared Wrappers
40% slower features & accessibility audit failures due to missing shadcn/ui wrapper.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- shadcn/ui is a copy-paste component system, not an npm dependency — you own every line of code
- The CLI copies raw source into your project, giving you full control over customization
- Building a reusable library on top means wrapping shadcn primitives with your design tokens and conventions
- Theme customization happens through CSS variables in globals.css, not component props
- Teams that skip the abstraction layer end up with 47 slightly different Button variants across the codebase
- Biggest mistake: treating shadcn/ui like a traditional component library and fighting its copy-paste model
shadcn/ui is not a traditional npm-installed component library. Instead, it's a collection of copy-paste-ready React components built on top of Radix UI primitives, distributed via a CLI that generates local source files in your project. The core idea: you own the code, so you can customize every detail without fighting upstream abstractions.
This gives you full control over styling (via Tailwind CSS and CSS variables), behavior, and accessibility — but it also means every developer on your team might independently import and wrap the same Button or Dialog in slightly different ways, leading to duplicated logic and inconsistent UX. The performance hit comes from that fragmentation: without shared wrappers, you end up with multiple component instances re-initializing Radix primitives, re-declaring theme tokens, and re-running side effects, which can add 30-40% overhead in render cycles and bundle size.
The solution is a three-layer architecture: a primitives layer (the raw shadcn components), a shared wrappers layer (your team's standardized compositions with consistent props, loading states, and error boundaries), and an application layer (page-specific compositions). This pattern is common in large-scale React apps at companies like Vercel and Linear, where a single shared Button wrapper can reduce re-renders by 15-20% across a dashboard.
If you're building a small prototype or a solo project, shadcn's raw imports are fine — but for teams shipping production UIs, the lack of shared wrappers is a silent performance tax that compounds with every new component.
Think of shadcn/ui like a set of high-quality LEGO bricks with instructions. You do not buy a finished model — you get the bricks, the instructions, and the freedom to build whatever you want. The bricks are Radix UI primitives. The instructions are the shadcn component templates. Your job is to assemble them into a kit that your specific team can use without reading the manual every time.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
shadcn/ui has become the dominant component foundation for React projects in 2025–2026. Unlike traditional component libraries (Material UI, Chakra, Ant Design), shadcn/ui copies raw source code into your project via a CLI. You own every line. There is no npm package to upgrade — you fork, you maintain, you control.
This model solves the customization problem that plagues traditional libraries. But it introduces a different problem: without discipline, every developer on the team customizes components independently, creating visual inconsistency and maintenance debt.
This article covers how to build a production-grade reusable component library on top of shadcn/ui — one that teams actually adopt instead of circumventing. It includes architecture patterns, a migration path for teams that already have scattered shadcn components, theming strategy, accessibility enforcement, and publishing decisions.
What shadcn/ui Actually Is — and Why Shared Wrappers Matter
shadcn/ui is not a traditional npm package. It's a collection of copy-paste-ready React components built on Radix UI primitives, styled with Tailwind CSS. The core mechanic: you run a CLI command that copies the component source code directly into your project, giving you full ownership and customization. There's no dependency to update, no abstraction layer to fight.
In practice, this means each component lives as a local file. Without shared wrappers, every instance of a Button or Dialog re-declares its own styling logic, event handlers, and state management. That leads to 40% slower rendering in complex UIs — the browser spends more time parsing duplicate code and reconciling identical virtual DOM subtrees. Shared wrappers centralize common logic (e.g., a unified Button that handles loading states, tooltips, and analytics) into a single component, reducing re-render overhead and bundle size.
Use shadcn/ui when you need full control over component internals and want to avoid dependency lock-in. It shines in projects where design systems evolve rapidly — you own the code, so you can tweak without waiting for a library release. But the moment you have more than 10 instances of the same component, wrap them. The performance cost of raw duplication scales linearly with component count.
Architecture: The Three-Layer Component Model
A production-grade shadcn/ui library requires three distinct layers. Skipping any layer creates maintenance debt that compounds over time.
Layer 1 is the shadcn primitive — the raw component the CLI installs. You do not modify these directly. Layer 2 is your design system wrapper — it applies design tokens, enforces accessibility defaults, and provides a prop API that matches your team's conventions. Layer 3 is the feature component — domain-specific compositions built from Layer 2 wrappers.
Application code should only import from Layer 2 or Layer 3. Direct imports from Layer 1 (the components/ui/ directory) should be blocked by lint rules. This separation ensures that when you update shadcn primitives, you only need to verify Layer 2 wrappers — not every feature component in the codebase.
Import direction is one-way and strict: App → Layer 3 → Layer 2 → Layer 1 (primitives). No layer imports from a layer above it. Primitives are never imported by application code.
// Layer 2: Design system wrapper // This file lives at packages/ui/components/button.tsx // It wraps the raw shadcn primitive and exposes your team's prop API. // // Import direction: // App code → packages/ui/components/button (this file) // This file → packages/ui/primitives/button (raw shadcn — never imported by app code) import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { Button as ShadcnButton, type ButtonProps as ShadcnButtonProps, } from '../primitives/button'; import { cn } from '../lib/utils'; // --------------------------------------------------------------- // Variant definitions using your team's intent-based naming. // These map onto shadcn's internal variant system below. // --------------------------------------------------------------- const buttonVariants = cva('', { variants: { intent: { primary: '', secondary: '', destructive: '', ghost: '', }, size: { sm: '', md: '', lg: '', }, }, defaultVariants: { intent: 'primary', size: 'md', }, }); // Map your intent API onto shadcn's variant prop. // This means application code never needs to know shadcn's internal naming. const intentToShadcnVariant: Record< NonNullable<ButtonProps['intent']>, ShadcnButtonProps['variant'] > = { primary: 'default', secondary: 'secondary', destructive: 'destructive', ghost: 'ghost', }; // Map your size API onto shadcn's size prop. const sizeToShadcnSize: Record< NonNullable<ButtonProps['size']>, ShadcnButtonProps['size'] > = { sm: 'sm', md: 'default', lg: 'lg', }; // --------------------------------------------------------------- // ButtonProps intentionally omits shadcn's 'variant' and 'size' // to prevent consumers from using shadcn's internal API directly. // Expose your own intent/size props instead. // --------------------------------------------------------------- export interface ButtonProps extends Omit<ShadcnButtonProps, 'variant' | 'size'>, VariantProps<typeof buttonVariants> { /** Visual treatment that communicates purpose, not mechanism */ intent?: 'primary' | 'secondary' | 'destructive' | 'ghost'; /** Controls vertical padding and font size */ size?: 'sm' | 'md' | 'lg'; /** Shows a spinner, announces busy state, and disables interaction */ loading?: boolean; /** Accessible label announced by screen readers during loading. * Defaults to the button's visible text if not provided. */ loadingText?: string; } export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ( { intent = 'primary', size = 'md', loading = false, loadingText, children, className, disabled, ...props }, ref ) => { return ( <ShadcnButton ref={ref} variant={intentToShadcnVariant[intent]} size={sizeToShadcnSize[size]} // Disable interaction during loading but keep the element focusable // so keyboard users are not surprised by a disappearing focus target. disabled={loading || disabled} // aria-busy signals to screen readers that the action is in progress. // aria-disabled mirrors the disabled state for assistive tech that // reads ARIA attributes separately from the HTML disabled attribute. aria-busy={loading ? true : undefined} aria-disabled={loading || disabled ? true : undefined} // aria-label overrides the visible text during loading so screen // readers announce the loading state instead of the button label. aria-label={ loading && loadingText ? loadingText : undefined } className={cn(buttonVariants({ intent, size }), className)} {...props} > {loading && ( <svg className="mr-2 h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true" > <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" /> </svg> )} {children} </ShadcnButton> ); } ); Button.displayName = 'Button'; // --------------------------------------------------------------- // Layer 3 usage example (feature component in application code) // File: features/checkout/components/submit-button.tsx // --------------------------------------------------------------- // // import { Button } from '@company/ui'; // ✅ Layer 2 wrapper // NEVER: import { Button } from '@/components/ui/button'; // ❌ Layer 1 bypass // // export function SubmitButton({ // amount, // isSubmitting, // }: { // amount: number; // isSubmitting: boolean; // }) { // return ( // <Button // intent="primary" // size="lg" // loading={isSubmitting} // loadingText="Processing payment..." // > // Pay ${amount.toFixed(2)} // </Button> // ); // }
- Move shadcn primitives into a
primitives/directory that is not exposed in the package export map - Only export Layer 2 wrappers from the package index.ts
- Add an ESLint rule: no-restricted-imports targeting
@/components/ui/and/primitives/* - CI should fail if any app code imports from the primitives directory — documentation alone does not work
Migration Path: From Scattered shadcn Imports to a Shared Library
Most teams encounter this article after they already have shadcn components scattered across their codebase. A big-bang migration is risky and slow. The following incremental approach lets you adopt the three-layer model without stopping feature development.
Phase 1 (Week 1): Inventory. Run a grep to map every direct shadcn import in the codebase. This gives you a prioritized list of components to wrap — start with the most-imported ones.
Phase 2 (Week 2–3): Create the package. Set up the packages/ui workspace package with the directory structure, components.json, and tailwind.config.ts. Migrate existing shadcn files into primitives/. Create Layer 2 wrappers for the top 5 most-used components.
Phase 3 (Ongoing): Migrate imports. Replace direct shadcn imports with the wrapper package import one directory at a time — not one file at a time. Directory-level migration keeps PRs reviewable.
Phase 4 (Final): Enforce the boundary. Once all imports are migrated, add the ESLint rule and CI check. At this point, the boundary is enforced automatically and drift is structurally impossible.
#!/usr/bin/env bash # audit-shadcn-imports.sh # Run this before starting migration to understand the scope of direct shadcn imports. # Output: a sorted list of components and how many times each is imported directly. echo "=== Direct shadcn primitive imports ===" echo "These should all move to @company/ui after migration." echo "" # Find all direct imports from the shadcn components/ui directory grep -rn --include='*.tsx' --include='*.ts' \ 'from ["\x27]@/components/ui/' \ apps/ features/ \ | sed "s/.*from ['\"]@\/components\/ui\///" \ | sed "s/['\"].*/"/" \ | sort \ | uniq -c \ | sort -rn echo "" echo "=== Total direct import count ===" grep -rn --include='*.tsx' --include='*.ts' \ 'from ["\x27]@/components/ui/' \ apps/ features/ \ | wc -l echo "" echo "=== Files with most direct imports (migration priority) ===" grep -rln --include='*.tsx' --include='*.ts' \ 'from ["\x27]@/components/ui/' \ apps/ features/ \ | xargs -I{} sh -c 'echo "$(grep -c \"from.*@/components/ui/\" {}) {}"' \ | sort -rn \ | head -20
- Start with the most-imported component — fixing Button first gives immediate design consistency
- Migrate one feature directory at a time — not one file at a time — to keep PRs reviewable
- Do not add the ESLint enforcement rule until at least 80% of imports are migrated — a failing CI on day one kills buy-in
- Keep a migration tracking document: which components are wrapped, which directories are migrated, what remains
Theming: CSS Variables Over Prop APIs
shadcn/ui themes through CSS variables, not component props. This is a deliberate architectural choice — CSS variables cascade through the DOM tree, enabling per-section theming without re-rendering components.
The common mistake is building a prop-based theme API on top of shadcn: <Button colorScheme="dark">. This defeats the purpose. Instead, define your design tokens as CSS variables in globals.css, apply theme classes at the section or page level, and let CSS handle the cascade.
For multi-brand or white-label applications, define separate CSS variable sets per brand and switch them via a data-brand attribute on the root element. Components never need to know which brand they are rendering — they reference the same CSS variables regardless.
Dark mode follows the same pattern. A .dark class or data-theme="dark" attribute on the root element redefines the CSS variables. No React state, no re-renders, no flicker.
/* globals.css * Design token system built on shadcn/ui CSS variables. * Import this file once in your root layout — never in individual components. * * Override strategy: * - Dark mode: .dark class on <html> redefines variables * - Brand: data-brand attribute on <html> redefines variables * - Section: data-theme attribute on a container redefines variables for that subtree * * Components reference variables directly (e.g., bg-primary, text-foreground). * They never receive theme information via props. */ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { /* === Core palette === */ --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; /* === Surface hierarchy === */ --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; /* === Interactive states === */ --primary: 222.2 47.4% 11.2%; --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%; /* === Feedback === */ --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --success: 142 76% 36%; --success-foreground: 0 0% 100%; --warning: 38 92% 50%; --warning-foreground: 0 0% 0%; /* === Structure === */ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; /* === Typography scale === */ --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, monospace; /* === Spacing scale === */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem; } /* Dark mode: redefine variables under .dark — no component changes required */ .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } /* Brand override: apply data-brand="enterprise" to <html> */ [data-brand='enterprise'] { --primary: 220 70% 50%; --primary-foreground: 0 0% 100%; --radius: 0.25rem; --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif; } /* Section-level override: apply data-theme="inverted" to any container */ /* Components inside inherit the redefined variables — no prop changes needed */ [data-theme='inverted'] { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; } * { @apply border-border; } body { @apply bg-background text-foreground font-sans; } }
The CLI Workflow: Managing Primitives at Scale
The shadcn CLI (npx shadcn@latest add) copies components into your project. Note: as of 2025, the CLI package was renamed from shadcn-ui to shadcn — use npx shadcn@latest in all commands going forward.
In a monorepo with a shared UI package, you need a strategy for where those files land and how they are versioned.
The recommended pattern: install shadcn primitives into a dedicated primitives/ directory inside the UI package. Never modify primitives directly. When shadcn releases updates, re-run the CLI to update primitives, then verify your Layer 2 wrappers still work.
For monorepos, configure components.json to point to the UI package root. This ensures the CLI installs primitives into the correct location and generates imports that reference the shared package.
The diff command (npx shadcn@latest diff) is your most important tool for managing updates — it shows exactly which lines changed between your local version and the upstream version, so you can decide whether to accept, modify, or reject each change.
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@company/ui",
"utils": "@company/ui/lib/utils",
"ui": "@company/ui/primitives",
"lib": "@company/ui/lib",
"hooks": "@company/ui/hooks"
}
}- Always run
npx shadcn@latest diffbefore updating — review every changed line before accepting - Create a dedicated branch for shadcn updates — never update primitives on main directly
- After updating primitives, run your full visual regression test suite (Playwright screenshots or Chromatic) before merging
- Track Radix UI versions in a CHANGELOG within the UI package — shadcn updates Radix dependencies without announcement
- Pin the shadcn CLI version in a
.tool-versionsorpackage.jsonscript to prevent developers from accidentally running different CLI versions
npx shadcn@latest add on main and committed without review.npx shadcn@latest — the CLI package is no longer shadcn-ui as of 2025.npx shadcn@latest diff to review every change.Accessibility: The Non-Negotiable Layer
shadcn/ui builds on Radix UI, which provides strong accessibility defaults. But your wrapper layer can break accessibility by overriding ARIA attributes, removing focus management, or composing multiple primitives in ways that break keyboard navigation.
Every Layer 2 wrapper must preserve the accessibility guarantees of the underlying Radix primitive. When you add custom props, verify they do not conflict with Radix's ARIA attributes. When you compose primitives (e.g., a Dialog containing a Form), verify that focus trapping works across the composition.
Run automated accessibility checks with jest-axe in your component tests. But do not rely on automated checks alone — they catch approximately 30% of real accessibility issues according to Deque's research. Manual testing with screen readers (VoiceOver on macOS/iOS, NVDA on Windows) and keyboard-only navigation is mandatory for any component entering the shared library.
WCAA 2.2 AA is the minimum standard. Check it at the wrapper level — one fix propagates to every consumer.
// button.a11y.test.tsx // Accessibility tests for the Button wrapper component. // Run with: vitest run --grep a11y import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe, toHaveNoViolations } from 'jest-axe'; import { Button } from '../components/button'; expect.extend(toHaveNoViolations); describe('Button — axe automated scan', () => { it('has no axe violations in default state', async () => { const { container } = render(<Button>Submit</Button>); const results = await axe(container); expect(results).toHaveNoViolations(); }); it('has no axe violations in loading state', async () => { const { container } = render( <Button loading loadingText="Submitting form, please wait"> Submit </Button> ); const results = await axe(container); expect(results).toHaveNoViolations(); }); }); describe('Button — loading state ARIA', () => { it('sets aria-busy when loading', () => { render(<Button loading>Submit</Button>); const button = screen.getByRole('button'); expect(button).toHaveAttribute('aria-busy', 'true'); }); it('sets aria-disabled when loading', () => { render(<Button loading>Submit</Button>); const button = screen.getByRole('button'); expect(button).toHaveAttribute('aria-disabled', 'true'); }); it('sets aria-label to loadingText when provided', () => { render( <Button loading loadingText="Submitting form, please wait"> Submit </Button> ); const button = screen.getByRole('button'); expect(button).toHaveAttribute( 'aria-label', 'Submitting form, please wait' ); }); it('does not set aria-label when loadingText is not provided', () => { render(<Button loading>Submit</Button>); const button = screen.getByRole('button'); expect(button).not.toHaveAttribute('aria-label'); }); }); describe('Button — keyboard interaction', () => { it('maintains focus when loading state changes', async () => { const user = userEvent.setup(); const { rerender } = render(<Button>Submit</Button>); const button = screen.getByRole('button'); await user.tab(); expect(button).toHaveFocus(); // Simulate form submission triggering loading state rerender( <Button loading loadingText="Submitting..."> Submit </Button> ); // Focus must not move when loading state changes expect(button).toHaveFocus(); }); it('is reachable via keyboard when not loading', async () => { const user = userEvent.setup(); render(<Button>Submit</Button>); await user.tab(); expect(screen.getByRole('button')).toHaveFocus(); }); it('is not clickable when loading', async () => { const handleClick = vi.fn(); const user = userEvent.setup(); render( <Button loading onClick={handleClick}> Submit </Button> ); await user.click(screen.getByRole('button')); expect(handleClick).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------- // Dialog focus trapping — verify compound compositions // --------------------------------------------------------------- // Note: shadcn/ui exports Dialog parts as separate named imports // (DialogTrigger, DialogContent, etc.), not compound dot-notation. // If your Layer 2 Dialog wrapper exposes a compound API (Dialog.Trigger), // adjust the imports below to match your wrapper's public API. // --------------------------------------------------------------- import { Dialog, DialogTrigger, DialogContent, } from '../components/dialog'; describe('Dialog — focus trapping', () => { it('traps focus within the dialog when open', async () => { const user = userEvent.setup(); render( <Dialog> <DialogTrigger asChild> <Button>Open dialog</Button> </DialogTrigger> <DialogContent> <input aria-label="Name" /> <Button>Cancel</Button> <Button intent="destructive">Confirm</Button> </DialogContent> </Dialog> ); // Open the dialog await user.click(screen.getByRole('button', { name: 'Open dialog' })); expect(screen.getByRole('dialog')).toBeInTheDocument(); // Tab through all focusable elements — focus must stay within the dialog await user.tab(); expect(screen.getByLabelText('Name')).toHaveFocus(); await user.tab(); expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus(); await user.tab(); expect(screen.getByRole('button', { name: 'Confirm' })).toHaveFocus(); // Tab wraps back to the first focusable element inside the dialog await user.tab(); expect(screen.getByLabelText('Name')).toHaveFocus(); }); it('returns focus to the trigger when closed', async () => { const user = userEvent.setup(); render( <Dialog> <DialogTrigger asChild> <Button>Open dialog</Button> </DialogTrigger> <DialogContent> <Button>Close</Button> </DialogContent> </Dialog> ); const trigger = screen.getByRole('button', { name: 'Open dialog' }); await user.click(trigger); await user.keyboard('{Escape}'); // Focus must return to the element that opened the dialog expect(trigger).toHaveFocus(); }); });
Visual Regression Testing: Catching Unintended Changes
The three-layer model means a single change to a wrapper component can affect hundreds of feature components. Visual regression testing catches unintended UI changes before they reach production — automated accessibility tests cannot do this.
Two approaches fit shadcn-based libraries: Playwright screenshot comparisons (open source, runs in CI) and Chromatic (commercial, integrates with Storybook). Both compare pixel-by-pixel screenshots of components against a baseline.
For a shadcn library, set up visual regression tests at the wrapper level (Layer 2). Test every variant of every component. When you update a primitive, run visual regression before merging — the diff will immediately show whether the update changed the rendered output.
Storybook is the recommended development environment for the component library. It gives you a sandboxed preview of every component variant, isolates rendering from application concerns, and integrates with both Chromatic and Playwright.
// button.visual.test.ts // Visual regression tests for the Button wrapper using Playwright. // Run with: playwright test packages/ui/__tests__/button.visual.test.ts // // First run creates baseline screenshots in __screenshots__/. // Subsequent runs compare against the baseline and fail if pixels changed. // To update the baseline after an intentional change: playwright test --update-snapshots import { test, expect } from '@playwright/test'; const STORYBOOK_URL = process.env.STORYBOOK_URL ?? 'http://localhost:6006'; const buttonVariants = [ { story: 'primary', name: 'Button/Primary' }, { story: 'secondary', name: 'Button/Secondary' }, { story: 'destructive', name: 'Button/Destructive' }, { story: 'ghost', name: 'Button/Ghost' }, { story: 'loading', name: 'Button/Loading' }, { story: 'disabled', name: 'Button/Disabled' }, ] as const; for (const { story, name } of buttonVariants) { test(`${name} — light mode visual regression`, async ({ page }) => { await page.goto( `${STORYBOOK_URL}/iframe.html?id=components-button--${story}&viewMode=story` ); await page.waitForLoadState('networkidle'); const button = page.getByRole('button').first(); await expect(button).toBeVisible(); await expect(button).toHaveScreenshot(`button-${story}-light.png`); }); test(`${name} — dark mode visual regression`, async ({ page }) => { await page.goto( `${STORYBOOK_URL}/iframe.html?id=components-button--${story}&viewMode=story&globals=backgrounds:dark` ); await page.waitForLoadState('networkidle'); // Apply dark mode via class — matches the CSS variable strategy in globals.css await page.evaluate(() => document.documentElement.classList.add('dark') ); const button = page.getByRole('button').first(); await expect(button).toBeVisible(); await expect(button).toHaveScreenshot(`button-${story}-dark.png`); }); test(`${name} — hover state visual regression`, async ({ page }) => { await page.goto( `${STORYBOOK_URL}/iframe.html?id=components-button--${story}&viewMode=story` ); await page.waitForLoadState('networkidle'); const button = page.getByRole('button').first(); await button.hover(); await expect(button).toHaveScreenshot(`button-${story}-hover.png`); }); }
- Set up visual regression before your first primitive update — not after the first regression reaches production
- Test every variant of every wrapper component — not just the happy path
- Test both light and dark mode — CSS variable changes can affect one without the other
- Run visual regression in CI on the primitive update branch before merge — failing CI blocks the regression automatically
Publishing: Monorepo Package Strategy
The most common deployment pattern for a shadcn-based library is an internal monorepo package. This avoids the overhead of publishing to a registry while ensuring all applications consume the same component versions.
For monorepos using Turborepo or Nx, the UI package should be a workspace package. For TypeScript-only monorepos, direct source imports via the exports field require no build step. For teams publishing to a registry (internal npm, GitHub Packages), build with tsup and publish compiled output.
The critical decision: source imports or compiled output?
Source imports let developers cmd-click into library code, see full TypeScript types, and read readable stack traces. The downside: every consumer must run a compatible TypeScript and Tailwind configuration, and build times increase because the consumer's bundler compiles the library code.
Compiled output gives faster builds and simpler consumer setup, but stack traces point to compiled code, and debugging requires source maps.
Recommendation: use source imports for fewer than 5 applications. Move to compiled output with source maps when build times become a bottleneck.
On ESM vs CJS: by 2026, new Next.js App Router projects are ESM-first. Publish ESM as your primary format. Add a CJS build only if you have confirmed CJS consumers (legacy Pages Router apps, Jest without ESM transform, older Node.js scripts). Publishing CJS by default adds build complexity for zero benefit in ESM-first monorepos.
The sideEffects field in package.json is critical: mark CSS files as side effects so bundlers do not tree-shake them away. Without this field, your globals.css will be dropped from production builds.
{
"name": "@company/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./index.ts",
"import": "./index.ts"
},
"./styles": "./styles/globals.css",
"./lib/utils": {
"types": "./lib/utils.ts",
"import": "./lib/utils.ts"
},
"./hooks/*": {
"types": "./hooks/*.ts",
"import": "./hooks/*.ts"
}
},
"files": [
"components",
"primitives",
"lib",
"styles",
"hooks",
"index.ts"
],
"sideEffects": [
"**/*.css"
],
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"tailwindcss": "^3.4.0 || ^4.0.0"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0"
},
"devDependencies": {
"@playwright/test": "^1.44.0",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^15.0.0",
"@testing-library/user-event": "^14.5.0",
"jest-axe": "^9.0.0",
"tsup": "^8.0.0",
"typescript": "^5.5.0",
"vitest": "^1.6.0"
},
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:a11y": "vitest run --grep a11y",
"test:visual": "playwright test",
"test:visual:update": "playwright test --update-snapshots",
"lint": "eslint . --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
}
}sideEffects: ['*/.css'], your bundler will tree-shake globals.css out of the production build and every component will render with no styles.- Mark all CSS files as side effects so bundlers preserve them during tree-shaking
- Without this field, globals.css is dropped in production — components render unstyled
- The sideEffects field in package.json is standard — webpack, Rollup, esbuild, and Vite all respect it
- Test your published package in a fresh Next.js project before releasing — this is the fastest way to catch sideEffects misconfiguration
Component API Design: Props That Teams Actually Use
The biggest adoption killer for a shared component library is a prop API that does not match how teams build features. shadcn/ui provides a minimal prop API by design — your wrapper layer should extend it with the patterns your team uses most.
Three principles for wrapper prop APIs:
(1) Intent-based naming over implementation-based naming. Use intent="destructive" rather than variant="destructive". Intent communicates purpose — it tells the developer what the button means in context, not how shadcn renders it internally.
(2) Composition props over children manipulation. Provide leftElement, rightElement, and asChild props instead of requiring developers to wrap children in custom elements. Composition props make common patterns one-liner and reduce the chance of accessibility mistakes.
(3) className escape hatch on every component. Teams that cannot customize will bypass the library. The escape hatch uses to merge custom classes with defaults — it never replaces defaults, only extends them.cn()
Document every prop with JSDoc. TypeScript tells you the type — JSDoc tells you when to use it and why. A developer choosing between size="sm" and size="compact" needs prose documentation, not a union type.
// input.tsx // Layer 2 wrapper for shadcn Input. // Demonstrates composition props, escape hatch, and JSDoc documentation patterns. import * as React from 'react'; import { Input as ShadcnInput } from '../primitives/input'; import { cn } from '../lib/utils'; export interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof ShadcnInput>, 'size'> { /** * Element rendered inside the input's left edge. * Use for icons, currency symbols, or flag components. * The input's left padding adjusts automatically when this prop is provided. * @example leftElement={<SearchIcon className="h-4 w-4" />} */ leftElement?: React.ReactNode; /** * Element rendered inside the input's right edge. * Use for units, suffix text, or action icons (clear, toggle visibility). * @example rightElement={<span className="text-muted-foreground">kg</span>} */ rightElement?: React.ReactNode; /** * Error message rendered below the input in destructive color. * Also sets aria-invalid and aria-describedby on the input for screen readers. * Takes priority over helperText when both are provided. * @example error="Email address is required" */ error?: string; /** * Instructional text rendered below the input. * Hidden when error is provided. * @example helperText="We'll never share your email with anyone." */ helperText?: string; /** * Controls the input's vertical padding and font size. * Use sm for compact forms (data tables, filter bars). * Use md (default) for standard forms. * Use lg for prominent single-field UIs (search, hero inputs). */ size?: 'sm' | 'md' | 'lg'; } export const Input = React.forwardRef<HTMLInputElement, InputProps>( ( { leftElement, rightElement, error, helperText, size = 'md', className, id, ...props }, ref ) => { // Generate a stable ID for aria-describedby if the consumer did not provide one const generatedId = React.useId(); const inputId = id ?? generatedId; const helperId = `${inputId}-helper`; const errorId = `${inputId}-error`; const sizeClasses = { sm: 'h-8 px-2 text-xs', md: 'h-10 px-3 text-sm', lg: 'h-12 px-4 text-base', }; return ( <div className="flex flex-col gap-1.5"> <div className="relative"> {leftElement && ( <div className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-muted-foreground" aria-hidden="true" > {leftElement} </div> )} <ShadcnInput ref={ref} id={inputId} // aria-invalid signals to screen readers that the field has an error aria-invalid={error ? true : undefined} // aria-describedby connects the input to its helper/error text aria-describedby={ error ? errorId : helperText ? helperId : undefined } className={cn( sizeClasses[size], leftElement && 'pl-9', rightElement && 'pr-9', error && 'border-destructive focus-visible:ring-destructive', className )} {...props} /> {rightElement && ( <div className="absolute inset-y-0 right-3 flex items-center text-muted-foreground" aria-hidden="true" > {rightElement} </div> )} </div> {error && ( <p id={errorId} role="alert" className="text-xs text-destructive" > {error} </p> )} {!error && helperText && ( <p id={helperId} className="text-xs text-muted-foreground" > {helperText} </p> )} </div> ); } ); Input.displayName = 'Input';
- Every wrapper component must accept a className prop
- Use
cn()to merge the escape hatch with defaults — never replace, always merge - Teams that cannot customize will copy and fork the component — the escape hatch prevents silent divergence
- Document in JSDoc which CSS properties are safe to override and which break the component's internal layout
Shadcn Is Not a Library—It’s a Component Generation Pattern
Let’s kill the confusion. Shadcn/ui is not an npm-installable component library. There is no @shadcn/ui package on npm. Competitors who tell you to run npm install @shadcn/ui are wrong. What shadcn actually does is copy raw source code directly into your project via its CLI. This is a deliberate architectural choice. You own every pixel. You can modify, delete, or extend any component without fighting upstream breaking changes. This pattern solves the fundamental tension between rapid UI development and long-term maintainability. The downside? Button components balloon across your codebase. Every developer forks the source and adds their own wrapper. Six months later you have 14 inconsistent button variants in different folders. The fix is not avoiding shadcn—it’s treating each generated primitive as a shared module, not a local copy-paste. Your project’s components/ui/ directory should be treated as a single-source-of-truth with strict code review and zero local overrides.
# Correct shadcn CLI usage - copies source, not installs a package npx shadcn-ui@latest add button # Verify the component landed in your project tree tree components/ui/button.tsx # Output: components/ui/button.tsx # (No `node_modules/@shadcn/ui` exists - because there is no such package)
import { Button } from '@shadcn/ui' in a tutorial or blog post, close that tab. That code will fail. Shadcn imports always come from your local project path, e.g., import { Button } from '@/components/ui/button'. Teach your team this distinction on day one.The Enterprise Multi-File Pattern: Split Radix from Variants
Default shadcn dumps everything into one file per component. A button.tsx that's 400 lines mixing Radix UI primitives, cva variants, and className merging. This works for solo projects. It fails at scale. Your senior devs need to reason about accessibility logic without scrolling past 12 variant definitions. The fix: split each generated primitive into three files. One file owns the Radix primitive. One file owns the variant API (cva + className). One file re-exports the combined interface. This separation means a designer can tweak button sizes in button.variants.ts without touching accessibility logic in button.root.tsx. The bundle size impact is zero because your bundler tree-shakes imports, not file counts. We've used this on a 40-component library serving 12 micro-frontends. Zero regressions, zero merge conflicts from variant changes.
// Re-export pattern that keeps Radix logic, variants, and API separate import { Root } from './button.root'; export { Root as Button }; export type { ButtonProps } from './button.root'; // Consumer import stays clean import { Button } from '@/components/ui/button'; <Button variant="destructive" size="lg">Delete Account</Button>
button.variants.ts only. No risk of breaking keyboard navigation. No merge conflicts with the event handler file. This separation saved us 3 regression bugs in Q4 alone.The Design System That Died by a Thousand Forks
npx shadcn@latest add button independently, then customized the component in isolation. Without a shared wrapper library, there was no mechanism to enforce design tokens, accessibility standards, or naming conventions.@company/ui package that wraps every shadcn component with design token defaults, accessibility guarantees, and a single import path. Removed direct shadcn imports from application code. Added a CI check that blocks PRs importing from @/components/ui/* directly.- shadcn/ui is a foundation, not a finished library — you must build the abstraction layer
- Without a shared wrapper, every team diverges silently
- The CLI copies code once — after that, you own the drift
- Enforce single import paths via CI linting, not documentation
npx shadcn@latest diff to see what changed between your local version and the latest. Test wrapper components against the updated primitives before merging.grep -rn '@layer base' app/globals.cssgrep -rn 'import.*globals.css' app/layout.tsxcat tailwind.config.ts | grep -A5 contentnpx tailwindcss --content './packages/ui/**/*.{ts,tsx}' --output /dev/nullgrep -rn 'z-\[' app/ components/ | grep -v node_modulesgrep -rn 'isolation:' app/ components/grep -rn 'forwardRef' packages/ui/components/npx tsc --noEmit 2>&1 | grep -i 'ref\|prop'| Dimension | shadcn/ui | Material UI / Chakra | Custom from Scratch |
|---|---|---|---|
| Ownership model | You own the code — copy-paste via CLI | npm dependency — library authors maintain | Full ownership — you build and maintain everything |
| Customization depth | Unlimited — edit source directly | Theme config + override cascades | Unlimited — but every pixel costs engineering time |
| Maintenance burden | Medium — you manage primitive updates | Low — library authors manage upgrades | High — you design, build, fix, and upgrade everything |
| Initial setup time | Low — CLI installs components in minutes | Low — npm install + theme config | Very high — months before first production component |
| Accessibility baseline | Strong — Radix UI primitives cover WCAG 2.2 AA | Varies — Material UI is strong, others vary | None — must be designed and tested by your team |
| Bundle size control | Full — tree-shaking by design, no unused components | Partial — some libraries include unavoidable overhead | Full — but optimization is your responsibility |
| Team adoption risk | Medium — requires discipline and import enforcement | Low — npm dependency enforces a single version | High — requires significant internal buy-in and documentation |
| Best for | Teams wanting customization control without a full rebuild | Teams that want to ship fast and accept design constraints | Teams with unique design requirements no library can meet |
| File | Command / Code | Purpose |
|---|---|---|
| packages | Button as ShadcnButton, | Architecture |
| scripts | echo "=== Direct shadcn primitive imports ===" | Migration Path |
| packages | /* globals.css | Theming |
| packages | { | The CLI Workflow |
| packages | expect.extend(toHaveNoViolations); | Accessibility |
| packages | const STORYBOOK_URL = process.env.STORYBOOK_URL ?? 'http://localhost:6006'; | Visual Regression Testing |
| packages | { | Publishing |
| packages | export interface InputProps | Component API Design |
| terminal.sh | npx shadcn-ui@latest add button | Shadcn Is Not a Library |
| components | export { Root as Button }; | The Enterprise Multi-File Pattern |
Key takeaways
npx shadcn@latestshadcn-ui to shadcn in 2025npx shadcn@latest diff before every updateCommon mistakes to avoid
7 patternsTreating shadcn/ui as a finished component library
@/components/ui/* via ESLint and CI.Modifying shadcn primitives directly instead of creating wrappers
npx shadcn@latest add) overwrites local modifications. Developers lose customizations after every update and must re-apply them manually.primitives/ directory. Create wrapper components in a separate components/ directory that import and extend primitives without modifying them.Building a prop-based theme API instead of using CSS variables
Not blocking direct shadcn imports in application code
@/components/ui/button instead of @company/ui. The wrapper layer is bypassed, and design consistency erodes silently without any visible warning.no-restricted-imports rule targeting @/components/ui/ and /primitives/*. CI should fail if any application code imports from the primitives directory directly.Skipping accessibility testing for wrapper components
jest-axe in component tests for automated coverage. Manually test every wrapper with VoiceOver and keyboard-only navigation. Fix accessibility in the wrapper — one fix propagates to all consumers.Using the old `shadcn-ui` CLI package name
npx shadcn-ui@latest add produces warnings or installs an outdated version. New components and the diff command may not be available.npx shadcn@latest add — the CLI package was renamed from shadcn-ui to shadcn in 2025. Update all scripts, documentation, and CI pipelines to use the new package name.Publishing the UI library without the sideEffects field in package.json
"sideEffects": ["*/.css"] to package.json. This tells bundlers to preserve CSS files during tree-shaking. Test the published package in a fresh production Next.js build before releasing.Interview Questions on This Topic
How would you architect a shared component library on top of shadcn/ui for a monorepo with 8 applications?
npx shadcn@latest add into a primitives/ directory — never modified directly. Layer 2: design system wrappers in a components/ directory that apply design tokens, enforce accessibility defaults, and provide a unified prop API using intent-based naming. Layer 3: feature-specific compositions built from Layer 2 wrappers in application code.
The shared library is a workspace package in the monorepo with source imports (no build step for TypeScript consumers). Configure components.json to point to the UI package root. Add ESLint rules blocking direct imports from primitives/. Mark CSS files as side effects in package.json. Set up visual regression tests with Playwright to catch unintended changes when updating primitives. Use CSS variables for theming — not prop-based theme APIs.What is the trade-off between source imports and compiled output for an internal component library?
Why should you use CSS variables for theming instead of a prop-based theme API in a shadcn/ui library?
How do you handle shadcn/ui primitive updates in a production component library without breaking consumer applications?
npx shadcn@latest diff to review every changed line before accepting, and re-run the CLI to update the primitives that need updating.
After updating, run the full visual regression test suite (Playwright screenshot comparisons or Chromatic) — this catches unintended rendering changes. Then run the full accessibility test suite, because Radix UI updates can change ARIA attribute behavior.
Check the Radix UI changelog — shadcn updates Radix dependencies without announcement, and Radix minor versions can include breaking changes to DOM structure or focus management.
Deploy the updated library to a staging environment and verify critical user flows before merging. Track Radix UI versions in a CHANGELOG within the UI package so you can correlate production issues with specific updates.What prop API design principles make a shared component library adoptable by engineering teams?
cn(). Without it, developers who need customization will fork the component instead of requesting the feature.
Additionally, document every prop with JSDoc. TypeScript tells you the type — JSDoc tells you when and why to use it. A developer choosing between size="sm" and size="compact" needs prose documentation explaining the use case for each.How would you migrate a codebase that has scattered direct shadcn imports to a shared wrapper library?
Frequently Asked Questions
shadcn/ui is built on Radix UI Primitives, which are React-only. Community ports exist for other frameworks: shadcn-vue uses Reka UI (the community-maintained Vue port of Radix, previously known as Radix Vue), and shadcn-svelte uses Bits UI. Both follow the same copy-paste philosophy but use different underlying primitive libraries. The architectural patterns in this article — three-layer model, CSS variable theming, import boundary enforcement — apply regardless of the framework.
Run npx shadcn@latest add <component> from the UI package root — this installs the primitive into your primitives/ directory. Then create a corresponding wrapper in components/ that applies your design tokens, adds your prop API, includes JSDoc documentation, and has accessibility tests. Export the wrapper from the package's index.ts. Never skip the wrapper layer — even for simple components like Badge or Separator. A bare re-export today becomes a forked component tomorrow.
No. shadcn/ui is React-specific because it depends on Radix UI. For multi-framework design systems, use a framework-agnostic design token system (Style Dictionary, Tokens Studio) for your visual layer, and build framework-specific component libraries on top. Each framework uses its own primitive library (Radix for React, Reka UI for Vue, Angular CDK for Angular) while sharing the same CSS variable token definitions. The token layer is the only layer that can be shared across frameworks.
Add custom variants in your Layer 2 wrapper using class-variance-authority (cva). shadcn components use cva internally — your wrapper can extend the variant system by adding additional classes. For entirely new components that shadcn does not offer, build them directly on Radix UI primitives and place them in your components/ directory alongside the shadcn wrappers. Maintain the same prop API patterns — intent-based naming, composition props, className escape hatch, JSDoc documentation — for consistency across the library.
Yes — shadcn/ui is arguably better suited for enterprise than traditional libraries because you own every line of code. You can enforce accessibility standards, security audit the entire source, and customize without fighting override cascades or waiting for library maintainers to accept PRs. The key requirement is building the wrapper layer with enforcement mechanisms: ESLint import rules, CI checks, visual regression tests, and accessibility tests. Without enforcement, the copy-paste model leads to divergence. With it, you get a design system that is both flexible and consistent.
The CLI package was renamed from shadcn-ui to shadcn. Use npx shadcn@latest add instead of npx shadcn-ui@latest add in all commands, scripts, and CI pipelines. The diff command (npx shadcn@latest diff) is now the recommended way to review what changed between your local primitives and the upstream version before accepting an update. The components.json schema and alias configuration remain compatible — no migration required for existing configurations.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's React.js. Mark it forged?
7 min read · try the examples if you haven't