Senior 7 min · April 14, 2026

shadcn/ui Library-40% Slower Without Shared Wrappers

40% slower features & accessibility audit failures due to missing shadcn/ui wrapper.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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
Plain-English First

Think of shadcn/ui like a set of high-quality LEGO bricks with instructions. You do not buy a finished model — you get the bricks, the instructions, and the freedom to build whatever you want. The bricks are Radix UI primitives. The instructions are the shadcn component templates. Your job is to assemble them into a kit that your specific team can use without reading the manual every time.

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.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// 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>
//   );
// }
The Import Boundary Rule
  • 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.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#!/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.cssCSS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/* 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
  • Props like colorScheme="dark" force React to re-render the component tree when the theme changes — CSS variables do not
  • Prop-based theming creates a combinatorial explosion: every component must handle every theme variant
  • CSS variables cascade naturally — a parent container sets --primary and all descendants inherit it with zero prop drilling
  • Theme switching via CSS variables is instant on mobile — prop-based switching adds 300–500ms re-render time
  • The only valid exception: a component-specific override that cannot be expressed as a shared token (e.g., a Button inside a dark card on an otherwise light page)
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.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "$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.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
// 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
  • Fixing accessibility in a wrapper component fixes it for every consumer — one change, many surfaces
  • Ignoring accessibility in the wrapper means every feature component inherits the debt
  • Automated tests (axe, jest-axe) catch approximately 30% of real issues — manual screen reader and keyboard testing catches the rest
  • WCAG 2.2 AA is the minimum standard for any shared component library in 2026
  • Test with VoiceOver (macOS/iOS) and NVDA (Windows) — they handle ARIA attributes differently
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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 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`);
  });
}
Visual Regression as Your Primitive Update Safety Net
  • 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.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
{
  "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"
  }
}
The sideEffects Field Is Not Optional
  • 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.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// 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.
● Production incidentPOST-MORTEMseverity: high

The Design System That Died by a Thousand Forks

Symptom
New 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.
Assumption
The 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 cause
No 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.
Fix
Created 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 layer
  • Without a shared wrapper, every team diverges silently
  • The CLI copies code once — after that, you own the drift
  • Enforce single import paths via CI linting, not documentation
Production debug guideWhen your shadcn-based library breaks or teams stop using it5 entries
Symptom · 01
Developers bypass the shared library and import shadcn components directly
Fix
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.
Symptom · 02
Theme tokens do not apply consistently across components
Fix
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.
Symptom · 03
Component library build fails in monorepo with cryptic path resolution errors
Fix
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.
Symptom · 04
Upgrading shadcn primitives breaks custom wrapper components
Fix
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.
Symptom · 05
Tree-shaking fails — bundle includes every shadcn component even if unused
Fix
Ensure the library package exports individual components, not a barrel index. Use named exports: export { Button } from './button' — not export * from './components'.
★ Component Library Quick Debug Cheat SheetWhen your shadcn-based component library has issues, run through this checklist.
CSS variables not applying — components render with default styles
Immediate action
Verify 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 now
Ensure globals.css is imported once in the root layout, not in individual components
cn() utility produces unexpected class merges+
Immediate action
Check 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 now
Add the library package path to tailwind content array so Tailwind scans library source files
Radix UI portal renders in wrong z-index layer+
Immediate action
Check for conflicting z-index values in parent containers
Commands
grep -rn 'z-\[' app/ components/ | grep -v node_modules
grep -rn 'isolation:' app/ components/
Fix now
Add 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 action
Check 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 now
Ensure every wrapper component uses React.forwardRef and spreads ...props onto the underlying shadcn component
shadcn/ui vs Traditional Component Libraries
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

1
shadcn/ui is a foundation, not a finished library
you must build the wrapper layer or drift is guaranteed
2
Three-layer architecture
primitives (raw shadcn), wrappers (your design API), features (domain compositions) — import direction is one-way
3
Use npx shadcn@latest
the CLI was renamed from shadcn-ui to shadcn in 2025
4
Theme with CSS variables, not component props
variables cascade without React re-renders
5
Enforce import boundaries with ESLint and CI
documentation alone does not prevent direct primitive imports
6
Every wrapper must accept className
without the escape hatch, teams will fork components silently
7
Update primitives on branches with visual regression tests
npx shadcn@latest diff before every update
8
Accessibility fixes in the wrapper library propagate to every consumer
highest-leverage bug fix in the codebase
9
The sideEffects field in package.json is mandatory
without it, globals.css is dropped from production builds
10
Migrate from scattered imports incrementally
audit → package → migrate by directory → enforce

Common mistakes to avoid

7 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How would you architect a shared component library on top of shadcn/ui f...
Q02SENIOR
What is the trade-off between source imports and compiled output for an ...
Q03SENIOR
Why should you use CSS variables for theming instead of a prop-based the...
Q04SENIOR
How do you handle shadcn/ui primitive updates in a production component ...
Q05SENIOR
What prop API design principles make a shared component library adoptabl...
Q06SENIOR
How would you migrate a codebase that has scattered direct shadcn import...
Q01 of 06SENIOR

How would you architect a shared component library on top of shadcn/ui for a monorepo with 8 applications?

ANSWER
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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Can I use shadcn/ui with Vue or Svelte instead of React?
02
How do I add a new shadcn component to my shared library?
03
Should I use shadcn/ui for a design system that needs to support multiple frameworks?
04
How do I handle component variants that shadcn/ui does not provide?
05
Is shadcn/ui suitable for enterprise applications with strict design compliance requirements?
06
What changed in the shadcn CLI in 2025 that I need to know about?
🔥

That's React.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
How I Generate 50+ shadcn Components Faster with AI
43 / 47 · React.js
Next
Building an AI SaaS from Scratch with Next.js 16