フロントエンドのスタイリング技術は、かなりの頻度で入れ替わってきました。Bootstrapが広く使われた時代があり、その後CSS ModulesやCSS-in-JSが広まってstyled-componentsやEmotionが使われ、今はTailwind CSSのようなUtility First CSSが一般的です。最近は、型安全性やビルド時のCSS生成を重視するPanda CSSのような選択肢も出てきました。

どれが正解という話をしたいわけではありません。その時点で生産性が高く、プロジェクトに合う技術を選べばよいと思っています。

そのうえで私は、アプリケーションコードから特定のCSSフレームワークを直接使わないようにしています。FeatureやPageからTailwind CSSのclassNameを書くのをやめ、自前のUIコンポーネントを一枚挟み、その内部だけでCSSフレームワークを使います。依存関係は次の形です。

Application
    ↓
自前の型付きUI Component
    ↓
CSS Framework / UI Library

アプリケーションから見れば、CSSフレームワークは実装詳細になります。この記事では、なぜこの構造にしているのか、実際にCSSフレームワークを差し替えると何が変わるのかを、コードを交えて整理します。

スタイリングの主流は、10年後も同じとは限らない

2010年代前半にBootstrapが広く使われて以降も、Foundation、Semantic UI、Bulma、Material UI、CSS Modules、styled-components、Emotion、Chakra UI、Tailwind CSS、Panda CSSと、選択肢は次々に出てきました。

これらは同じカテゴリの技術ではありません。CSSフレームワークもあれば、CSS-in-JSも、Utility First CSSも、React向けのUIライブラリもあります。ただ、どれを見ても、登場した時点の主流が10年後もそのまま主流でいる保証はありませんでした。

BootstrapもEmotionも、悪い技術だったわけではありません。Tailwind CSSも、今とても優れた選択肢だと思います。問うべきなのは何を採用するかより、その技術への依存がアプリケーションのどこまで広がるかです。

FeatureでTailwindを直接書くと、TailwindのAPIがDesign Systemになる

たとえばTailwind CSSでボタンを実装するとします。ごく一般的なコードです。

OrderPage.tsx
export function OrderPage() {
  return (
    <button
      className="
        rounded-md
        bg-blue-600
        px-4
        py-2
        text-sm
        font-medium
        text-white
        hover:bg-blue-700
        disabled:opacity-50
      "
    >
      注文を確定する
    </button>
  )
}

小規模なアプリケーションならこれで十分です。Tailwindの良さをそのまま使えて、実装も速い。

ところがプロダクトが大きくなると、別の画面にはこう書かれ、

<button className="rounded bg-blue-500 px-3 py-2 text-white">
  保存
</button>

さらに別の画面ではこうなったりします。

<button className="rounded-lg bg-sky-600 px-4 py-2 text-sm text-white">
  登録
</button>
どれも技術的には正しく、Tailwindも問題なく動きます。しかし blue-500 と blue-600 のどちらを使うのか、rounded か rounded-md か、px-3 か px-4 か、text-sm か text-base か、といったルールがコードベース全体に散らばっていきます。この状態では、TailwindのAPIそのものがアプリケーションのDesign Systemになっています。

UIコンポーネントを境界にすると、Featureは意味だけを知ればよくなる

そこで、FeatureからCSSフレームワークを直接使わず、次のようなButtonを用意します。

Button.tsx
type ButtonVariant =
  | "primary"
  | "secondary"
  | "danger"

type ButtonSize =
  | "sm"
  | "md"
  | "lg"

type ButtonProps = {
  children: React.ReactNode
  variant?: ButtonVariant
  size?: ButtonSize
  disabled?: boolean
  onClick?: () => void
}

export function Button({
  children,
  variant = "primary",
  size = "md",
  disabled,
  onClick,
}: ButtonProps) {
  // Tailwindによる実装
}

Feature側はこうなります。

export function OrderPage() {
  return (
    <Button variant="primary">
      注文を確定する
    </Button>
  )
}
Feature側は bg-blue-600 や px-4、rounded-md、hover:bg-blue-700 を知りません。知っているのは variant="primary" という意味だけです。

TypeScriptの型がDesign Systemのルールになる

