# shadcn/ui design guide [2026 edition] — an ownable design system built with cva, cn, and Slot

> A complete guide to designing shadcn/ui as a production-quality design system. With real code, it explains: the advantages of the 'own the code' model rather than an npm dependency, variant design with class-variance-authority (cva), the role of cn(), polymorphism with asChild / Slot, CSS-variable themes, extension via composition, a11y, and organization-wide deployment with a custom registry.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: React, Tailwind CSS, TypeScript, フロントエンド, アクセシビリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/shadcn-ui-design-system-architecture-production-guide
- Category: Frontend
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-16-app-router-cache-components-data-fetching

## Key points

- shadcn/ui isn't a library but a model where you own the component source in your own repository. It frees you from version lock and the black box.
- The core is three: cva (variant declaration), cn() (class-conflict resolution with twMerge × clsx), and Radix (a11y guarantee).
- Extend by composition (a wrapper) without editing the original, reconciling safe updates via shadcn diff with DRY, SRP, and ETC.
- Hold the theme as semantic tokens in CSS variables, consolidating light/dark and rebranding in one place.
- Only by protecting a11y, types (VariantProps), and visuals in CI does it become a design system that doesn't break down.

---

"I installed a component library but end up fighting with customization anyway" — ending that drain is shadcn's philosophy.

---

## 1. Why "copy and own"

Traditional UI libraries (npm dependencies) had, in exchange for convenience, the following pains.

- **The customization wall.** You just want to change a small detail of the look, but you fight the library's internal circumstances.
- **Version lock.** A major update breaks the API, and migration cost balloons.
- **A black box.** You don't know what's happening inside, and debugging is hard.

shadcn/ui solves this with the approach of "**bringing the component source into your own repository via the CLI.**" Once brought in, it's your code, so you can freely read it, change it if needed, and aren't tossed around by external versions. The trade-off is bearing "the responsibility to maintain it yourself." But **owning the code is the greatest weapon for long-term maintainability (ETC).**

```bash
# 初期化（style: new-york, base color: neutral, CSS変数を有効化）
npx shadcn@latest init
# プリミティブを取り込む
npx shadcn@latest add button card input
```

Settings are consolidated in `components.json`, recording aliases and styles.

---

## 2. `cn()`: why you need both `clsx` and `tailwind-merge`

The foundation of all primitives is the `cn()` utility. It's a short function, but it simultaneously solves two different problems.

```ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

- **`clsx`: assembling conditional classes.** Add classes by truthiness like `cn("base", isActive && "bg-primary")`.
- **`tailwind-merge`: resolving conflicting classes.** Normalize `cn("px-2", "px-4")` to `px-4`. Without this, last-wins override doesn't take effect, and both `px-2 px-4` remain, causing accidents.

This two-stage approach establishes the extensibility that "**the caller can override with `className`.**" The order (clsx first, twMerge after) also has meaning.

---

## 3. `cva`: design variants declaratively

With `class-variance-authority` (cva), declare a component's visual derivations (variant / size) **as configuration, not JSX branching.** Let's look at the actual button primitive.

```tsx
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  // 全バリアント共通の基底クラス（フォーカスリング・無効化・アイコン整形まで内蔵）
  "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-[color,background-color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        outline: "border border-input bg-background shadow-sm hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  },
);
```

**When you want a new look, don't add an `if` to the JSX; add one line to `variants`** — this is the iron rule. The type is auto-derived with `VariantProps<typeof buttonVariants>`, making `<Button variant="ghost" size="sm" />` type-safe (a non-existent value is a compile error).

> Design tip: putting the focus ring and disabled styles in the base class means all variants automatically have a11y and interaction states. It also suits SRP (consolidating the visual responsibility here).

---

## 4. `asChild` and `Slot`: polymorphism that avoids prop explosion

"I want the look of a button but the substance to be a link (`a`)" — a common request. Solving it naïvely proliferates `as` props or an `isLink` flag. shadcn solves it with Radix's `Slot`.

```tsx
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    // asChild なら、自分は描画せず「子要素」にクラスとpropsを委譲する
    const Comp = asChild ? Slot : "button";
    return (
      <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
    );
  },
);
Button.displayName = "Button";
```

```tsx
// 見た目はボタン、実体は Next.js の Link（正しい <a> としてレンダリング）
<Button asChild>
  <Link href="/contact">お問い合わせ</Link>
</Button>
```

`asChild` is a mechanism that "transplants the style and behavior to the child." With this, you can share the look while keeping **the semantically correct element (a link is `a`, an operation is `button`).** It's the right answer for both a11y and prop design ([accessibility details](/blog/react-nextjs-web-accessibility-wcag22-guide)).

---

## 5. Theme: name "meaning" with CSS variables

shadcn handles colors not with concrete values like `bg-blue-600` but with **role (semantic) tokens** like `bg-primary` / `bg-muted` / `bg-destructive`. The substance is CSS variables, so light/dark and rebranding complete with a change in one place.

```css
:root {
  --primary: 222 47% 11%;
  --primary-foreground: 210 20% 98%;
  --muted-foreground: 215 19% 42%; /* AA を満たす淡色テキスト */
}
.dark {
  --primary: 210 20% 98%;
  --primary-foreground: 222 47% 11%;
}
```

Holding color in HSL channels lets you add transparency afterward like `bg-primary/90`, meshing cleanly with Tailwind v4's `@theme` mapping. **The key points of a design where dark mode switches at runtime** are detailed in the [Tailwind v4 guide](/blog/tailwind-css-v4-css-first-design-tokens-production-guide) (beware the `@theme inline` trap).

---

## 6. The practice of extension: don't edit the original, "compose"

This is the dividing point in team operation. **Directly rewriting an imported primitive breaks updates (`shadcn diff`)** and lowers reusability too. The principle is "**don't touch the original, compose with a wrapper.**"

```tsx
// ❌ button.tsx を直接編集して案件固有のスタイルを足す（更新が地獄に）

