📖

shadcnを理解する

に公開

※shadcnさんの経歴などの話はしないです。
shadcn/uiは従来のUIライブラリとは一線を画した設計思想で構築されています。Radix UIのアクセシブルなプリミティブとTailwind CSSのユーティリティを活用した再利用可能なUIコンポーネント群です
しかしこれは単なるパッケージ化された「コンポーネントライブラリ」ではなく、プロジェクトにコードをコピーして組み込む形式を採っています。おっと、コンポーネントライブラリとかRadix UIとかなんぞやと思った方、解説もあるのでちょっと待ってください。
※Tailwind CSSの解説は省きます。

本記事では、shadcn背後にある設計哲学に焦点を当て、Buttonなど基本コンポーネントの内部構造や、CVA(Class Variance Authority)によるクラス管理、Tailwind CSSとの統合方法、Radix UIとの関係、カスタマイズ方法について解説します。サンプルコードなどは私が現場でshadcn導入したのはVueなので、shadcn-vueを使っています。

コンポーネントライブラリとは何か

コンポーネントライブラリは、ボタンやフォーム、ナビゲーションなど再利用可能なUI要素をまとめた「設計済みコンポーネントの集合」です。これにより、開発者は各プロジェクトで同じUIを一から作る必要がなくなり、開発効率と見た目の一貫性が向上します。
コンポーネントライブラリにはいくつかの形態があります。モノリシックなUIフレームワーク、(例:Vue界隈のVuetifyなど)は、マテリアルデザイン等に沿ったスタイル付きコンポーネント群を提供します。一方で、ヘッドレスコンポーネントライブラリ(例:Radix UI、Tailwind LabsのHeadless UIなど)は「機能は提供するが見た目(スタイル)は持たない」設計です。

実際に違いを見ていきましょう。
モノリシックなUIフレームワークであるVuetifyでは以下のようにボタンを使うことができます。

<v-btn variant="text">
  Button
</v-btn>

v-btnコンポーネントに対してpropsを指定していく方法です。用意されているpropsは以下のようにドキュメントに起こされているので確認できます。
https://vuetifyjs.com/en/api/v-btn/#props

しかしこの方法ではデザインについてはフレームワーク(Vuetify)に依存するところが大きいですよね。classを指定してスタイルを上書きするにも限界があったり、固定値で変更不可のところも多くあるかと思います。そのため自社のデザインシステムやデザイナーの意向に沿うものができない可能性が多分にあり、フレームワークに寄ったデザインになることが予想されます。

一方、機能は提供するがスタイルは持たない設計のヘッドレスコンポーネントライブラリでは、その心配はなくなります。モーダル・ドロップダウン・トースト…挙動が複雑なUIはアクセシビリティ(フォーカストラップ、ARIA ラベル)まで書こうとすると途端に膨大になります。Headless UI の流儀は「そこを丸ごと渡すから、CSS は好きに着せ替えてね」という割り切りですので。
以下はRadix UIの例です。

<script setup lang="ts">
import { DialogRoot, DialogTrigger, DialogContent } from 'radix-vue'
</script>

<template>
  <DialogRoot>
    <DialogTrigger as-child>
      <button class="btn">開く</button>
    </DialogTrigger>

    <DialogContent class="bg-white p-6 shadow-xl">
      <slot />
    </DialogContent>
  </DialogRoot>
</template>

見た目は Tailwind で自由自在、フォーカス管理や ESC クローズは Radixが面倒を見てくれます。このアプローチの利点は、デザインの自由度が飛躍的に高まることです。コンポーネントライブラリにありがちな「デフォルトの見た目を上書きする苦労」から解放され、ゼロから自分たちのデザインポリシーを適用できます。その代わり、利用する側は自前でスタイリングを当てる手間が増えます。

shadcnではこれらの良いとこどりのイメージがあります。
shadcn = 「Radix系ヘッドレスコンポーネント」+「Tailwind CSS」+「コード所有型UIライブラリ」 といった感じでしょうか。

shadcnとは何か

https://ui.shadcn.com/
特徴的なのは、一般的なUIライブラリとは異なり**「npmでインストールするパッケージではない」という点です。つまりshadcnは完成済みのコンポーネント群をコピー&ペーストでプロジェクト内に取り込むスタイルを採っています。「それってUIライブラリじゃないの?」と思うかもしれません。実際、公式ドキュメントでも「これはコンポーネントライブラリではありません」と記載されています。
https://www.shadcn-vue.com/docs/introduction

