僕はアプリケーションのコードから、特定の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の内側だけで済む。