Skip to content
Homeβ€Ί JavaScriptβ€Ί Creating Reusable Component Libraries with shadcn/ui

Creating Reusable Component Libraries with shadcn/ui

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 43 of 47
Build, publish, and maintain your own component library based on shadcn/ui that teams actually want to use.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Build, publish, and maintain your own component library based on shadcn/ui that teams actually want to use.
  • 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 from shadcn-ui to shadcn in 2025
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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
🚨 START HERE
Component Library Quick Debug Cheat Sheet
When your shadcn-based component library has issues, run through this checklist.
🟑CSS variables not applying β€” components render with default styles
Immediate ActionVerify globals.css is imported in the root layout
Commands
grep -rn '@layer base' app/globals.css
grep -rn 'import.*globals.css' app/layout.tsx
Fix NowEnsure globals.css is imported once in the root layout, not in individual components
🟑cn() utility produces unexpected class merges
Immediate ActionCheck tailwind.config content paths include the library package
Commands
cat tailwind.config.ts | grep -A5 content
npx tailwindcss --content './packages/ui/**/*.{ts,tsx}' --output /dev/null
Fix NowAdd the library package path to tailwind content array so Tailwind scans library source files
🟑Radix UI portal renders in wrong z-index layer
Immediate ActionCheck for conflicting z-index values in parent containers
Commands
grep -rn 'z-\[' app/ components/ | grep -v node_modules
grep -rn 'isolation:' app/ components/
Fix NowAdd isolation: isolate to the Radix portal container or use a dedicated z-index scale in your design tokens
🟑Component props not forwarding to DOM element
Immediate ActionCheck if React.forwardRef is wrapping the component correctly
Commands
grep -rn 'forwardRef' packages/ui/components/
npx tsc --noEmit 2>&1 | grep -i 'ref\|prop'
Fix NowEnsure every wrapper component uses React.forwardRef and spreads ...props onto the underlying shadcn component
Production IncidentThe Design System That Died by a Thousand ForksA 60-person engineering team adopted shadcn/ui as their component foundation. Eighteen months later, they had 47 variants of Button, 12 different modal implementations, and no single source of truth for design tokens.
SymptomNew features took 40% longer than estimated because developers spent time choosing which Button variant to use. Design reviews flagged visual inconsistencies across 6 product surfaces. Accessibility audits failed because only 3 of 12 modal implementations had proper focus trapping.
AssumptionThe team assumed that because shadcn/ui provides well-structured, copy-paste components, developers would naturally use them consistently. They treated the CLI output as the final component β€” not as a foundation to build on.
Root causeNo abstraction layer existed between shadcn/ui primitives and the application. Each team ran 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.
FixCreated an internal @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.
Key Lesson
shadcn/ui is a foundation, not a finished library β€” you must build the abstraction layerWithout a shared wrapper, every team diverges silentlyThe CLI copies code once β€” after that, you own the driftEnforce single import paths via CI linting, not documentation
Production Debug GuideWhen your shadcn-based library breaks or teams stop using it
Developers bypass the shared library and import shadcn components directly→The shared library is missing features developers need. Audit what they are customizing — that is your feature gap. Add the customization to the shared wrapper with a prop API.
Theme tokens do not apply consistently across components→Check if components are using hardcoded Tailwind colors instead of CSS variables. Run: grep -rn 'bg-\[#' components/ui/ to find hardcoded hex values. Replace any hardcoded colors with the appropriate CSS variable reference.
Component library build fails in monorepo with cryptic path resolution errors→Verify that tailwind.config.ts and globals.css are in the correct package root. shadcn components reference CSS variables defined in globals.css — if the build cannot find them, every component breaks.
Upgrading shadcn primitives breaks custom wrapper components→shadcn updates Radix UI dependency versions. Run npx shadcn@latest diff to see what changed between your local version and the latest. Test wrapper components against the updated primitives before merging.
Tree-shaking fails — bundle includes every shadcn component even if unused→Ensure the library package exports individual components, not a barrel index. Use named exports: export { Button } from './button' — not export * from './components'.

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.

packages/ui/components/button.tsx Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
// 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>
//   );
// }
Mental Model
The Import Boundary Rule
If your application code can see the raw shadcn files, developers will import them directly β€” and your abstraction layer becomes optional.
  • 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