// ✅ 呼び出し側ドメインでラップして合成する
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export function SubmitButton({ className, ...props }: ButtonProps) {
  return (
    <Button
      type="submit"
      className={cn("min-w-32", className)} // 追加スタイルは合成。cn が衝突を解決
      {...props}
    />
  );
}
```

Correspondence with design principles:

- **DRY:** consolidate common look in `cva` and common behavior in the wrapper. Don't create duplication.
- **SRP:** the primitive bears only "the general-purpose look" and doesn't bring in business logic (auth, analytics, API).
- **ETC:** since the original is preserved, you can safely follow the latest with `npx shadcn@latest diff`.
- **One-way dependency:** the primitives (`components/ui/*`) don't depend on domain components. Keep the direction of dependency one-way.

---

## 7. Using it in an organization: scale with a custom registry

To reuse the same parts across multiple products, stand up **your own registry** and distribute internal components with `npx shadcn@latest add`. You can operate the design system as "the single source of truth to copy from," and each repository imports and owns it.

- Distribute brand colors, spacing, and typography as common tokens.
- Update parts on the registry side, and each project imports the diff.
- Evolve it with "explicit diff application," not "library version hell."

But don't over-build from the start (YAGNI). Consolidating once duplication actually occurs across 2–3 products is healthy.

---

## 8. Testability and quality assurance

- **Automated accessibility inspection.** Verify in CI with `@axe-core/playwright` that there are no missing labels or insufficient contrast on screens after primitive composition (this site applies it to all pages too).
- **Keyboard-operation tests.** Even riding on Radix's guarantee, confirm Tab / Enter / Escape to check you didn't break it in composition.
- **Visual regression.** Snapshot-compare to check that adding a variant didn't break the existing look.
- **Type tests.** With `VariantProps`, specifying a non-existent variant fails at compile time (a defense needing no runtime test).

---

## 9. Antipatterns

- ❌ **Editing `components/ui/*` directly for a one-off.** Updates break. Compose with a wrapper.
- ❌ **Overriding `variant` with string concatenation in JSX.** Extend the `cva` configuration.
- ❌ **Putting business logic (auth, analytics, fetch) in a primitive.** Generality dies. An SRP violation.
- ❌ **Concatenating `className` directly without `cn()`.** Tailwind's conflicting classes aren't resolved and override doesn't take effect.
- ❌ **Stripping Radix's a11y attributes for visual reasons.** Keyboard operation and screen reading break.
- ❌ **Designing a giant common registry from the start.** Extract after duplication appears (YAGNI).

---

## 10. FAQ

**Q. Is shadcn/ui a library you install with npm?**
A. No. You use the CLI to **copy the component source into your own repository** and own it. It doesn't reside in `node_modules` as a runtime dependency (peers like Radix are installed).

**Q. What's the difference from existing libraries like MUI / Chakra?**
A. The biggest difference is "ownership." Existing libraries configure on top of an abstraction, whereas shadcn has the source itself, so customization freedom and debuggability are on a different level. In exchange, maintenance responsibility comes to you.

**Q. How do I update components?**
A. Check the diff with upstream via `npx shadcn@latest diff <name>` and import only the necessary parts. That's exactly why the practice of "don't edit the original directly" is important.

**Q. Is it OK to use it with Tailwind v4?**
A. Rather, it's the premise. Since shadcn uses a CSS-variable theme, it integrates naturally with Tailwind v4's `@theme` token design.

**Q. Is the design system complete with shadcn alone?**
A. It becomes the foundation of primitives, but it becomes a "design system that doesn't break down" only when you set up token design, composition conventions, a11y criteria, and a test regime. The tool and the operation are different things.

---

## Conclusion: "own" components and grow them with composition

shadcn/ui's value is in its **design philosophy**, not the prettiness of the look. Own the code, manage the look declaratively with `cva`, safely allow override with `cn()`, keep meaning with `Slot`, and extend with composition — this discipline makes a design system that doesn't break down long-term.

1. The **ownership model** frees you from version lock and the black box.
2. With **`cva` / `cn()` / Radix** at the core, make type-safe, extensible, accessible parts.
3. **Extend by composition (a wrapper)** and preserve the original to keep it updatable (DRY, SRP, ETC).
4. With a **CSS-variable theme**, consolidate light/dark and rebranding in one place.
5. Protect **a11y, types, and visuals** in CI to keep quality continuous.

A well-ordered design system lifts development speed, consistency, and maintainability all at once. It's also the foundation of the trust of "a carefully made product."

**If you need design-system design, building a component foundation based on shadcn/ui, or refactoring an existing UI, feel free to consult me.** The case study below introduces the process of designing and implementing many UI parts needed for a business system, emphasizing consistency, type safety, and maintainability.