この構造の大きな利点は、UIのルールをTypeScriptで縛れることです。<Button variant="primary" /> は通りますが、<Button variant="purple" /> はコンパイル時にエラーになります。Design Systemに存在しない表現は、そもそも書けません。 Tailwindを直接使う場合は、bg-blue-500 も bg-sky-500 も bg-indigo-500 も bg-[#4287f5] も、技術的には正しいコードです。でもDesign Systemとして許したい色は一つだけかもしれない。それを variant="primary" というインターフェースに閉じ込めることで、「CSSとして書けること」と「プロダクトとして書いてよいこと」を分けられます。

Tailwindを使うのはUIコンポーネントの内部だけにする

Buttonの内部は、たとえば次のように実装します。

const variantClasses = {
  primary:
    "bg-blue-600 text-white hover:bg-blue-700",

  secondary:
    "bg-gray-100 text-gray-900 hover:bg-gray-200",

  danger:
    "bg-red-600 text-white hover:bg-red-700",
}

const sizeClasses = {
  sm: "h-8 px-3 text-sm",
  md: "h-10 px-4 text-sm",
  lg: "h-12 px-6 text-base",
}

export function Button({
  children,
  variant = "primary",
  size = "md",
  disabled,
  onClick,
}: ButtonProps) {
  return (
    <button
      disabled={disabled}
      onClick={onClick}
      className={`
        inline-flex
        items-center
        justify-center
        rounded-md
        font-medium
        transition-colors
        disabled:pointer-events-none
        disabled:opacity-50
        ${variantClasses[variant]}
        ${sizeClasses[size]}
      `}
    >
      {children}
    </button>
  )
}

Tailwindを使っていること自体に意味はありません。大事なのは、Tailwindを使う場所がここに限られていることです。Feature側にはTailwindのclassNameを置きません。

classNameをPublic APIにしない

もう一つ守りたいのが、<Button className="bg-purple-500 px-12"> のような書き方を許さないことです。 UIコンポーネントを作っても、className を自由に渡せるようにすると境界が崩れます。<Button className="bg-green-500"> や <Button className="rounded-none">、<Button className="px-[37px]"> といったコードが、またFeature側に広がっていくからです。 そこで、必要な変更だけを <Button size="sm" /> や <Button variant="danger" /> のような明示的なPropsとして公開します。 実際のプロダクトでも採られている考え方です。KEPPLEはChakra UI / EmotionからTailwind CSS + Radix UIへ移行した後、スタイリングが無秩序にならないよう、className をそのまま渡すのではなく size など限られたPropsだけを公開する方針にしています(移行の記事)。

レイアウトにも同じ考え方を当てはめられる

頻繁に出てくるレイアウトも同じです。<div className="flex flex-col gap-4"> をあちこちに書く代わりに、<Stack gap="md"> を用意します。
type StackGap =
  | "sm"
  | "md"
  | "lg"

type StackProps = {
  children: React.ReactNode
  gap?: StackGap
}

export function Stack({
  children,
  gap = "md",
}: StackProps) {
  const gaps = {
    sm: "gap-2",
    md: "gap-4",
    lg: "gap-8",
  }

  return (
    <div className={`flex flex-col ${gaps[gap]}`}>
      {children}
    </div>
  )
}

Feature側は次のように書くだけです。

<Stack gap="md">
  <Heading>配送情報</Heading>
  <Text>配送先を確認してください</Text>
  <Button>確認する</Button>
</Stack>
これで gap-3、gap-4、gap-[14px] のような値がアプリケーション中に増えていくのを防げます。

自作Tailwindにはしない

ここは注意が必要です。

<Box
  display="flex"
  padding="4"
  marginTop="2"
  background="blue500"
  borderRadius="md"
/>

のようにCSSプロパティを片っ端からPropsにすると、Tailwindを隠しただけの巨大な自作CSSフレームワークができあがります。これでは抽象化した意味が薄れます。

作りたいのは、プロダクトのDesign Systemとして意味のあるAPIです。<Button variant="primary" />、<Text tone="muted" />、<Alert severity="warning" />、<Stack gap="md" /> のように、公開するのはCSSの書き方でなく、UIとしての意味です。

スタイルのテストも一箇所に集まる

この設計ではスタイルの実装が汎用UIコンポーネントに集まるので、スタイルのテストも一箇所に置けます。Buttonなら次のように書けます。

describe("Button", () => {
  it("primary variant", () => {
    render(
      <Button variant="primary">
        Save
      </Button>
    )

    const button = screen.getByRole("button")

    expect(button).toHaveClass("bg-blue-600")
    expect(button).toHaveClass("text-white")
  })
})

ただし、このテスト自体はTailwindに依存しています。Tailwindから別の実装に変えれば、テストも書き換えることになります。

そこで、もう一段上ではVisual Regression Testを使います。StorybookにButtonのprimary・secondary・danger・disabled・small・largeのStoryを用意しておき、PlaywrightやChromaticでスクリーンショットを比較すれば、内部実装が変わっても見た目が同じことを確かめられます。

理想的には、契約が3層に分かれます。

TypeScript             → UI APIの契約
Unit Test              → 振る舞いの契約
Visual Regression Test → 見た目の契約

TailwindからPanda CSSに差し替えても、Featureのコードは変わらない

この設計のありがたみは、実際にCSSフレームワークを入れ替えてみるとよく分かります。

Feature側のコードは次のとおりで、これは一切変更しません。

<Button
  variant="primary"
  size="md"
>
  保存
</Button>

変えるのはButtonの内部だけです。Panda CSSに移行する場合、概念的には次のような実装になります。

import { css } from "../../styled-system/css"

const variantStyles = {
  primary: css({
    backgroundColor: "blue.600",
    color: "white",
    _hover: {
      backgroundColor: "blue.700",
    },
  }),

  secondary: css({
    backgroundColor: "gray.100",
    color: "gray.900",
    _hover: {
      backgroundColor: "gray.200",
    },
  }),

  danger: css({
    backgroundColor: "red.600",
    color: "white",
    _hover: {
      backgroundColor: "red.700",
    },
  }),
}

const sizeStyles = {
  sm: css({
    height: "8",
    paddingX: "3",
    fontSize: "sm",
  }),

  md: css({
    height: "10",
    paddingX: "4",
    fontSize: "sm",
  }),

  lg: css({
    height: "12",
    paddingX: "6",
    fontSize: "md",
  }),
}

export function Button({
  children,
  variant = "primary",
  size = "md",
  disabled,
  onClick,
}: ButtonProps) {
  return (
    <button
      disabled={disabled}
      onClick={onClick}
      className={`
        ${css({
          display: "inline-flex",
          alignItems: "center",
          justifyContent: "center",
          borderRadius: "md",
          fontWeight: "medium",
        })}
        ${variantStyles[variant]}
        ${sizeStyles[size]}
      `}
    >
      {children}
    </button>
  )
}
内部実装はかなり変わり、bg-blue-600 も px-4 も rounded-md もなくなりました。それでもFeature側は <Button variant="primary">保存</Button> のままです。
Before: Feature → 自前UI Component → Tailwind CSS
After:  Feature → 自前UI Component → Panda CSS

変わったのは一番下の層だけです。CSSフレームワークの変更をFeatureまで伝播させない。これがこの設計の目的です。

実際にスタイリング基盤を移行した事例

仮定の話だけではありません。国内外で、スタイリング基盤を途中で入れ替えた事例があります。

レバテック:TailwindからPanda CSSへ

レバテックは、Next.js + TypeScriptで作られた業務アプリで、Tailwind CSSからPanda CSSへの移行を決めています。プロジェクト開始から約1年半、コードベースが200ファイルを超えたころに、スタイルのルールが曖昧なことや画面ごとのばらつきが課題になり、スタイリング基盤を見直したそうです(記事)。

これは「Tailwindが悪かった」という話ではありません。プロジェクトの規模や要件が変われば、最適な選択肢も変わる。だからこそ、どのCSSフレームワークを選ぶかと同じくらい、どこまで依存させるかを考えておく価値があります。

Airbnb:抽象レイヤーが後から効いた

海外で特に興味深いのがAirbnbです。AirbnbはDesign SystemをReactへ移行する過程でCSS-in-JSを採用し、Aphroditeを使っていました。ただしAphroditeに直接は依存していません。当時CSS-in-JSはまだ新しい領域で、将来別の実装に切り替えられるよう、react-with-styles という抽象レイヤーを作っています。
Component
    ↓
自前のStyling Interface(react-with-styles)
    ↓
Aphrodite

そして後年、Airbnbは実際にLinariaへ移行しました(Airbnb's Trip to Linaria)。「いつか変わるかもしれない」と思って作った抽象化が、本当に後から役に立った例です。スクリーンショットテストでスタイル変更によるVisual Regressionを検知していた点も、この記事の構成と重なります。

Atlassian:EmotionからCompiled CSS-in-JSへ

AtlassianもDesign Systemのスタイリング基盤を変えています。Atlassian Design Systemを含むAtlaskitでは、Emotionを使っていたコンポーネントをCompiled CSS-in-JSへ移行しています。理由として挙がっているのは、ランタイムのパフォーマンス、JavaScriptのバンドルサイズ、React 18のconcurrent rendering、streaming SSRへの対応です(RFC-73)。

一度選んだスタイリング手法は、大きな組織でも固定されたままではありません。

AIのおかげで、境界を作るコストが下がった

以前なら「Buttonを一つ作るだけなのに、そこまで抽象化する必要があるのか」という反論にはかなり説得力がありました。コンポーネントを作り、Propsを設計し、型とvariantを定義し、テストとStorybookまで用意すると、それなりの作業量になります。小さなサービスならTailwindを直接書いたほうが明らかに速い場面も多かったと思います。

AIによるコーディングが当たり前になって、このコスト構造は少し変わりました。コンポーネントの雛形、variantの追加、型定義、テスト、Story、リファクタリングといった定型作業の多くは、AIに任せられます。Design Systemに境界を作るための実装コストは、以前よりずっと小さくなりました。

AIに書かせるなら、なおさら制約が効く

AIはコードを大量に生成できますが、自由度が高いと、微妙な差異も大量に生成します。ある画面では gap-3、別の画面では gap-4、さらに別の画面では gap-[14px]。あるいは text-gray-500 と text-slate-500 と text-zinc-500 が混ざる。どれも単体では間違いではないので、レビューでも見過ごされがちです。 AIに「いい感じのTailwindを書いて」と頼むより、<Stack gap="md">、<Text tone="muted">、<Button variant="primary"> という限られたインターフェースだけを使わせたほうが、一貫性を保ちやすくなります。Design Systemは人間のためのルールであると同時に、AIに対する制約としても働くようになりました。

依存をなくすのではなく、依存する場所を限定する

念のため書いておくと、これは「Tailwindを使うべきではない」という話ではありません。Tailwindは使いますし、Panda CSSが合っていればPanda CSSを使います。UIライブラリも必要なら使います。CSSフレームワークに依存していることは変わらず、その依存をUI層だけに閉じ込めているだけです。

この設計で私が一番大事だと思っているのは、Design SystemのPublic APIをCSSフレームワークに決めてもらわないことです。bg-blue-600 をPublic APIにせず、<Button variant="primary"> をPublic APIにする。primary が何色で、paddingはいくつで、border-radiusはどれくらいか、内部がTailwindなのかPanda CSSなのかは、UIコンポーネント内部の責務にします。そうすれば、Design Systemそのものを自分たちで所有できます。

CSS周りの技術はこれからも変わるはずです。Bootstrapの時代からCSS-in-JS、Utility First CSSへと移ってきたように、数年後には別のアプローチが主流になっているかもしれません。新しい技術を避ける必要はなく、そのメリットは積極的に使えばいい。ただ、その技術をアプリケーション全体のPublic APIにはしない。Featureは自分たちの型付きUI APIだけを知り、CSSフレームワークはその裏側の実装詳細として扱い、必要になれば交換する。

以前は少し重かったこの設計も、AIで定型作業のコストが下がったことで、かなり現実的になりました。どのCSSフレームワークが10年後も残っているかを予想するより、CSSフレームワークが変わっても困らない境界を作っておく。長く運用するプロダクトなら、検討する価値のある選択肢だと思います。

参考記事

国内

海外

  • Airbnb Engineering「Airbnb's Trip to Linaria」
    • Aphroditeに直接依存せず react-with-styles という抽象レイヤーを用意し、後に実際にLinariaへ移行した事例。この記事の設計思想にかなり近い。
  • Atlassian「RFC-73: Migrating our components to Compiled CSS-in-JS」
    • Atlassian Design Systemを含むAtlaskitのコンポーネントを、EmotionからCompiled CSS-in-JSへ移行している事例。