πŸ“Š Production Insight
A fintech team's shadcn library had 200+ components but zero adoption enforcement.
After 6 months, 40% of imports bypassed the wrapper layer entirely.
The abstraction layer only works if bypassing it is harder than using it.
One ESLint rule fixed the problem permanently β€” no migration required.
🎯 Key Takeaway
Three layers: primitives (raw shadcn), wrappers (your design API), features (domain compositions).
Application code imports only from Layer 2 or Layer 3.
Enforce import boundaries with ESLint and CI β€” 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.

scripts/audit-shadcn-imports.sh Β· BASH
12345678910111213141516171819202122232425262728293031323334
#!/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
πŸ’‘Migration Order Matters
  • 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
πŸ“Š Production Insight
A 40-person team tried to migrate all shadcn imports in a single week.
The PR was 3,000 lines, took 2 weeks to review, and introduced 11 regressions.
The team that migrated one feature directory per sprint had zero regressions and full team buy-in.
Rule: incremental migration with directory-level scope is the only migration that ships.
🎯 Key Takeaway
Audit first β€” grep for all direct shadcn imports before writing a single wrapper.
Migrate directory by directory, not file by file.
Add ESLint enforcement only after 80%+ of imports are migrated.

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.

packages/ui/styles/globals.css Β· CSS
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
/* 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;
  }
}
⚠ Do Not Build a Prop-Based Theme API
πŸ“Š Production Insight
A SaaS platform built a prop-based theme API on top of shadcn.
Switching themes required re-rendering the entire component tree β€” 400ms on mobile.
After migrating to CSS variables with a data attribute on the root element, theme switching became instant with zero re-renders and zero component changes.
Rule: if your theme switch causes a React re-render, you are using the wrong mechanism.
🎯 Key Takeaway
Theme with CSS variables, not component props β€” variables cascade without re-renders.
Define all tokens in globals.css under @layer base.
Use data-brand and data-theme attributes for brand and section-level overrides.
Import globals.css once in the root layout β€” never in individual components.

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.

packages/ui/components.json Β· JSON
1234567891011121314151617181920
{
  "$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"
  }
}
πŸ’‘Primitive Update Strategy
  • Always run npx shadcn@latest diff before 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-versions or package.json script to prevent developers from accidentally running different CLI versions
πŸ“Š Production Insight
A team ran npx shadcn@latest add on main and committed without review.
The updated Dialog primitive changed its portal rendering behavior between Radix UI minor versions.
Four feature components broke in production because they relied on the old DOM structure.
Rule: always update primitives on a branch. Run visual regression tests. Never merge without a diff review.
🎯 Key Takeaway
Use npx shadcn@latest β€” the CLI package is no longer shadcn-ui as of 2025.
Configure components.json to install primitives into the shared UI package.
Never modify primitives directly β€” treat them as a managed upstream dependency.
Update on branches with visual regression tests. Use 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.

packages/ui/__tests__/button.a11y.test.tsx Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
// 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();
  });
});
⚠ Accessibility Debt Is Expensive β€” Wrapper Fixes Are Free
πŸ“Š Production Insight
A media company's shared Dialog wrapper had broken focus trapping β€” a single line missing from the Radix composition.
Every feature that used Dialog inherited the bug β€” 14 product surfaces affected.
Fixing the wrapper took 20 minutes and fixed all 14 surfaces in a single deploy.
Rule: accessibility bugs in the wrapper library are the highest-leverage bugs in the codebase.
🎯 Key Takeaway
Preserve Radix UI accessibility defaults β€” never override ARIA attributes without testing the result.
Add aria-busy, aria-disabled, and aria-label to loading states β€” the HTML disabled attribute alone is not sufficient for screen readers.
Test with jest-axe for automation and VoiceOver/NVDA for real coverage.
Fix accessibility in the wrapper β€” one fix propagates to every consumer.

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.

packages/ui/__tests__/button.visual.test.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// 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`);
  });
}
Mental Model
Visual Regression as Your Primitive Update Safety Net
Every time you update a shadcn primitive, visual regression tests tell you exactly what changed visually β€” before it reaches production.
  • 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
πŸ“Š Production Insight
A team updated shadcn's Card primitive and accidentally changed the border-radius for all cards across 6 applications.
They had no visual regression tests. The change reached production and was caught by a user.
Setting up Playwright screenshot tests took 4 hours. The next primitive update was caught automatically in CI.
Rule: visual regression tests are cheap to set up and expensive to skip.
🎯 Key Takeaway
Set up visual regression tests at the Layer 2 wrapper level β€” not at the feature level.
Test every variant in both light and dark mode.
Run visual regression in CI on all primitive update branches.
Playwright (open source) or Chromatic (commercial) are both valid choices.

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.

packages/ui/package.json Β· JSON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
{
  "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"
  }
}
Mental Model
The sideEffects Field Is Not Optional
Without 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
πŸ“Š Production Insight
A team published their UI library without the sideEffects field.
Development builds looked correct because globals.css was imported directly.
Production builds tree-shook globals.css away β€” every component rendered with zero styles.
The bug took 6 hours to diagnose because it only appeared in production.
Rule: always include sideEffects in package.json and always test the published package in a production build.
🎯 Key Takeaway
Use monorepo workspace packages for internal libraries β€” no registry overhead.
Source imports for fewer than 5 apps, compiled output with source maps beyond that.
Publish ESM as the primary format β€” add CJS only for confirmed CJS consumers.
The sideEffects field is mandatory β€” without it, globals.css is dropped from production builds.

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.

(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 cn() to merge custom classes with defaults β€” it never replaces defaults, only extends them.

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.

packages/ui/components/input.tsx Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
// 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';
πŸ’‘The className Escape Hatch Is Not Optional
  • 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
πŸ“Š Production Insight
A team's Input component did not accept className.
A developer needed to adjust margin for a specific layout context.
Instead of requesting the feature (which would have taken a day), they copied the Input and modified it locally.
Six months later, 4 Input variants existed across the codebase.
Rule: every component without a className escape hatch will eventually be forked.
🎯 Key Takeaway
Use intent-based prop naming β€” it communicates purpose, not shadcn's internal mechanism.
Provide composition props (leftElement, rightElement, asChild) for the patterns your team uses most.
Document with JSDoc β€” TypeScript types alone do not explain when to use a prop.
Every component must accept className β€” without it, teams will fork.
πŸ—‚ shadcn/ui vs Traditional Component Libraries
When to choose each approach for your project
Dimensionshadcn/uiMaterial UI / ChakraCustom from Scratch
Ownership modelYou own the code β€” copy-paste via CLInpm dependency β€” library authors maintainFull ownership β€” you build and maintain everything
Customization depthUnlimited β€” edit source directlyTheme config + override cascadesUnlimited β€” but every pixel costs engineering time
Maintenance burdenMedium β€” you manage primitive updatesLow β€” library authors manage upgradesHigh β€” you design, build, fix, and upgrade everything
Initial setup timeLow β€” CLI installs components in minutesLow β€” npm install + theme configVery high β€” months before first production component
Accessibility baselineStrong β€” Radix UI primitives cover WCAG 2.2 AAVaries β€” Material UI is strong, others varyNone β€” must be designed and tested by your team
Bundle size controlFull β€” tree-shaking by design, no unused componentsPartial β€” some libraries include unavoidable overheadFull β€” but optimization is your responsibility
Team adoption riskMedium β€” requires discipline and import enforcementLow β€” npm dependency enforces a single versionHigh β€” requires significant internal buy-in and documentation
Best forTeams wanting customization control without a full rebuildTeams that want to ship fast and accept design constraintsTeams 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 from shadcn-ui to shadcn in 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 diff before 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

    βœ•Treating shadcn/ui as a finished component library
    Symptom

    Every developer customizes components independently. After 6 months, 47 Button variants exist across the codebase with no single source of truth.

    Fix

    Build a Layer 2 wrapper library that enforces design tokens, accessibility defaults, and a unified prop API. Block direct imports from @/components/ui/* via ESLint and CI.

    βœ•Modifying shadcn primitives directly instead of creating wrappers
    Symptom

    Re-running the CLI (npx shadcn@latest add) overwrites local modifications. Developers lose customizations after every update and must re-apply them manually.

    Fix

    Never modify files in the 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
    Symptom

    Theme switching causes full component tree re-renders. Pages flicker on theme toggle. Mobile devices show 300–500ms delay on theme change.

    Fix

    Define design tokens as CSS variables in globals.css. Apply themes via data attributes on the root element. Components reference CSS variables β€” no React state is involved in theming.

    βœ•Not blocking direct shadcn imports in application code
    Symptom

    Developers import from @/components/ui/button instead of @company/ui. The wrapper layer is bypassed, and design consistency erodes silently without any visible warning.

    Fix

    Add ESLint 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
    Symptom

    A Dialog wrapper with broken focus trapping affects every feature that uses it. Screen reader users cannot navigate 14 product surfaces. Legal risk from WCAG non-compliance.

    Fix

    Run 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
    Symptom

    Running npx shadcn-ui@latest add produces warnings or installs an outdated version. New components and the diff command may not be available.

    Fix

    Use 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
    Symptom

    Components render with zero styles in production builds. Development builds look correct because globals.css is imported directly. The bug only appears after bundling for production.

    Fix

    Add "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

  • QHow would you architect a shared component library on top of shadcn/ui for a monorepo with 8 applications?SeniorReveal
    Use a three-layer architecture. Layer 1: shadcn primitives installed via 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.
  • QWhat is the trade-off between source imports and compiled output for an internal component library?Mid-levelReveal
    Source imports let consumers cmd-click into library code, see full TypeScript types, and debug with readable stack traces. The downside: every consumer must have compatible TypeScript and Tailwind configurations, and build times increase because the consumer's bundler compiles the library source. Compiled output gives faster builds and simpler consumer configuration, but stack traces point to compiled code and debugging requires source maps. The recommendation: use source imports for fewer than 5 applications β€” simpler setup, easier debugging, no build step. Move to compiled output with source maps when build times become a bottleneck or when you need to support consumers with different build toolchains. Always publish source maps with compiled output β€” debugging minified component code without them is a significant productivity loss.
  • QWhy should you use CSS variables for theming instead of a prop-based theme API in a shadcn/ui library?Mid-levelReveal
    CSS variables cascade through the DOM tree without triggering React re-renders. A prop-based theme API (colorScheme="dark") requires React to re-render the component tree when the theme changes β€” this causes visible flicker and adds 300–500ms of latency on mobile devices. CSS variables also enable per-section theming: a parent container sets --primary to a different value, and all children inherit it without any prop drilling. shadcn/ui is designed around CSS variables β€” building a prop-based theme API on top fights the architecture. The only valid exception is a component-specific override that cannot be expressed as a global token β€” for example, a Button inside a dark card on an otherwise light page. Even then, the override should use a data attribute on the container rather than a component prop.
  • QHow do you handle shadcn/ui primitive updates in a production component library without breaking consumer applications?SeniorReveal
    Never update shadcn primitives on the main branch. Create a dedicated branch for the update, run 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.
  • QWhat prop API design principles make a shared component library adoptable by engineering teams?Mid-levelReveal
    Three principles: (1) Intent-based naming β€” use intent="destructive" instead of variant="destructive" because intent communicates purpose. A developer knows what destructive means in context without reading documentation. (2) Composition props β€” provide leftElement, rightElement, asChild props instead of requiring children manipulation. Composition props make common patterns one-liners and reduce the chance of accessibility mistakes. (3) className escape hatch β€” every component must accept a className prop merged with defaults via 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.
  • QHow would you migrate a codebase that has scattered direct shadcn imports to a shared wrapper library?SeniorReveal
    Use an incremental, phased approach β€” never a big-bang migration. Phase 1 (Week 1): Audit. Run grep to find every direct shadcn import, count occurrences per component, and identify the top 5 most-imported components. This is your migration priority list. Phase 2 (Weeks 2–3): Create the package. Set up the workspace package structure, configure components.json, and move existing shadcn files into primitives/. Create Layer 2 wrappers for the top 5 components only. Phase 3 (Ongoing): Migrate imports directory by directory β€” not file by file. Directory-level migration keeps PRs reviewable and reduces risk. Phase 4 (Final): Add ESLint enforcement only after 80%+ of imports are migrated. Adding enforcement on day one with most imports still using the old path means a permanently failing CI and kills team buy-in. Rule: the migration is successful when bypassing the wrapper is harder than using it.

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.

πŸ”₯
Naren Founder & Author

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.

← PreviousHow I Generate 50+ shadcn Components Faster with AINext β†’Building an AI SaaS from Scratch with Next.js 16
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged