フロントエンドのスタイリング技術は、かなりの頻度で入れ替わってきました。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フレームワークが変わっても困らない境界を作っておく。長く運用するプロダクトなら、検討する価値のある選択肢だと思います。
参考記事
国内
- KEPPLE「Next.jsのアップデートに伴い、Chakra UIからTailwind CSSへ移行しました」
- Chakra UI / EmotionからTailwind CSS + Radix UIへ移行した事例。60以上のコンポーネントと17ページを約2〜3か月で移行している。移行後に
classNameを自由に渡さず、限定されたPropsでスタイルを制御している点も参考になる。
- Chakra UI / EmotionからTailwind CSS + Radix UIへ移行した事例。60以上のコンポーネントと17ページを約2〜3か月で移行している。移行後に
- レバテック「業務アプリのフロントエンド負債と向き合い、Tailwind CSSからPanda CSSへの移行を決めた話」
- Tailwind CSSを採用していたNext.js + TypeScriptの業務アプリで、プロジェクトの成長後にPanda CSSへの移行を決めた事例。
海外
- Airbnb Engineering「Airbnb's Trip to Linaria」
- Aphroditeに直接依存せず
react-with-stylesという抽象レイヤーを用意し、後に実際にLinariaへ移行した事例。この記事の設計思想にかなり近い。
- Aphroditeに直接依存せず
- Atlassian「RFC-73: Migrating our components to Compiled CSS-in-JS」
- Atlassian Design Systemを含むAtlaskitのコンポーネントを、EmotionからCompiled CSS-in-JSへ移行している事例。