依存関係として追加するのではなく、必要なコンポーネントのコードを自分のプロジェクト内に取り込んで使うため、コードは自分達の管理下に置かれます。shadcnはUIコンポーネントの「設計図」や「参考実装集」であり、開発者はそれを元に自分のUIコンポーネントを構築するイメージです。
VuetifyやNuxt UIなどのモノリシックなライブラリでは用意されたpropsやスロットの範囲でのカスタマイズに留まりますが、shadcnであれば、Radix VueのComboboxプリミティブを活かしつつ、テンプレートHTMLを自分で編集して独自のUIに仕上げることが可能です。「こんなUIは既存ライブラリには無い」というケースでも、shadcnなら既存コンポーネントをベースに発展させていける強みがあります。

また、アップデートと保守の違いもあります。Nuxt UIは公式がバージョン管理して改善をリリースしてくれるので、バグ修正や機能追加はパッケージ更新で取り込めます。一方shadcnは自前で取り込んだコードゆえ、ある意味フォークした状態になります。新しいコンポーネントがshadcnプロジェクトで追加されても自分のプロジェクトには影響ありません。必要なら後追いで自分でaddすれば良いですし、既に取り込んだコンポーネントの更新はdiffを見ながら手動でマージする形になります。これをデメリットと捉えるか、自由度の裏返しと捉えるかは視点によります。

Radix UI

shadcnを理解するには、その根底にあるHeadless UIとRadix UI(現状はVueではRadix VueをリブランドしたReka UIと呼ばれています)の思想を知ることが重要です。 数あるHeadless UIライブラリの中でも代表的存在がRadix UIです。
shadcn-vueはRadix UIを内部で活用しており、各コンポーネントのロジック部分にRadix由来のヘッドレスコンポーネントを利用しています。
https://www.shadcn-vue.com/docs/about

例えば、shadcn-vueのDropdown MenuコンポーネントはRadix VueのDropdownMenuプリミティブを下敷きにし、そこにTailwindクラスで見た目を定義するといった具合です。これにより、アクセシビリティや挙動といった「中身の良さ」はRadixで担保しつつ、見た目は自由にデザインできるという理想的な形を取っています。

コンポーネントの設計思想

shadcn/vueのコンポーネントは、「最小限の実装+明確な構造」という哲学で設計されています。典型例がButtonコンポーネントで、その内部構造はシンプルかつ拡張性の高いものです。Buttonは見た目としては通常の<button>要素ですが、状態やバリアント(種類)に応じて適切なスタイルが適用されるよう工夫されています。例えばButtonの場合、スタイルのバリエーション定義を担うbutton/index.ts、実際のVueコンポーネントを記述するButton.vue、
この構成により、スタイルロジックとコンポーネントロジックが明確に分離され、コードの見通しと再利用性が向上します。
Button.vue
https://github.com/unovue/shadcn-vue/blob/dev/apps/www/src/registry/default/ui/button/Button.vue

以下はスタイルです。
https://github.com/unovue/shadcn-vue/blob/dev/apps/www/src/registry/default/ui/button/index.ts

使用例は以下に記載されています。
https://www.shadcn-vue.com/docs/components/button.html

Buttonコンポーネントの内部ではbuttonVariantsという関数を用いてクラス名文字列を計算し、それをボタン要素に適用しているだけです。buttonVariantsは CVAを用いて定義されたスタイルバリエーション生成関数 であり、プロパティ(例: variantやsize)に応じた適切なTailwind CSSクラスの集合を返します。このclasses計算プロパティには、ボタンのスタイルに必要な全てのクラス名が文字列として格納されます。テンプレートでは<button :class="classes">とバインディングすることで、Buttonコンポーネントにスタイルを適用しているのです。
このようにButton.vue自体のテンプレートはシンプルで、スロット経由で子要素(ボタンのラベルなど)を表示するだけの構造になっています。複雑なロジックはなく、主眼は「どのクラスを付与するか」という点に絞られています。これにより、コンポーネント利用側の開発者にとってもコードが読みやすく、必要に応じてカスタマイズ(例えばクラスの追加や調整)もしやすくなっています。

CVAについては以下の記事がわかりやすかったです。
https://tech.route06.co.jp/entry/2024/06/14/105429

CVAの利点は、スタイルのバリエーション定義をオブジェクトリテラルで宣言できるため、後から新しいvariantを追加したり既存のスタイルを調整したりするのが容易な点です。また、内部的にはCVAがクラス名を組み立てる際に重複や競合をマージしてくれるため、たとえば基底クラスに含まれるrounded-mdとvariantに含まれるrounded-mdが二重につくようなケースでも、自動的に一つにまとめられます。(実際にはCVAはオプションでclsxやtailwind-mergeと組み合わせて動作し、不要な重複クラスを整理します)

