🏖️

「shadcn/ui × 自社デザインシステム」実践ガイド──FigmaトークンからTailwindまでの連携術

に公開

PeopleX社でフロントエンドエンジニアをしている芹澤(せりざわ)です!社内では「せりせり」や「せりちゃん」と呼ばれています。みんなとランチに行くのが趣味です!

私がフロントエンド開発を担当している 「PeopleX AI面接」 というプロダクトでは、shadcn/ui をベースにしたデザインシステムを構築しています。今回この記事では、なぜshadcn/ui を採用しているかに加え、それをどのようにカスタマイズしながら運用しているかを、実践例を交えながらご紹介できればと思います。


そもそも shadcn/ui とは?

shadcn/ui は、Radix UI のプリミティブに Tailwind CSS を組み合わせて構築された React コンポーネントライブラリです。

最大の特徴は、npm パッケージとしてではなく CLI 経由でコードを直接プロジェクトに取り込むスタイルである点です。
これによりスタイルや挙動をプロジェクト内で自由に編集でき、柔軟なカスタマイズが可能になります。

特に向いているユースケース:

  • 自社デザインシステムに合わせて頻繁にスタイルを調整する必要がある場合
  • デザインと実装の乖離を減らしたいチーム
  • Radix UI のアクセシビリティを維持しつつ、独自 UI を実装したい場面

なぜ、shadcn を使うのか?

PeopleX AI面接のような SaaS プロダクトでは、Figma で設計された ブランドカラー・余白・タイポグラフィなどを厳密に反映する 必要があります。

例えば、shadcn/ui に標準で含まれるボタンコンポーネントは次のようなスタイルです:

shadcnデフォルトのボタン

このままではプロダクトのブランドとは合わないため、独自のカラーや角丸、シャドウなどを反映させる必要があります。

shadcn/ui は スタイルとロジックが分離されているため、アクセシビリティやキーボード操作などのベースを保ちつつ、見た目だけを柔軟に変更できる 点が大きな強みです。


生成AIとの相性の良さ

もうひとつの魅力として、生成AIとの高い親和性があります。

たとえば v0 を使えば、「ユーザー登録フォームを作って」といった簡単なプロンプトだけで、shadcn/ui をベースにしたUIコードを自動生成することができます。

わたしたちのチームでは、デザイナーが v0.dev を活用して、動くプロトタイプを素早く生成し、それをBiz、エンジニアが実際に触りながら、
仕様の理解や設計上の課題の洗い出しを行うといった形で活用しています。

このように、v0.dev は単なるコード生成ツールというよりも、
デザイナーとエンジニア、そしてBizの認識を揃えるための共通言語として有効に機能しています。

今後、プロトタイピングや仕様検討の初期段階で、さらに活用の幅が広がっていくことを期待しています。


Figma のデザイントークンを Tailwind クラスにマッピングする

わたしたちのチームでは、Figma で設計されたデザインを厳密に再現するために、次のようなフローでトークンを管理しています。

フロー概要:

  1. Figma 上のデザイントークンを Design Tokens Manager プラグインで JSON にエクスポート
  2. Style Dictionary を使って Tailwind で扱いやすい形式に変換
  3. 変換された JSON を Tailwind Config に取り込む

💡 Tailwind v3 を使用しています。v4 にアップデートすれば、Tailwind Config を経由せずに CSS 変数から直接カスタムクラスを定義できるようになります!
(記事に合わせてアップデートしようかと思いましたが…今回は断念。次回のテックブログネタにします。)


1. Figma上のデザイントークンをエクスポート

以下は、わたしたちのチームでFigma上に定義されたデザイントークン(変数群)の一部です:

これらのFigma上に定義された一連のトークンはFigmaのDesign Tokens Managerなどのプラグインによって以下のようなJSON形式で複数ファイルに分けて出力できます。

semantic.tokens.json

"semantic": {
  "text": {
    "l-main": {
      "$type": "color",
      "$value": "{token.gray.1000}"
    },
    ...
  }
}

colors.tokens.json

{
  "token": {
    "gray": {
      "1000": {
        "$type": "color",
        "$value": "#1a1a1a"
      },
      ...
    }
  }
}

semantic.tokens.json のように、値が {token.gray.1000} のような参照形式になっていることに注意が必要です。tailwind.configでは、参照を解決した値しか使えないので、このJSONはそのままでは使用できません。

2. Style Dictionaryで参照を解決・変換

Style Dictionary は、参照を解決しつつ、さまざまな形式でトークンを出力できます。

上記の例で言うと、semantic.tokens.json内のtoken.gray.1000#1a1a1aのような変数に生の値を差し替え、新たなファイルを生成してくれます。

ここでは主に以下3つの成果物を出力対象としています。

references.json: 参照解決済みのJSON

{
  "semantic": {
    "text": {
      "l-main": {
        "value": "#1a1a1a"
      }
    }
  }
}

variables.css: CSSカスタムプロパティ

:root {
  --token-gray-1000: #1a1a1a;
  ...
  --typography-h1-leading-default: 400 31px/155% 'Noto Sans JP';
  ...
}

これにより、後述のtypography.jsonなどでvar(--typography-h1-leading-default)のような動的な値を参照できるようになります。

typography.json: タイポグラフィのユーティリティ

