メインコンテンツへスキップ
友田 陽大
フロントエンド
React
Tailwind CSS
TypeScript
フロントエンド
アクセシビリティ
アーキテクチャ設計

shadcn/ui 設計ガイド【2026年版】— cva・cn・Slot で作る、所有できるデザインシステム

shadcn/ui を本番品質のデザインシステムとして設計する完全ガイド。npm 依存ではなく『コードを所有する』モデルの利点、class-variance-authority(cva)によるバリアント設計、cn() の役割、asChild / Slot による多態、CSS 変数テーマ、合成による拡張、a11y、カスタムレジストリでの組織展開まで実コードで解説します。

公開日
読了時間
10分
著者
友田 陽大
シェア

「コンポーネントライブラリを入れたのに、結局カスタマイズで戦っている」——その消耗を終わらせるのが shadcn の思想です。


1. なぜ「コピーして所有する」のか

従来のUIライブラリ(npm 依存)には、便利さと引き換えに次の痛みがありました。

  • カスタマイズの壁。 細かい見た目を変えたいだけなのに、ライブラリ内部の都合と戦う。
  • バージョンロック。 メジャーアップデートで API が壊れ、移行コストが膨らむ。
  • ブラックボックス。 中で何が起きているか分からず、デバッグが難しい。

shadcn/ui は「コンポーネントのソースを CLI で自分のリポジトリに取り込む」アプローチでこれを解きます。取り込んだ後はあなたのコードなので、自由に読め、必要なら変えられ、外部バージョンに振り回されません。トレードオフは「自分でメンテする責任」を負うこと。しかしコードの所有こそが長期的な保守性(ETC)の最大の武器になります。

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

設定は components.json に集約され、エイリアスやスタイルが記録されます。


2. cn():なぜ clsxtailwind-merge の両方が要るのか

すべてのプリミティブの基礎が cn() ユーティリティです。短い関数ですが、2つの異なる問題を同時に解いています。

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

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  • clsx:条件付きクラスの組み立て。 cn("base", isActive && "bg-primary") のように真偽でクラスを足す。
  • tailwind-merge:競合クラスの解決。 cn("px-2", "px-4")px-4 に正規化する。これが無いと、後勝ちの上書きが効かず px-2 px-4 が両方残って事故ります。

この2段構えにより、「呼び出し側が className で上書きできる」という拡張性が成立します。順序(先に clsx、後に twMerge)にも意味があります。


3. cva:バリアントを宣言的に設計する

class-variance-authority(cva)で、コンポーネントの見た目の派生(variant / size)をJSX の分岐ではなく設定として宣言します。実際のボタンプリミティブを見てみましょう。

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" },
  },
);

新しい見た目が欲しいときは、JSX に if を足すのではなく variants に1行追加する——これが鉄則です。型は VariantProps<typeof buttonVariants> で自動導出され、<Button variant="ghost" size="sm" /> が型安全になります(存在しない値はコンパイルエラー)。

設計のコツ:基底クラスにフォーカスリングや無効化スタイルを入れておくと、すべてのバリアントが自動で a11y とインタラクション状態を備えます。SRP(見た目の責務をここに集約)にも適います。


4. asChildSlot:プロップ爆発を避ける多態

「ボタンの見た目で、実体はリンク(a)にしたい」——よくある要求です。素朴に解くと as プロップや isLink フラグが増殖します。shadcn は Radix の Slot で解決します。

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";
// 見た目はボタン、実体は Next.js の Link(正しい <a> としてレンダリング)
<Button asChild>
  <Link href="/contact">お問い合わせ</Link>
</Button>

asChild は「スタイルと振る舞いを子へ移植する」仕組みです。これにより、意味的に正しい要素(リンクは a、操作は button を保ったまま見た目を共有できます。a11y とプロップ設計の両方で正解になります(アクセシビリティの詳細)。


5. テーマ:CSS 変数で「意味」に名前を付ける

shadcn は色を bg-blue-600 のような具体値ではなく、bg-primary / bg-muted / bg-destructive のような役割(セマンティック)トークンで扱います。実体は CSS 変数なので、ライト/ダークやリブランドが1か所の変更で完結します。

: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%;
}

色を HSL チャンネルで持つと bg-primary/90 のように透明度を後付けでき、Tailwind v4 の @theme マッピングと綺麗に噛み合います。ランタイムでダークモードが切り替わる設計の要点Tailwind v4 ガイドで詳述しています(@theme inline の罠に注意)。


6. 拡張の作法:原本は編集せず「合成」する

ここがチーム運用での分かれ目です。取り込んだプリミティブを直接書き換えると、更新(shadcn diff)が破綻し、再利用性も落ちます。原則は「原本は触らず、ラッパーで合成する」。

// ❌ 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}
    />
  );
}