shadcn/vueでも内部的に cn()というユーティリティ関数(clsxとtailwind-mergeを組み合わせたもの) を用意しており、CVAと併用してクラス合成の効率化・最適化を図っています。
ただし通常コンポーネント利用者がCVAやcnを直接意識する必要はなく、提供されたProps(例: <Button variant="destructive" size="sm">)を指定すれば自動的に適切なクラスが当たる仕組みです。

Vue/Nuxt 3でのshadcn利用方法(実装例)

それでは、実際にVueもしくはNuxt 3プロジェクトでshadcn-vueを使う流れを簡単に追ってみます。
https://www.shadcn-vue.com/docs/installation/nuxt
上記のドキュメントの手順通りにインストール可能です。
まずtailwind cssが内部で使われているため、tailwind cssの導入が必須になります。tailwind cssを入れた後にshadcn-nuxtを入れる流れです。

$ npx shadcn-vue@latest init

上記で設定ファイルやshadcnが内部で使用しているtaiwlind cssのカラーなどが自動で追加されていきます。
components.json

{
  "$schema": "https://shadcn-vue.com/schema.json",
  "style": "default",
  "typescript": true,
  "tailwind": {
    "config": "tailwind.config.js",
    "css": "assets/style/setting.scss",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": "tw-"
  },
  "aliases": {
    "components": "@/components",
    "composables": "@/composables",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib"
  },
  "iconLibrary": "lucide"
}

このcomponents.jsonでshadcnのコンポーネントを利用する際のルールなどを定義できます。
例えばtailwind cssにtw-text-smなどのようにprefixを使用している場合、shadcnにもその条件を適用する必要があるのでtailwindの箇所に"prefix": "tw-" を追加してあげると良いです。

カラーもデフォルトの定義が入ります。これはtailwind cssの設定を定義しているファイルをプロジェクトから探してきて自動で末尾に追加しているものだと思います。
setting.scss

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 0 0% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 0 0% 3.9%;
    --primary: 0 0% 9%;
    --primary-foreground: 0 0% 98%;
    --secondary: 0 0% 96.1%;
    --secondary-foreground: 0 0% 9%;
    --muted: 0 0% 96.1%;
    --muted-foreground: 0 0% 45.1%;
    --accent: 0 0% 96.1%;
    --accent-foreground: 0 0% 9%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 0 0% 89.8%;
    --input: 0 0% 89.8%;
    --ring: 0 0% 3.9%;
    --chart-1: 12 76% 61%;
    --chart-2: 173 58% 39%;
    --chart-3: 197 37% 24%;
    --chart-4: 43 74% 66%;
    --chart-5: 27 87% 67%;
    --radius: 0.5rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

コンポーネントを利用するときは以下のようにして引っ張ってることが可能です。

npx shadcn-vue@latest add button

このようにして追加されたコンポーネントはあなたのプロジェクトの一部です。外部依存ではないため、自分たちで好きなようにコードを編集できます。

デフォルトではcomponents/ui 配下にbuttonに必要なコンポーネントが配置されていきます。

<script setup lang="ts">
import { Button } from '@/components/ui/button'
</script>

<template>
  <Button>Button</Button>
</template>

そして上記のようにして呼び出し可能です。

私は自前のコンポーネントとshadcnのコンポーネントを分けて管理したいとの考えから以下のようにnuxt.config.tsでprefixやコンポーネントの配置箇所を変更しています。

  shadcn: {
    /**
     * Prefix for all the imported component
     */
    prefix: 'Ui',
    /**
     * Directory that the component lives in.
     * @default "./components/ui"
     */
    componentDir: './components/shadcn',
  },

こうすることで以下のように呼び出せるようになります。

<template>
  <UiButton>Button</Button>
</template>

導入の判断基準