タイポグラフィ関連のスタイル(fontSize, fontWeight, lineHeight, letterSpacing など)は Tailwind CSS ではそれぞれ別々のユーティリティクラスで管理されるため、一貫したスタイルの適用に手間がかかるケースがあります。

そこでわたしたちのチームでは、Figma上で定義されたタイポグラフィトークンをもとに、特定の組み合わせをあらかじめクラス化したCSSユーティリティのJSONとして出力しています。

{
  ".typography-h1-leading-default": {
    "font": "var(--typography-h1-leading-default)",
    "letter-spacing": "0px"
  }
}

<Text className="typography-h1-leading-default"> のように意味ベースのスタイルが一括適用できます。

3. Tailwind に取り込む

const tokens = require('./style-dictionary/__generated__/references.json');
const typographyUtils = require('./style-dictionary/__generated__/typography.json');

module.exports = {
  theme: {
    textColor: {
      ...tokens.semantic.text
    },
    backgroundColor: {
      ...tokens.semantic.bg
    },
    borderRadius: {
      ...tokens.radius
    },
    ...
  },
  plugins: [
    plugin(({ addUtilities }) => {
      addUtilities(typographyUtils);
    }),
  ],
}

最終的なディレクトリ構成

libs/
├── style-dictionary/
│   ├── tokens/
│   │   ├── FigmaからexportしたJSONファイル群(semantic.tokens.json、color.tokens.jsonなど)
│   ├── __generated__/
|       ├── references.json
│       ├── variables.css
│       └── typography.json
│   ├── style-dictionary.config.ts
|   ├── package.json
├── tailwind.config.ts

Figma上のデザイントークンが更新されたら再度、Design Tokens Managerプラグインを使いJSONファイル群を生成し、libs/style-dictionary/tokens配下に配置します。

以下のような style-dictionary.config.tsを用意することで、package.jsonに定義したコマンド一つで libs/style-dictionary/tokens配下のファイルを読み取り→__generated__配下のファイルを更新→カスタムTailwindクラスに反映、とするようにしています。

style-dictionary.config.ts

import StyleDictionary, { Config } from "style-dictionary";

const BUILD_PATH = "__generated__/";

/**
 * Configuration to process token files
 */
const config: Config = {
  source: ["tokens/**/*.json"],
  platforms: {
    css: {
      ...
    },
    references: {
      ...
    },
    typography: {
      ...
    },
  },
};

// Build all the platforms
const sd = new StyleDictionary(config);
sd.buildAllPlatforms();

export default config;

package.json

{
  ...
  "scripts": {
    "build": "tsx style-dictionary.config.ts",
  },
  ...
}

あとは、生成されたTailwindクラスをFigmaの指定に従い、各コンポーネントに当てはめていくだけです。

最終的なアウトプット

以下がわたしたちのチームでshadcn/uiをベースに作成したbuttonコンポーネントです。

import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'

import { cn } from '@/lib/utils'

const buttonVariants = cva(
  ...,
  {
    variants: {
      variant: {
        primary:
          'bg-purple-800 text-d-main hover:bg-purple-900 focus-visible:bg-purple-950 focus-visible:ring-purple-800 active:bg-purple-800 active:ring-purple-800',
        secondary:
          'border border-purple-700 bg-main text-primary hover:bg-purple-25 focus-visible:bg-purple-50 focus-visible:ring-purple-700  active:ring-purple-700',
        tertiary:
          'text-primary underline hover:bg-purple-25 focus:bg-purple-50 focus-visible:bg-purple-50 focus-visible:ring-purple-700  active:bg-transparent active:ring-purple-700',
        'danger-primary':
          'bg-red-600 text-d-main hover:bg-red-800 focus-visible:bg-red-900 focus-visible:ring-red-600 active:bg-red-600 active:ring-red-600',
        'danger-secondary':
          'border border-attention bg-main text-attention hover:bg-red-25 focus-visible:bg-red-50 focus-visible:ring-red-600 active:ring-red-600',
        'danger-tertiary':
          'text-attention underline hover:bg-red-25 focus:bg-red-50 focus-visible:bg-red-50 focus-visible:ring-red-600 active:bg-transparent active:ring-red-600',
      },
      size: {
        ...
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  },
)

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) => {
    const Comp = asChild ? Slot : 'button'
    return (
      <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
    )
  },
)
Button.displayName = 'Button'

export { Button, buttonVariants }

ご覧の通り、bg-purple-800typography-button-leading-noneなどFigmaで定義した独自のデザイントークンを反映したコンポーネントになります。

元のshadcn/ui のボタンコンポーネントではvariantが 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'となっているため、variant周りもかなりカスタマイズされたコンポーネントとなっていることがわかります。

この辺りは実装問題というより、デザインガイドライン自体をどの程度shadcnで採用されているものに寄せるか、という別の論点になる箇所ですが、わたしたちのチームでは使用してないvariantはガンガン削除していくスタイルで開発を進めています。

最後に

この記事が、shadcn/ui のカスタマイズや、Figma からのトークン管理を検討している方の参考になれば幸いです。

もっとこうした方がいいんじゃないか???みたいなアイデアもあればコメントで教えていただけますと嬉しいです!

最後までお読みいただき、ありがとうございました!!

PeopleXテックブログ

Discussion