設計原則との対応:

  • DRY: 共通の見た目は cva に、共通の振る舞いはラッパーに集約。重複を作らない。
  • SRP: プリミティブは「汎用の見た目」だけを担い、業務ロジック(認証・分析・API)を持ち込まない。
  • ETC: 原本を温存するので、npx shadcn@latest diff で安全に最新へ追従できる。
  • 一方向依存: プリミティブ(components/ui/*)はドメインコンポーネントに依存しない。依存の向きを一方向に保つ。

7. 組織で使う:カスタムレジストリでスケールさせる

複数プロダクトで同じ部品を使い回すなら、自前のレジストリを立て、npx shadcn@latest add で社内コンポーネントを配布できます。デザインシステムを「コピー元の単一の真実」として運用でき、各リポジトリはそれを取り込んで所有します。

  • ブランドカラー・スペーシング・タイポを共通トークンとして配布。
  • 部品の更新はレジストリ側で行い、各プロジェクトが差分取り込み。
  • 「ライブラリのバージョン地獄」ではなく「明示的な差分適用」で進化させる。

ただし、最初から作り込みすぎないこと(YAGNI)。2〜3プロダクトで重複が実際に発生してから共通化するのが健全です。


8. テスト容易性・品質保証

  • アクセシビリティ自動検査。 @axe-core/playwright で、プリミティブ合成後の画面にラベル欠落・コントラスト不足が無いか CI で検証する(このサイトも全ページに適用)。
  • キーボード操作テスト。 Radix の保証に乗っていても、合成で壊していないか Tab / Enter / Escape を確認する。
  • ビジュアルリグレッション。 バリアント追加が既存の見た目を壊していないかスナップショット比較する。
  • 型テスト。 VariantProps により、存在しない variant 指定はコンパイル時に落ちる(実行時テスト不要の防御)。

9. アンチパターン

  • components/ui/* を一品物のために直接編集する。 更新が破綻する。ラッパーで合成する。
  • variant を JSX で文字列連結して上書きする。 cva の設定を拡張する。
  • プリミティブに業務ロジック(認証・分析・fetch)を入れる。 汎用性が死ぬ。SRP 違反。
  • cn() を使わず className をそのまま結合する。 Tailwind の競合クラスが解決されず上書きが効かない。
  • Radix の a11y 属性を見た目都合で剥がす。 キーボード操作・読み上げが壊れる。
  • 最初から巨大な共通レジストリを設計する。 重複が出てから抽出する(YAGNI)。

10. FAQ(よくある質問)

Q. shadcn/ui は npm でインストールするライブラリ? A. 違います。CLI でコンポーネントのソースを自分のリポジトリにコピーして所有します。ランタイム依存として node_modules に常駐するわけではありません(Radix 等の peer は入ります)。

Q. MUI / Chakra など既存ライブラリと何が違う? A. 最大の違いは「所有」です。既存ライブラリは抽象の上で設定するのに対し、shadcn はソースそのものを持つため、カスタマイズの自由度とデバッグ性が段違いです。代わりにメンテ責任は自分に来ます。

Q. コンポーネントの更新はどうする? A. npx shadcn@latest diff <name> で上流との差分を確認し、必要箇所だけ取り込みます。だからこそ「原本を直接編集しない」運用が重要です。

Q. Tailwind v4 と併用して問題ない? A. むしろ前提です。shadcn は CSS 変数テーマを使うため、Tailwind v4 の @theme トークン設計と自然に統合できます。

Q. デザインシステムは shadcn だけで完成する? A. プリミティブの土台にはなりますが、トークン設計・合成規約・a11y 基準・テスト体制を整えて初めて「破綻しないデザインシステム」になります。道具と運用は別物です。


まとめ:コンポーネントを「所有」し、合成で育てる

shadcn/ui の価値は、見た目のきれいさではなく設計思想にあります。コードを所有し、cva で見た目を宣言的に管理し、cn() で安全に上書きを許し、Slot で意味を保ち、合成で拡張する——この規律が、長期的に破綻しないデザインシステムを作ります。

  1. 所有モデルでバージョンロックとブラックボックスから解放される。
  2. cva / cn() / Radix を核に、型安全・拡張可能・アクセシブルな部品を作る。
  3. 合成(ラッパー)で拡張し、原本は温存して更新可能に保つ(DRY・SRP・ETC)。
  4. CSS 変数テーマでライト/ダーク・リブランドを1か所に集約する。
  5. a11y・型・ビジュアルを CI で守り、品質を継続させる。

整ったデザインシステムは、開発速度・一貫性・保守性のすべてを底上げします。それは「丁寧に作られたプロダクト」という信頼の土台でもあります。

デザインシステムの設計、shadcn/ui を基盤にしたコンポーネント基盤の構築、あるいは既存 UI のリファクタリングが必要な場合は、お気軽にご相談ください。 下記の事例では、業務システムに必要な多数のUI部品を、一貫性・型安全・保守性を重視して設計・実装した過程を紹介しています。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る