プロダクトチームがshadcnを採用するか検討する際は、以下の点を考慮するとよいかと思います。

  • デザイン要件とカスタマイズ性
    ブランド固有のデザインやアニメーションを多用する場合、shadcnのコードは自由に書き換えられるので適しています。逆に、管理画面など標準的なUIだけで十分なら、既存のUIフレームワークを使ったほうが楽かもしれません。

  • Tailwindの経験
    チームにTailwind CSSの経験者がいるかどうか。使いこなせればshadcnの柔軟性は大きな武器になりますが、慣れていないとスタイルの編集に手間取るかもしれません。またVuetifyなど直感的に記載できるライブラリと異なり、コンポーネントを理解しないとカスタマイズに失敗するため、ある程度のリテラシー(知識,経験)が必要かなとも思いました。(バックエンドエンジニアの僕でも大丈夫なのでだいたいの方がいけるっ)

  • 互換性・依存関係
    RadixやTailwindの更新に追随できるかどうか。手動で追いかける必要があります。Nuxt UIのようにチームが公式メンテするライブラリとは異なり、コミュニティベースの管理である点も理解してお区必要があります

shadcnをデザインシステムに適応させる

sahdcnのボタンをカスタマイズする例を紹介します。

まず、自社デザインで使う色やサイズなどをデザイントークンとして定義します。デザイントークンは「色やタイポグラフィ、スペーシングなどデザイン上のキーとなる値」を表し、デザインシステムの中核をなします。Tailwind ではこれらを tailwind.config.js の theme.extend 配下で定義します。例えば、自社ブランドカラーとステータス用の色を追加するには次のようにします。
tailwind.config.js

export default {
  theme: {
    extend: {
      colors: {
        'brand-primary': '#1E40AF',    // 会社のプライマリカラー
        'brand-secondary': '#9333EA',  // セカンダリカラー
        'success': '#10B981',          // 成功・確認用グリーン
        'error': '#EF4444',            // エラー用レッド
        // 必要に応じて他のトークンを追加
      },
    },
  },
}

これで bg-brand-primary や text-success といったユーティリティクラスを使えるようになります。Tailwind はもともとカスタマイズ性を重視して設計されており、デザイントークンの追加など各種カスタマイズを容易に行えます。上記例のようにカラーを登録しておけば、ボタンの背景色や文字色も自社トークンに合わせて変更できます。

ButtonVariants の拡張(スタイルの調整)

// components/ui/button.ts
import { cva } from 'class-variance-authority';

export const buttonVariants = cva(
  // ベーススタイル(全てのボタンに共通)
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
  "focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
  {
    variants: {
      variant: {
        primary:   "bg-brand-primary text-white hover:bg-brand-primary/90",
        secondary: "bg-brand-secondary text-white hover:bg-brand-secondary/90",
        destructive:"bg-error text-white hover:bg-error/90",
        outline:   "border border-gray-300 text-gray-700 hover:bg-gray-100",
        // 後述の success をここで追加
      },
      size: {
        default: "h-10 px-4",
        sm:      "h-8 px-3 text-sm",
        lg:      "h-12 px-6 text-lg",
      },
    },
    // 省略時に適用されるデフォルト variant
    defaultVariants: {
      variant: "primary",
      size:    "default",
    },
  }
);

上記コードでは bg-brand-primary や bg-error など、先に定義したカラーを使用しています。buttonVariants の第一引数は全ボタンに共通のクラス群、第二引数で variants(ここでは variant と size)を定義しています。これにより、例えば <Button variant="secondary"> と指定すると bg-brand-secondary が適用されます。
逆にshadcnのクラス名をもとにtailwind.configのデザイントークンの命名をするのも良い方法かもしれません。その方がshadcnのカスタマイズ箇所が減るので、サンプルとのdiff確認するのも簡単になるかと思います。

新しい Variant の追加例:Success
自社デザインで新たに必要になったスタイルがあれば、buttonVariants の variants に追加できます。ここでは例「success(サクセス)」を追加してみます。
success: 成功を示す緑背景のボタン。

先ほどの buttonVariants に次のようなエントリを追加します。

export const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
  "focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
  {
    variants: {
      variant: {
        primary:    "bg-brand-primary text-white hover:bg-brand-primary/90",
        secondary:  "bg-brand-secondary text-white hover:bg-brand-secondary/90",
        destructive:"bg-error text-white hover:bg-error/90",
        outline:    "border border-gray-300 text-gray-700 hover:bg-gray-100",
        success:    "bg-success text-white hover:bg-success/90", // 追加
      },
      size: { /* 省略 */ },
    },
    defaultVariants: { /* 省略 */ },
  }
);

<Button variant="success"> で緑背景に白文字のボタンが得られます。必要に応じて他のステータスカラー(例: warning, info など)も同様に追加できます。

広告

励みになるのでXのフォローやzennのフォロー、いいねしてくれると嬉しいです😄
https://x.com/teitarakuna

Discussion