Creating Reusable Component Libraries with shadcn/ui
- shadcn/ui is a foundation, not a finished library β you must build the wrapper layer or drift is guaranteed
- Three-layer architecture: primitives (raw shadcn), wrappers (your design API), features (domain compositions) β import direction is one-way
- Use
npx shadcn@latestβ the CLI was renamed fromshadcn-uitoshadcnin 2025
- 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
CSS variables not applying β components render with default styles
grep -rn '@layer base' app/globals.cssgrep -rn 'import.*globals.css' app/layout.tsxcn() utility produces unexpected class merges
cat tailwind.config.ts | grep -A5 contentnpx tailwindcss --content './packages/ui/**/*.{ts,tsx}' --output /dev/nullRadix UI portal renders in wrong z-index layer
grep -rn 'z-\[' app/ components/ | grep -v node_modulesgrep -rn 'isolation:' app/ components/Component props not forwarding to DOM element
grep -rn 'forwardRef' packages/ui/components/npx tsc --noEmit 2>&1 | grep -i 'ref\|prop'Production Incident
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.Production Debug GuideWhen your shadcn-based library breaks or teams stop using it
npx shadcn@latest diff to see what changed between your local version and the latest. Test wrapper components against the updated primitives before merging.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.
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
| 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 |
π― Key Takeaways
- shadcn/ui is a foundation, not a finished library β you must build the wrapper layer or drift is guaranteed
- Three-layer architecture: primitives (raw shadcn), wrappers (your design API), features (domain compositions) β import direction is one-way
- Use
npx shadcn@latestβ the CLI was renamed fromshadcn-uitoshadcnin 2025 - Theme with CSS variables, not component props β variables cascade without React re-renders
- Enforce import boundaries with ESLint and CI β documentation alone does not prevent direct primitive imports
- Every wrapper must accept className β without the escape hatch, teams will fork components silently
- Update primitives on branches with visual regression tests β
npx shadcn@latest diffbefore every update - Accessibility fixes in the wrapper library propagate to every consumer β highest-leverage bug fix in the codebase
- The sideEffects field in package.json is mandatory β without it, globals.css is dropped from production builds
- Migrate from scattered imports incrementally: audit β package β migrate by directory β enforce
β Common Mistakes to Avoid
Interview Questions on This Topic
- QHow would you architect a shared component library on top of shadcn/ui for a monorepo with 8 applications?SeniorReveal
- QWhat is the trade-off between source imports and compiled output for an internal component library?Mid-levelReveal
- QWhy should you use CSS variables for theming instead of a prop-based theme API in a shadcn/ui library?Mid-levelReveal
- QHow do you handle shadcn/ui primitive updates in a production component library without breaking consumer applications?SeniorReveal
- QWhat prop API design principles make a shared component library adoptable by engineering teams?Mid-levelReveal
- QHow would you migrate a codebase that has scattered direct shadcn imports to a shared wrapper library?SeniorReveal
Frequently Asked Questions
Can I use shadcn/ui with Vue or Svelte instead of React?
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.
How do I add a new shadcn component to my shared library?
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.
Should I use shadcn/ui for a design system that needs to support multiple frameworks?
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.
How do I handle component variants that shadcn/ui does not provide?
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.
Is shadcn/ui suitable for enterprise applications with strict design compliance requirements?
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.
What changed in the shadcn CLI in 2025 that I need to know about?
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.