僕はアプリケーションのコードから、特定のCSSフレームワークを直接使わないようにしている。FeatureやPageにTailwind CSSのclassNameは書かない。間に自前の型付きUIコンポーネントを1枚挟み、CSSフレームワークを使うのはその内側だけに限る。

依存の向きは一本道だ。Applicationから自前のUIコンポーネントへ、そこからCSSフレームワークやUIライブラリへと流れる。アプリケーションから見れば、CSSフレームワークはただの実装詳細に過ぎないわけだ。

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

どれが正解かを論じたいわけではない。その時点で生産性が高く、プロジェクトに合う技術を選べばよい。ただ、波が来るたびにアプリケーション全体が水浸しになるのは御免蒙りたい。以下、自前のUIコンポーネントという防波堤をなぜ築いているのか、そして実際に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も、今とても優れた選択肢だ。問うべきはなにを採用するかよりも、その技術への依存がアプリケーションのどこまで広がるか、なのである。

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を名乗りはじめるのだ。

意味だけを知るFeature

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

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" という意味だけだ。 Material UIやChakra UIを触ったことがある人なら、見覚えのある書き方だろう。あちらも <Button variant="contained"> のように意味を渡すだけで、内側でどんなスタイルが当たっているかは気にしなくていい。要するに、あれと同じ仕組みを自前で作るのだ。中身がTailwindになっただけである。 この構造のありがたいところは、UIのルールをTypeScriptで縛れることにある。<Button variant="primary" /> は通るが、<Button variant="purple" /> はコンパイル時にエラーになる。Design Systemに存在しない表現は、そもそも書けないのだ。 翻ってTailwindを直接使う場合、bg-blue-500 も bg-sky-500 も bg-indigo-500 も bg-[#4287f5] も、技術的にはどれも正しいコードである。けれどもDesign Systemとして許したい色は、1つだけかもしれない。それを variant="primary" というインターフェースに閉じ込める。そうすることで、「CSSとして書けること」と「プロダクトとして書いてよいこと」を切り分けられる。

Tailwindの居場所

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を1つも置かない。

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

レイアウトと自作Tailwindの罠

頻繁に出てくるレイアウトにも、同じ考え方を当てはめられる。<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] のような値がアプリケーション中に繁殖していくのを防げる。もっとも、ここには落とし穴がある。調子に乗ってCSSプロパティを片っ端からPropsにすると、次のような代物が生まれる。
<Box
  display="flex"
  padding="4"
  marginTop="2"
  background="blue500"
  borderRadius="md"
/>

これはTailwindを隠しただけの、巨大な自作CSSフレームワークに過ぎない。抽象化した意味は、ほとんどなくなってしまう。作りたいのは、プロダクトのDesign Systemとして意味のあるAPIだ。

<Button variant="primary" />、<Text tone="muted" />、<Alert severity="warning" />、<Stack gap="md" /> がその典型である。公開するのはCSSの書き方ではなく、UIとしての意味なのだ。

テストも1カ所に集まる

この設計では、スタイルの実装が汎用UIコンポーネントに集まる。だからスタイルのテストも1カ所に置ける。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のStoryを用意しておく。primary、secondary、danger、disabled、small、largeの各状態である。それをPlaywrightやChromaticでスクリーンショット比較すれば、内部実装が変わっても見た目が同じであることを確かめられる。

理想を言えば、TypeScriptがUI APIの契約を、Unit Testが振る舞いの契約を、Visual Regression Testが見た目の契約を受け持つ。そういう3層の分担になる。

Panda CSSへの差し替え

この設計のありがたみは、実際に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> のままである。Feature、自前のUIコンポーネント、Tailwind CSSという3段のうち、入れ替わったのは一番下だけだ。Tailwind CSSがPanda CSSになった、ただそれだけである。

Panda CSSに限った話ではない。CSS Modulesでもstyled-componentsでも、なんならMaterial UIでも、差し替わるのはButtonの内側だけで済む。

Feature変更 0行
注文画面
保存
設定画面
保存
一覧画面
保存
自前の Buttonprimary / secondary / danger
Tailwind CSS
CSSフレームワーク
差し替え 0回