Tailwind は「ただの CSS」と言われる。では
bg-blue-500 text-white px-6 py-3 rounded-lg font-bold shadow-mdは 実際にどの CSS に変換されているのか? Tailwind クラス文字列を貼ると素の CSS に変換 + live preview する 500 行 vanilla JS ツールを書いた。実装してみると、ドキュメントを読むより Tailwind の設計が腹落ちした。
🌐 Demo: https://sen.ltd/portfolio/tw-to-css/
📦 GitHub: https://github.com/sen-ltd/tw-to-css
なぜ自作するか
理由 2 つ:
- Tailwind チュートリアルの大半は「変換後の CSS」を見せない。読者はクラス名のパターンマッチで覚えていて、各クラスが何を する のかは曖昧。input/output を並べて見せると map が可視化される。
-
自分で実装すると Tailwind の設計が最速で頭に入る。
bg-{color}-{shade}を 1 ハンドラとして書いた瞬間、全ユーティリティが同じ shape の繰り返しであることが見える。
このツールは Tailwind の再実装ではない。デフォルトテーマのうちチュートリアルや quickstart で使われる ~100 ユーティリティに絞っている。目的は「マッピングを説明する」こと。
アーキテクチャ
tailwind-data.js ← ルックアップテーブル: カラーパレット、スペーシング、フォントサイズ、…
parser.js ← トークナイザ + ハンドラ配列(DOM 非依存、Node でテスト可)
app.js ← UI グルー: 入力 → parser → live preview + CSS 出力
parser.js の parse(input) は { rules, unrecognised } を返す。パイプライン全体が pure なので、node --test で 43 件のケースをブラウザなしで走らせられる。
ハンドラ配列 — ユーティリティ追加 = 関数追加
全ユーティリティが HANDLERS 配列の 1 エントリ。パーサは順に試して、最初に non-null を返したハンドラの結果を採用する。
const HANDLERS = [
// 完全一致ユーティリティ
layoutHandler("flex", [["display", "flex"]]),
layoutHandler("flex-col", [["flex-direction", "column"]]),
layoutHandler("hidden", [["display", "none"]]),
// プレフィックス + コールバック
prefixHandler("bg-", (v) => {
const hex = resolveColor(v);
return hex ? [["background-color", hex]] : null;
}),
prefixHandler("text-", (v) => {
if (v in FONT_SIZE) { const [s, l] = FONT_SIZE[v]; return [["font-size", s], ["line-height", l]]; }
if (["left","center","right","justify"].includes(v)) return [["text-align", v]];
const hex = resolveColor(v);
return hex ? [["color", hex]] : null;
}),
// スペーシング系はヘルパで共通化
spacingHandler("p", "padding"),
spacingHandler("px", ["padding-left", "padding-right"]),
spacingHandler("py", ["padding-top", "padding-bottom"]),
spacingHandler("w", "width"),
spacingHandler("h", "height"),
// ...
];
この形が Tailwind が「一貫している」と感じる正体: ユーティリティ名 = プレフィックス + スケールキー、しかも同じ 0, 0.5, 1, 2, 4, 8, … のスケールが至る所で使われている。自分で書くとこの構造が体に染み込む。
クラス名のオーバーロード問題(text- 系)
text- は Tailwind で最もオーバーロードされたプレフィックス:
| クラス | 設定するもの |
|---|---|
text-sm |
font-size + line-height |
text-center |
text-align |
text-blue-500 |
color |
ナイーブなパーサは 1 つだけ拾って他を壊す。実際のハンドラは 順序付きディスパッチ:
prefixHandler("text-", (v) => {
// 1. フォントサイズスケール (sm, md, lg, xl, 2xl, ...)
if (v in FONT_SIZE) {
const [size, lh] = FONT_SIZE[v];
return [["font-size", size], ["line-height", lh]];
}
// 2. テキスト alignment キーワード
if (["left","center","right","justify"].includes(v)) {
return [["text-align", v]];
}
// 3. カラー解決にフォールスルー
const hex = resolveColor(v);
return hex ? [["color", hex]] : null;
});
順序は適当ではない — Tailwind 自身がほぼこの順で解釈するので、text-sm は仮想的に "sm" という名前のカラーがあっても勝つし、text-blue-500 は blue-500 が FONT_SIZE に無いので fall-through してカラーに落ちる。同じパターンが border- (カラー vs 太さ) ほか少数のクラスにも適用される。
スペーシングスケールは n * 0.25rem だけ
const SPACING_BASE = [
0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12,
14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
];
for (const n of SPACING_BASE) {
SPACING_MAP[String(n)] = n === 0 ? "0px" : `${n * 0.25}rem`;
}
SPACING_MAP["px"] = "1px";
SPACING_MAP["auto"] = "auto";
SPACING_MAP["full"] = "100%";
これがスペーシングシステムの全て。p-4 は padding: 1rem。mt-8 は margin-top: 2rem。gap-2 は gap: 0.5rem。Tailwind を使ってきて染みついた「8px グリッド感覚」の正体は 0.25rem × 2 = 0.5rem ≈ 8px (デフォルトの root font 16px 換算)。それだけ。
方向ショートカット (px, py, mx, my) は 1 行の一般化:
function spacingHandler(prefix, cssProp) {
return (cls) => {
if (!cls.startsWith(`${prefix}-`)) return null;
const v = cls.slice(prefix.length + 1);
if (!(v in SPACING_MAP)) return null;
const props = Array.isArray(cssProp) ? cssProp : [cssProp];
return props.map((p) => [p, SPACING_MAP[v]]);
};
}
// usage:
spacingHandler("px", ["padding-left", "padding-right"]);
「同じプロパティに対しては最後に書いたクラスが勝つ」
parse("bg-red-500 bg-blue-500")
// → { rules: [
// { class: "bg-red-500", declarations: [["background-color", "#ef4444"]] },
// { class: "bg-blue-500", declarations: [["background-color", "#3b82f6"]] },
// ] }
両方の rule がパースを生き残るが、toCSS() が単一ブロックに畳んで後勝ち:
export function toCSS(rules, selector = ".preview") {
const seen = new Map();
for (const r of rules) {
for (const [prop, value] of r.declarations) {
seen.set(prop, value); // 同じプロパティの先行 value を上書き
}
}
// ...
}
Tailwind の「同じプロパティに対しては最後に書いたクラスが勝つ」直感と一致する。本物の Tailwind は CSS の source ordering + JIT コンパイルでこれを実現しているが、静的なクラスリストに対しては Map.set を順に呼ぶだけで等価。
(本物の Tailwind には @layer で component と utility の優先順位を制御する仕組みがあるが、1 つのクラスリスト内ではこのルールに収束する。)
見えない部分をテストする
parser.js が pure なので各ユーティリティを単体テストできる:
test("text-blue-500", () => {
assert.deepEqual(classToDeclarations("text-blue-500"),
[["color", "#3b82f6"]]);
});
test("h-screen → 100vh", () => {
assert.deepEqual(classToDeclarations("h-screen"),
[["height", "100vh"]]);
});
test("w-screen → 100vw, NOT 100vh", () => {
assert.deepEqual(classToDeclarations("w-screen"),
[["width", "100vw"]]);
});
test("不正クラスは null を返す", () => {
assert.equal(classToDeclarations("not-a-real-class"), null);
});
43 件のテストでトークナイズ、各ユーティリティカテゴリ、text- の順序付きディスパッチ、マッチしないケース、CSS レンダリングを網羅。w-screen vs h-screen のテストは実バグの guard で、実装初期に両方とも 100vh にしていた(width には間違い)。
実装していないもの
-
バリアント (
hover:,md:,dark:) — メディアクエリ / 擬似クラスラッパが必要。「名前 → CSS 値」デモンストレータの趣旨を外れる。 -
任意値 (
p-[17px],bg-[#abcdef]) — 追加自体は容易だが、目的は標準テーマであって Tailwind 再実装ではない。 - プラグイン / カスタムテーマ — 同上。
仮にこれらを足したくなったら、ハンドラ配列の形のまま素直に追加できる(バリアントは既存ハンドラのラッパとして書ける)。
まとめ
- Tailwind の大部分は デフォルトテーマの 50 行ぐらいのルックアップテーブル — カラーパレット × shade、スペーシングスケール、フォントサイズ、weight。残りは命名規約。
-
bg-{color}-{shade}はプレフィックス + 2 次元テーブルのキー。自分で実装すると設計空間が一目で見える。 -
text-は意図でオーバーロードされている(size vs align vs color)。順序付きディスパッチ(size → align → color)が綺麗に解決する。 - 「最後のクラスが勝つ」 はパース済み rules を Map.set で順に処理すれば 1 つのクラスリスト内では等価。
- パーサを DOM 非依存に保つと
node --testで全ユーティリティの分岐をカバーできる。
リポジトリ: https://github.com/sen-ltd/tw-to-css
このツールは弊社の OSS ポートフォリオ #241 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/
