Tailwind CSSの使い方のコツ:@applyを乱用せず崩れないUIにする実践Tips
Tailwind CSSのclassNameが伸びて崩れる前に。ユーティリティファースト、@applyの使いどころ、コンポーネント抽出、v4の変更点を実例で。
「ボタン1個だけ、ちょっと整えて」
そう頼んだだけなのに、出てきたコードの className が画面の横幅を超えていました。bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg text-sm font-semibold ... と、呪文みたいに延々と続く。動くんです。でも、次に色を変えたくなったとき、同じボタンが何個あるのか、どこを直せばいいのか、もう分からない。
僕が最初にTailwind CSSでやらかしたのは、この「class soup(クラスのごった煮)」でした。読めない className を前に、つい @apply で全部CSSファイルに逃がしてしまう。そしてしばらく経つと、Tailwindを使っているのに普通のCSSを書いているのと同じ状態に逆戻り——。
Tailwind CSSは、p-4 や text-sm のような小さなユーティリティクラスを組み合わせてUIを作るCSSフレームワークです。便利な反面、考え方を間違えると一気に保守できなくなります。今日は、僕が遠回りして学んだ「崩れにくくする使い方のコツ」を、コピペで試せる形でまとめます。
この記事の要点
- Tailwindは「ユーティリティファースト」。CSSファイルに名前を付ける前に、まずHTML/JSXへ直接クラスを書く
@applyは乱用しない。繰り返しは コンポーネント抽出 で解決するほうが保守しやすい- レスポンシブは「基本=スマホ、
sm:md:lg:で広い画面の差分」のmobile-firstで読む - 色・余白・角丸は
theme(v4は@theme)でデザイントークン化し、似た青を増やさない - v4から設定はCSSベース。
tailwind.config.jsが必須ではなくなり、@import "tailwindcss"で始める
レイアウト全般の崩れない設計順序はレスポンシブCSS設計の考え方、色やテーマ切替の土台になるCSS変数はCSS変数の使い方にまとめました。この記事はTailwind固有のTipsに絞ります。
公式の一次情報は、Tailwind CSS公式ドキュメントを基準にしています。@apply の位置づけはFunctions and directives、レスポンシブはResponsive design、テーマ変数はTheme variablesを確認してください。
ユーティリティファーストって、要するに何?
ユーティリティファーストは、「先にCSSクラスへ名前を付ける」のをやめて、「小さな道具(ユーティリティ)をその場で組み合わせる」考え方です。
たとえば普通のCSSなら、.card { padding: 1rem; border-radius: 0.75rem; ... } と先にクラスを設計します。Tailwindでは <div class="p-4 rounded-xl ..."> と、必要な見た目をその場で足す。料理でいうと、専用ソースを瓶詰めしてから使うのではなく、塩・胡椒・油をその都度ふる感じです。
何が嬉しいかというと、クラス名を考えなくていい ことと、そのHTMLを見るだけで見た目が分かる こと。.hero-title が実際どんなスタイルなのかCSSファイルを往復して確認する、あの作業が消えます。
逆にデメリットは、className が長くなること。ここで多くの人が @apply に手を出して、結局もとの「名前付きCSS」へ戻ってしまいます。だからこそ、@apply をいつ使い、いつ使わないかが最初の分かれ道になります。
@applyは乱用しない。これが最大のコツ
@apply は、ユーティリティクラスをまとめて1つのCSSクラスに焼き込むディレクティブです。こう書けます。
/* やりがちだが、おすすめしない例 */
.btn-primary {
@apply inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700;
}
一見きれいです。でも、これを多用するとTailwindの旨みがほぼ消えます。理由は3つ。
- HTMLを見ても見た目が分からなくなる。
class="btn-primary"だけでは、結局CSSファイルを開く羽目になる。普通のCSSへ逆戻りです。 - どこで使われているか追えなくなる。
btn-primaryがアプリ全体で何個あるのか、grepしないと分からない。 - 状態差分が書きにくい。「このボタンだけ少し幅広」みたいな1回限りの調整を、また別クラスで作りたくなる。
Tailwind公式自身、@apply は「同じユーティリティの並びがあちこちに散らばって本当に困ったとき」の最終手段に近い位置づけにしています。@apply を使う前に、まずこう自問してください。
- これはReact/Vueの コンポーネントとして くくり出せないか?
- 1回しか使わないのに、共通化しようとしていないか?
@apply が妥当なのは、コンポーネント化できない場所だけです。たとえば外部CMSが吐くHTMLや、Markdownから生成される本文タグ(prose で足りないとき)、サードパーティのクラス名に当てたいとき。こういう「JSXで包めない」ケースに限れば、@apply は素直に役立ちます。
繰り返しはコンポーネント抽出で解決する
「同じボタンが10回出てくる」問題の正解は、@apply ではなく コンポーネント抽出 です。クラス名はソースに文字列として残るので、Tailwindの検出にも引っかかりやすくなります。
下はそのままコピーして使える、variant(種類)対応のButtonコンポーネントです。色や状態を文字列のマップで持ち、bg-${color} のような動的な組み立てを避けているのがポイントです。
import type { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonVariant = "primary" | "secondary" | "danger";
// variantごとに「完全な文字列」で持つ。動的結合しないのが事故回避のコツ
const buttonVariants: Record<ButtonVariant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-600",
secondary:
"border border-slate-300 bg-white text-slate-900 hover:bg-slate-50 focus:ring-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-white",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-600",
};
// 小さなクラス結合ヘルパー(falseやundefinedを捨てて結合する)
function cn(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
loading?: boolean;
children: ReactNode;
};
export function Button({
variant = "primary",
loading = false,
disabled,
className,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(
// 全variant共通の土台。ここに構造とフォーカスリングをまとめる
"inline-flex min-h-10 items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60",
buttonVariants[variant],
className, // 呼び出し側で1回だけ上書きしたいときの逃げ道
)}
disabled={disabled || loading}
{...props}
>
{loading ? "処理中..." : children}
</button>
);
}
これで <Button variant="danger">削除</Button> と書けます。className の長さは1か所に閉じ込められ、色を変えたいときは buttonVariants の1行を直すだけ。@apply と違って、HTMLには btn-primary のような不透明な名前が出ません。
判断の目安はシンプルです。3か所以上に同じ見た目が出たら抽出、1〜2回なら直書きのまま。1回しか使わない装飾まで共通化すると、逆に変更が重くなります。
レスポンシブはmobile-firstで読む
Tailwindのレスポンシブ接頭辞は、sm: md: lg: xl: 2xl: です。ここで一番つまずくのが、接頭辞なしのクラスがスマホ用ではない、と勘違いする ことです。
正しくはこうです。接頭辞なしが「すべての画面(=最小幅から)」のベースで、md: は「中サイズ以上に 上書き」。つまり下限を書いて、広い画面の差分を足していく。これがmobile-firstです。
<!-- スマホ1列 → 640px以上で2列 → 1024px以上で3列 -->
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<article class="rounded-xl border border-slate-200 p-4">カード1</article>
<article class="rounded-xl border border-slate-200 p-4">カード2</article>
<article class="rounded-xl border border-slate-200 p-4">カード3</article>
</section>
初心者がやりがちなのは、PC画面を先に見て grid-cols-4 を付け、あとからスマホで崩れて慌てて直すパターン。僕も最初これでした。デスクトップから書くと、スマホ向けに sm:grid-cols-1 のような「打ち消し」が増えて読みにくくなります。最初から375px幅で確認する癖をつけると、打ち消しが激減します。
よく使う接頭辞を表にしておきます。
| 接頭辞 | 効き始める幅(既定) | よく使う場面 |
|---|---|---|
| (なし) | 0px〜 | スマホ基準のベース |
sm: | 640px〜 | 横向きスマホ・小型タブレット |
md: | 768px〜 | タブレット、2カラム化 |
lg: | 1024px〜 | PC、サイドバー表示 |
xl: 2xl: | 1280px / 1536px〜 | 大型ディスプレイの余白調整 |
レイアウトそのものの崩れ(clampやコンテナクエリの使い分け)はレスポンシブCSS設計の考え方に詳しく書いたので、ブレークポイントだけで足りないと感じたら覗いてみてください。
デザイントークンはthemeに寄せる(v4は@theme)
色・余白・角丸・影に名前を付けて共通化したものを、デザイントークンと呼びます。これを最初に決めておくと、bg-blue-600 と bg-sky-600 と bg-indigo-600 が画面ごとに混ざる、あの「似た青だらけ」事故を防げます。
トークンの中身は結局CSS変数(カスタムプロパティ)です。Tailwindはそれを bg-brand-600 のようなユーティリティとして使えるようにしてくれます。CSS変数そのものの挙動が不安ならCSS変数の使い方を先にどうぞ。
Tailwind v4では、CSSの中の @theme でトークンを宣言します。
/* src/styles/app.css */
@import "tailwindcss";
@theme {
--font-sans: Inter, "Noto Sans JP", system-ui, sans-serif;
/* これだけで bg-brand-600 / text-brand-600 などが生える */
--color-brand-50: #eef6ff;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-ink: #111827;
--color-muted: #6b7280;
--color-danger: #dc2626;
--radius-card: 0.75rem; /* rounded-card として使える */
--shadow-card: 0 16px 40px rgb(15 23 42 / 0.08);
}
--color-brand-600 と書けば bg-brand-600 text-brand-600 border-brand-600 が自動で使えます。命名の規約(--color-* や --radius-*)に沿わせるのがコツで、ここを守ると新しいトークンを足すたびにユーティリティが増えていきます。
v3以前の tailwind.config.js を使っているプロジェクトなら、同じ値を theme.extend に書けば考え方は同じです。設定の置き場所が変わっただけで、「色に名前を付けて使い回す」発想は共通です。
Tailwind v4の変更点:設定がCSSベースになった
v3からv4で一番大きいのは、設定の主役がJSからCSSへ移った ことです。手が止まりやすいポイントだけ、表でまとめます。
| 項目 | v3まで | v4 |
|---|---|---|
| 読み込み | @tailwind base; など3行 | @import "tailwindcss"; の1行 |
| 設定ファイル | tailwind.config.js が前提 | CSS内の @theme 中心。configは任意 |
| テーマ拡張 | theme.extend にJSで記述 | @theme { --color-... } をCSSで記述 |
| ダークの定義 | darkmode: "class" など | @custom-variant dark (...) をCSSで |
| 動的クラスの追加検出 | safelist | @source / @source inline() |
たとえば、HTMLに .dark を付けたときだけ dark: を効かせたいなら、CSSにこう1行足します。
/* .dark を持つ要素配下で dark: ユーティリティを有効化する */
@custom-variant dark (&:where(.dark, .dark *));
「v4に上げたら tailwind.config.js を読んでくれない」と焦る人がいますが、多くは設定をCSSへ移し忘れているだけです。まずは @import "tailwindcss" で始め、足りない設定を @theme と @custom-variant に移す。これで素直に動きます。導入手順や最新の差分は、必ずTailwind CSS公式ドキュメントで確認してください。
動的クラスは「完全な文字列」で書く
Tailwindはソースファイルを走査して、実際に書かれているクラス名だけ をCSSに出力します(content scanning)。だから bg-${status}-600 のように文字列を組み立てると、Tailwindが「そんなクラス見当たらない」と判断し、本番CSSから消えます。ローカルでは効いていたのに本番で色が飛ぶ、典型的な事故です。
対策は、使うクラスを完全な文字列として列挙すること。先ほどのButtonと同じ「マップに寄せる」発想です。
type Status = "success" | "warning" | "danger";
// テンプレートリテラルで bg-${status} を作らない。全部ベタ書きする
const statusClasses: Record<Status, string> = {
success: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
warning: "bg-amber-50 text-amber-800 ring-amber-600/20",
danger: "bg-red-50 text-red-700 ring-red-600/20",
};
export function StatusBadge({ status, label }: { status: Status; label: string }) {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${statusClasses[status]}`}
>
{label}
</span>
);
}
どうしてもスキャン対象外(外部UIキットやCMS由来のHTML)でクラスを使うなら、v4では @source や @source inline() で明示します。ただし増やしすぎるとCSSが太るので、まずは静的マップに寄せ、それでも残るものだけを足すのが順番です。
/* src/styles/app.css */
@import "tailwindcss";
@source "../node_modules/@acme/ui-kit"; /* 外部パッケージも走査対象に */
@source inline("bg-emerald-50"); /* どうしても消えるクラスだけ明示 */
@source inline("bg-amber-50");
@source inline("bg-red-50");
僕がやらかした失敗3つ
正直に書きます。最初のTailwind導入は、Tipsの逆をやって自滅していました。
ひとつ目は、最初から @apply で固めたこと。読みやすくなる気がして、ボタンもカードも見出しも全部 @apply で名前付きクラスにした。結果、3か月後にはどれが本当に共通でどれが1回限りか分からなくなり、普通のCSSと同じ保守地獄でした。今はまずコンポーネント抽出、@apply はJSXで包めない所だけ、と決めています。
ふたつ目は、PC幅から書いたこと。lg: の見た目を先に作り込んで満足し、スマホで開いたら横スクロールが出ていた。打ち消しクラスだらけになって、結局スマホから書き直すことに。今は375px幅を最初に開きます。
みっつ目は、動的クラスで色を組み立てたこと。bg-${color}-600 が手元で動いたので安心して本番に出したら、バッジの色が全部消えていました。content scanningの仕様を知らなかったんです。それ以来、色も状態も静的マップ一択にしました。
始めるなら、ここから
いきなり全画面をTailwind化しようとしないでください。よく出てくる部品をひとつ選ぶ。ボタンか、カードか、バッジ。それを上のようにコンポーネント抽出して、デザイントークンを @theme に2〜3個だけ置く。これくらいが最初の一歩にちょうどいいです。
順番はいつも同じ。①接頭辞なし=スマホ基準で書く →②3回以上出る部品だけコンポーネント化する →③色や角丸は @theme のトークンに寄せる →④動的クラスは静的マップにする →⑤@apply はJSXで包めない所の最終手段にする。この5つを守るだけで、className のごった煮はかなり防げます。
よくある質問
Q. @apply は絶対に使ってはいけませんか?
いいえ。禁止ではなく「最終手段」です。ReactやVueでコンポーネント化できる繰り返しは、そちらで解決するほうが保守しやすい、という話です。Markdown本文タグや外部CMSのHTMLなど、JSXで包めない場所なら @apply は妥当です。
Q. classNameが長すぎて読めません。どうすれば?
まず「同じ並びが3回以上出ているか」を確認します。出ているならコンポーネント抽出、出ていないなら無理に短くしなくて大丈夫です。Prettier のTailwindプラグインでクラス順を自動整列すると、長くても読みやすくなります。
Q. Tailwind v4で tailwind.config.js は消えましたか?
必須ではなくなりました。設定の主役はCSS内の @theme です。configファイル自体はオプションとして残せますが、新規なら @import "tailwindcss" と @theme から始めるのが素直です。
Q. ローカルでは効くクラスが本番で消えるのはなぜ?
bg-${color}-600 のような動的クラスを、Tailwindが走査時に見つけられないからです。クラス名を完全な文字列で書く(静的マップに寄せる)か、最後の手段として @source inline() で明示します。
Q. デザイントークンとCSS変数は別物ですか?
ほぼ同じものを別の角度から呼んでいます。@theme で宣言した値の実体はCSS変数で、Tailwindがそれをユーティリティとして使えるようにしてくれます。仕組みを深掘りするならCSS変数の使い方が参考になります。
実際に試した結果
このサイトのUIをTailwindで整え直したとき、効果が一番大きかったのは「@apply を捨ててコンポーネント抽出に切り替えたこと」でした。btn-primary のような名前付きクラスを全部やめ、Buttonコンポーネント1個に寄せたら、色変更が瞬時に終わるようになった。
もうひとつは「375px幅を最初に開く」習慣です。PC幅から作っていた頃は、記事下のCTAボタンがスマホで折り返して押しづらくなっていました。スマホ基準で書き始めてからは、打ち消しクラスが減り、className も短くなった。
Tailwindは「賢く名前を付ける技術」ではなく、「名前を付けずに済ませる技術」なんだと、遠回りしてようやく腹落ちしました。@apply に手が伸びたら、まず「これはコンポーネントにできないか」と一呼吸おく。それだけで、半年後の自分がだいぶ楽になります。
もしClaude Codeに実装ごと任せたい場合は、ここで書いた「コンポーネント抽出・mobile-first・静的マップ」を依頼文に入れると、出てくるコードの質が安定します。型化したテンプレートやレビュー観点は教材・テンプレート一覧にまとめています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。