- React
ReactのCSSの書き方比較|CSS Modulesとclsx
ReactのCSSの書き方は、大きく「素のclassName」「インラインstyle」「CSS Modules」「Tailwind CSS」「CSS-in-JS」の5つです。 迷ったときの出発点としては、CSS Modulesでコンポーネントごとにクラス名を閉じ込め、条件付きのクラスはclsxで組み立てる形が、追加の仕組みが少なく扱いやすい組み合わせです。
この記事では、同じボタンを5つの方法で書き、Viteでビルドした後の実際のクラス名・ファイルサイズ・ブラウザでの表示を並べて比べます。条件付きクラスをテンプレート文字列で書いたときに false という文字がクラスに混ざる様子、styles.xxx が undefined になる3つの例も、再現コードと直し方をセットで載せています。
確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React 19.3.0 / Vite 8.3.2 / TypeScript 7.0.2 / clsx 2.1.1 / Tailwind CSS 4.3.3 / styled-components 6.5.3。ブラウザはPlaywright 1.56.0のChromium 141です。
5つの書き方の早見表
| 書き方 | スコープ | 動的スタイル | ランタイム負荷(今回のビルド) | Server Components |
|---|---|---|---|---|
| 素のclassName | グローバル(同じクラス名は重なる) | クラスの付け外し | 追加JSなし・CSS 0.24kB | 使える(Next.js公式はグローバルCSSをlayoutで読み込む例を掲載) |
| インラインstyle | その要素だけ | 値をそのまま渡せる | 追加JSなし・CSSなし | 使える(HTMLの属性として出力される) |
| CSS Modules | ファイル単位(クラス名が自動で一意に) | クラスの付け外し | 追加JSなし・CSS 0.26kB | 使える(Next.js公式の例が use client なし) |
| Tailwind CSS | クラスが共通の部品 | クラスの付け外し(完全なクラス名で書く) | 追加JSなし・CSS 5.63kB | 使える(Next.js公式の例が use client なし) |
| CSS-in-JS(styled-components) | コンポーネント単位 | propsから値を計算できる | JSが約26kB増・実行時に style タグを作る |
Next.js公式は「Client Componentsでサポート」と案内 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「ReactのCSSの書き方比較|CSS Modulesとclsx」
準備:5つを同じ条件で比べるプロジェクト
1つのViteプロジェクトに、書き方ごとのページ(plain.html、inline.htmlなど)を置き、ページごとに別のファイルとしてビルドされるようにしました。こうすると、書き方ごとに増えるJSとCSSの量がビルド結果からそのまま読み取れます。
mkdir css-compare && cd css-compare
npm init -y
npm pkg set type=module
npm i react react-dom clsx styled-components
npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom tailwindcss @tailwindcss/vite
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
rollupOptions: {
input: ['plain', 'inline', 'modules', 'tailwind', 'sc', 'scope', 'undef'].map((n) => `${n}.html`),
},
},
});
{
"compilerOptions": {
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler",
"jsx": "react-jsx", "strict": true, "noEmit": true, "skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src"]
}
tsconfig.json の "types": ["vite/client"] を入れておくと、CSSファイルのimportに型が付きます。
各ページのHTMLと入口のファイルは、どれも同じ形です(plainの部分だけが変わります)。
<!doctype html>
<html lang="ja">
<head><meta charset="UTF-8" /><title>plain</title></head>
<body><div id="root"></div><script type="module" src="/src/plain/main.tsx"></script></body>
</html>
import { createRoot } from 'react-dom/client';
import { Button } from './Button';
createRoot(document.getElementById('root')!).render(
<>
<Button>標準</Button>
<Button primary>主要</Button>
<Button primary active>主要+選択中</Button>
</>,
);
「標準」「主要」「主要+選択中」の3つのボタンを、5つの書き方で作っていきます。
書き方1:素のclassName
CSSファイルをimportし、クラス名を文字列で指定する、いちばん基本の形です。
.button {
padding: 8px 16px;
border: 1px solid #94a3b8;
border-radius: 6px;
background: #ffffff;
color: #0f172a;
}
.button:hover {
background: #f1f5f9;
}
.button--primary {
border-color: #2563eb;
background: #2563eb;
color: #ffffff;
}
.button.is-active {
outline: 3px solid #f59e0b;
}
import './button.css';
type Props = { primary?: boolean; active?: boolean; children: React.ReactNode };
export function Button({ primary = false, active = false, children }: Props) {
const className = ['button', primary && 'button--primary', active && 'is-active']
.filter(Boolean)
.join(' ');
return <button className={className}>{children}</button>;
}
ブラウザで確かめたクラス名と背景色です。
class="button" bg=rgb(255, 255, 255) outline=0px
class="button button--primary" bg=rgb(37, 99, 235) outline=0px
class="button button--primary is-active" bg=rgb(37, 99, 235) outline=3px
書いたクラス名がそのまま出力されます。わかりやすい反面、クラス名はページ全体で共有されるので、別のファイルに同じ名前があると重なります(後の「スコープ」の節で再現します)。
書き方2:インラインstyle
style 属性にオブジェクトを渡す形です。プロパティ名はキャメルケース(borderRadius)で書き、数値は多くのプロパティでpxとして扱われます。
import type { CSSProperties } from 'react';
type Props = { primary?: boolean; active?: boolean; children: React.ReactNode };
const base: CSSProperties = {
padding: '8px 16px',
border: '1px solid #94a3b8',
borderRadius: 6,
background: '#ffffff',
color: '#0f172a',
};
export function Button({ primary = false, active = false, children }: Props) {
const style: CSSProperties = {
...base,
...(primary && { borderColor: '#2563eb', background: '#2563eb', color: '#ffffff' }),
...(active && { outline: '3px solid #f59e0b' }),
};
return <button style={style}>{children}</button>;
}
class="" style="padding: 8px 16px; border: 1px solid rgb(148, 163, 184); border-radius: 6px; background: rgb(255, 255, 255); color: rgb(15, 23, 42);"
class="" style="padding: 8px 16px; border: 1px solid rgb(37, 99, 235); border-radius: 6px; background: rgb(37, 99, 235); color: rgb(255, 255, 255);"
class="" style="... background: rgb(37, 99, 235); color: rgb(255, 255, 255); outline: rgb(245, 158, 11) solid 3px;"
値をそのまま渡せるので、計算で決まる値(進み具合の幅、ドラッグ中の位置など)に向いています。 一方で、style 属性には :hover などの疑似クラスやメディアクエリを書けません。ほかの4つの例にあるホバー時の背景色は、この書き方では入れていません。見た目の基本はクラスで書き、計算で決まる値だけをインラインstyleやCSS変数で渡す、という分担がおすすめです。
書き方3:CSS Modules
ファイル名を .module.css にすると、そのファイルのクラス名がビルド時に一意の名前へ変換されます。 Viteの公式ドキュメント(Features)には、「.module.css で終わるCSSファイルはCSS Modulesのファイルとして扱われ、importするとモジュールオブジェクトが返る」という内容が書かれています。追加の設定やライブラリは不要です。
.button {
padding: 8px 16px;
border: 1px solid #94a3b8;
border-radius: 6px;
background: #ffffff;
color: #0f172a;
}
.button:hover {
background: #f1f5f9;
}
.primary {
border-color: #2563eb;
background: #2563eb;
color: #ffffff;
}
.active {
outline: 3px solid #f59e0b;
}
import clsx from 'clsx';
import styles from './Button.module.css';
type Props = { primary?: boolean; active?: boolean; children: React.ReactNode };
export function Button({ primary = false, active = false, children }: Props) {
return (
<button className={clsx(styles.button, primary && styles.primary, active && styles.active)}>
{children}
</button>
);
}
ビルド後の実際のクラス名
npx vite build で出力されたCSSです(1行にまとまっているものを、読みやすく改行しています)。
._button_1pss4_1{color:#0f172a;background:#fff;border:1px solid #94a3b8;border-radius:6px;padding:8px 16px}
._button_1pss4_1:hover{background:#f1f5f9}
._primary_1pss4_11{color:#fff;background:#2563eb;border-color:#2563eb}
._active_1pss4_16{outline:3px solid #f59e0b}
JS側には、元の名前と変換後の名前の対応表が埋め込まれていました。styles の正体はこのオブジェクトです。
i={button:`_button_1pss4_1`,primary:`_primary_1pss4_11`,active:`_active_1pss4_16`}
ブラウザで見たクラス名も同じです。
class="_button_1pss4_1" bg=rgb(255, 255, 255) outline=0px
class="_button_1pss4_1 _primary_1pss4_11" bg=rgb(37, 99, 235) outline=0px
class="_button_1pss4_1 _primary_1pss4_11 _active_1pss4_16" bg=rgb(37, 99, 235) outline=3px
| 部分 | 今回の値 | 意味 |
|---|---|---|
| 先頭 | _button |
元のクラス名 |
| 中央 | 1pss4 |
ファイルごとに決まるハッシュ。同じファイルのクラスは同じ値で、CSSを編集すると変わった |
| 末尾 | 1、11、16 |
今回の出力では、CSSファイルの中でそのクラスを定義した行番号と一致 |
開発サーバー(npx vite)でも、今回は同じクラス名でした。 違いは配信の形で、開発サーバーでは style タグが1つ挿入され、ビルド後は link タグでCSSファイルを読み込む形になっていました。
クラス名の形式を変える
変換後の名前は、vite.config.ts の css.modules.generateScopedName で変えられます。ファイル名を含めると、ブラウザの開発者ツールでどのファイルのクラスかを追いやすくなります。
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
css: {
modules: {
localsConvention: 'camelCase',
generateScopedName: '[name]__[local]__[hash:base64:5]',
},
},
build: { outDir: 'dist-camel', rollupOptions: { input: 'undef.html' } },
});
.Card-module__card__c6V8G{padding:16px}
.Card-module__card-title__Hskjd{font-weight:700}
.Card-module__isOpen__EksQW{display:block}
(この設定ファイルは、後で使う Card.module.css だけをビルドする確認用です。localsConvention は「undefinedになる例」の節で説明します。)
書き方4:Tailwind CSS
あらかじめ用意された小さなクラス(ユーティリティクラス)を組み合わせる書き方です。v4では、Viteのプラグインを足し、CSSに1行書けば使えます。
@import "tailwindcss";
import clsx from 'clsx';
import './tailwind.css';
type Props = { primary?: boolean; active?: boolean; children: React.ReactNode };
export function Button({ primary = false, active = false, children }: Props) {
return (
<button
className={clsx(
'rounded-md border px-4 py-2',
primary
? 'border-blue-600 bg-blue-600 text-white'
: 'border-slate-400 bg-white text-slate-900 hover:bg-slate-100',
active && 'outline-3 outline-amber-500',
)}
>
{children}
</button>
);
}
class="rounded-md border px-4 py-2 border-slate-400 bg-white text-slate-900 hover:bg-slate-100"
class="rounded-md border px-4 py-2 border-blue-600 bg-blue-600 text-white"
class="rounded-md border px-4 py-2 border-blue-600 bg-blue-600 text-white outline-3 outline-amber-500"
ビルド後のCSSには、使ったクラスの定義が入っていました(抜粋)。
.rounded-md{border-radius:var(--radius-md)}
.border-blue-600{border-color:var(--color-blue-600)}
.bg-blue-600{background-color:var(--color-blue-600)}
.px-4{padding-inline:calc(var(--spacing) * 4)}
.outline-3{outline-style:var(--tw-outline-style);outline-width:3px}
.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}
クラス名は完全な形で書く
Tailwindはソースコードの中の文字列を見てCSSを作るので、クラス名を文字列の連結で組み立てると、CSSが作られません。
import '../tailwind/tailwind.css';
type Color = 'red' | 'green';
// クラス名を文字列の連結で組み立てる例
export function BadgeDynamic({ color }: { color: Color }) {
return <span className={`bg-${color}-600 text-white`}>{color}</span>;
}
// 完全なクラス名を対応表に書く例
const bg: Record<Color, string> = { red: 'bg-rose-600', green: 'bg-emerald-600' };
export function BadgeMap({ color }: { color: Color }) {
return <span className={`${bg[color]} text-white`}>{color}</span>;
}
同じプラグイン設定で、この2つを表示するページを追加してビルドしました。ビルド後のCSSに各クラスの定義が何個あるかを数え、ブラウザで背景色を確かめました。
bg-red-600: 0
bg-green-600: 0
bg-rose-600: 1
bg-emerald-600: 1
class="bg-red-600 text-white" 背景色 = rgba(0, 0, 0, 0)
class="bg-rose-600 text-white" 背景色 = oklch(0.586 0.253 17.585)
連結で作った bg-red-600 は、クラスとしては付いているのに定義がないため、背景が透明のままです。対応表に完全なクラス名を書く形にすると、きちんと色が付きます。
書き方5:CSS-in-JS(styled-components)
JavaScriptの中にCSSを書き、スタイル付きのコンポーネントを作る書き方です。propsの値からスタイルを計算できるのが特徴です。
import styled, { css } from 'styled-components';
type Props = { primary?: boolean; active?: boolean; children: React.ReactNode };
const StyledButton = styled.button<{ $primary: boolean; $active: boolean }>`
padding: 8px 16px;
border: 1px solid #94a3b8;
border-radius: 6px;
background: #ffffff;
color: #0f172a;
&:hover {
background: #f1f5f9;
}
${(p) =>
p.$primary &&
css`
border-color: #2563eb;
background: #2563eb;
color: #ffffff;
`}
${(p) =>
p.$active &&
css`
outline: 3px solid #f59e0b;
`}
`;
export function Button({ primary = false, active = false, children }: Props) {
return (
<StyledButton $primary={primary} $active={active}>
{children}
</StyledButton>
);
}
スタイルの計算に使うpropsには、$primary のように $ を付けた名前を使っています。
class="sc-bdvwhi dwAzPv" bg=rgb(255, 255, 255) outline=0px
class="sc-bdvwhi gXguSe" bg=rgb(37, 99, 235) outline=0px
class="sc-bdvwhi izwAFH" bg=rgb(37, 99, 235) outline=3px
propsの組み合わせごとに別のクラス(dwAzPv、gXguSe、izwAFH)が作られています。CSSファイルは出力されず、ページを開いたときにJavaScriptが style タグを1つ作り、そこへルールを入れていました。
styleタグ=1 link=0
.dwAzPv { padding: 8px 16px; border: 1px solid rgb(148, 163, 184); ... }
.dwAzPv:hover { background: rgb(241, 245, 249); }
.gXguSe { padding: 8px 16px; border: 1px solid rgb(37, 99, 235); ... }
ビルド結果で比べる:ファイルサイズとランタイム負荷
1回のビルドで出力されたファイルのうち、ページごとの分を表にまとめます。React本体(共通のファイル、219.35kB)は全ページで共有されるため、ここには含めていません。
| 書き方 | ページのJS | CSSファイル | 実行時にJSが行うこと |
|---|---|---|---|
| 素のclassName | 0.52kB | 0.24kB | クラス名の文字列を渡すだけ |
| インラインstyle | 0.66kB | なし | styleオブジェクトを渡すだけ |
| CSS Modules+clsx | 0.60kB+clsx 0.36kB | 0.26kB | 対応表から名前を引くだけ |
| Tailwind+clsx | 0.65kB+clsx 0.36kB | 5.63kB(gzip 1.89kB) | クラス名の文字列を渡すだけ |
| styled-components | 26.68kB(gzip 11.13kB) | なし | CSSの文字列を作り、style タグへ入れる |
CSS ModulesとTailwindは、スタイルの計算をビルド時に済ませる方式です。ブラウザに届くのは普通のCSSファイルと短いクラス名だけなので、実行時にスタイルのためのJSは動きません。styled-componentsは、ライブラリ本体がJSに含まれ、実行時にCSSを組み立てます。TailwindのCSSが5.63kBあるのは、リセット用のスタイルやテーマの変数が含まれるためです。
スコープを確かめる:同じクラス名が重なるとき
2つのファイルに同じ .title を書いて、素のCSSとCSS Modulesで比べます。
/* a.css と A.module.css */
.title { color: rgb(220, 38, 38); } /* 赤 */
/* b.css と B.module.css */
.title { color: rgb(37, 99, 235); } /* 青 */
import { createRoot } from 'react-dom/client';
import './a.css';
import './b.css';
import a from './A.module.css';
import b from './B.module.css';
createRoot(document.getElementById('root')!).render(
<>
{/* 素のCSS:どちらも .title */}
<h2 id="plain-a" className="title">記事A(赤のつもり)</h2>
<h2 id="plain-b" className="title">記事B(青のつもり)</h2>
{/* CSS Modules:ファイルごとに別のクラス名になる */}
<h2 id="mod-a" className={a.title}>記事A(赤)</h2>
<h2 id="mod-b" className={b.title}>記事B(青)</h2>
</>,
);
各見出しのクラス名と、実際の文字色です。
plain-a class="title" color = rgb(37, 99, 235)
plain-b class="title" color = rgb(37, 99, 235)
mod-a class="_title_lqahc_1" color = rgb(220, 38, 38)
mod-b class="_title_1457x_1" color = rgb(37, 99, 235)
素のCSSでは、後から読み込まれた青が両方に効いて、記事Aも青になりました。 ビルド後のCSSを見ると、素のCSSの2つの .title は1つにまとめられ、後のほうだけが残っています。CSS Modulesでは別々の名前になるので、それぞれの色が保たれます。
.title{color:#2563eb}._title_lqahc_1{color:#dc2626}._title_1457x_1{color:#2563eb}
条件付きクラスの書き比べ:テンプレート文字列とclsx
「主要ボタンのときだけ primary を付ける」のような条件付きクラスは、書き方によって結果の文字列が変わります。Node.jsでそのまま動く比較コードです。
import clsx from 'clsx';
const styles = { button: '_button_x1', primary: '_primary_x1', active: '_active_x1' };
const cases = [
{ primary: true, active: true },
{ primary: true, active: false },
{ primary: false, active: false },
];
for (const { primary, active } of cases) {
console.log(`--- primary=${primary} active=${active}`);
// 1. && をテンプレート文字列に直接入れる
console.log('1. && を直接 :', JSON.stringify(`${styles.button} ${primary && styles.primary} ${active && styles.active}`));
// 2. 三項演算子で空文字にする
console.log('2. 三項演算子 :', JSON.stringify(`${styles.button} ${primary ? styles.primary : ''} ${active ? styles.active : ''}`));
// 3. 配列 + filter + join
console.log('3. 配列とfilter:', JSON.stringify([styles.button, primary && styles.primary, active && styles.active].filter(Boolean).join(' ')));
// 4. clsx
console.log('4. clsx :', JSON.stringify(clsx(styles.button, primary && styles.primary, active && styles.active)));
}
console.log('--- clsx の引数の形');
console.log(clsx('a', 'b'));
console.log(clsx('a', { b: true, c: false }));
console.log(clsx(['a', ['b', { c: true }]]));
console.log(clsx('a', null, undefined, false, 0, '', 'b'));
console.log(JSON.stringify(clsx(false, null)));
--- primary=true active=true
1. && を直接 : "_button_x1 _primary_x1 _active_x1"
2. 三項演算子 : "_button_x1 _primary_x1 _active_x1"
3. 配列とfilter: "_button_x1 _primary_x1 _active_x1"
4. clsx : "_button_x1 _primary_x1 _active_x1"
--- primary=true active=false
1. && を直接 : "_button_x1 _primary_x1 false"
2. 三項演算子 : "_button_x1 _primary_x1 "
3. 配列とfilter: "_button_x1 _primary_x1"
4. clsx : "_button_x1 _primary_x1"
--- primary=false active=false
1. && を直接 : "_button_x1 false false"
2. 三項演算子 : "_button_x1 "
3. 配列とfilter: "_button_x1"
4. clsx : "_button_x1"
--- clsx の引数の形
a b
a b
a b c
a b
""
| 書き方 | 条件がfalseのときの結果 | 評価 |
|---|---|---|
&& をテンプレート文字列に直接入れる |
false という文字がクラスに入る |
避ける。false という名前のクラスが付いた状態になる |
| 三項演算子で空文字にする | 余分な空白が残る | 動作は問題なし。条件が増えると読みにくくなる |
配列と filter(Boolean) と join(' ') |
きれいな文字列 | ライブラリなしで書ける |
| clsx | きれいな文字列 | いちばん短い。オブジェクトや配列も渡せる |
clsxは、false・null・undefined・0・空文字を取り除いて、残りを空白でつなぐ関数です。JSXの {条件 && 要素} と同じ感覚で 条件 && クラス名 と書けるので、条件付きクラスが2つ以上あるならclsxに任せるとすっきりします。
clsxの3つの渡し方
// 1. 条件 && クラス名(この記事で使っている形)
clsx(styles.button, primary && styles.primary, active && styles.active)
// 2. オブジェクト:キーがクラス名、値が条件
clsx(styles.button, { [styles.primary]: primary, [styles.active]: active })
// 3. 外から受け取ったclassNameを最後に足す
clsx(styles.button, primary && styles.primary, className)
CSS Modulesでは、クラス名が styles.primary のような変数に入っているので、オブジェクトで書くときは [styles.primary] と角かっこで囲みます(計算されたプロパティ名)。3つ目の形は、部品を使う側が余白などを足せるようにするための定番です。
styles.xxxがundefinedになる3つの例
CSS Modulesで「スタイルが当たらない」ときは、styles.xxx が undefined になっていることがほとんどです。再現用のCSSとコードです。
.card { padding: 16px; }
.card-title { font-weight: bold; }
.isOpen { display: block; }
import styles from './Card.module.css';
const s = styles as Record<string, string | undefined>;
console.log('styles =', JSON.stringify(styles));
console.log("styles.card =", s.card);
console.log("styles['card-title'] =", s['card-title']);
console.log("styles.cardTitle =", s.cardTitle);
console.log("styles.crad(打ち間違い)=", s.crad);
console.log('className =', `${s.card} ${s.cardTitle}`);
styles = {"card":"_card_x6625_1","card-title":"_card-title_x6625_2","isOpen":"_isOpen_x6625_3"}
styles.card = _card_x6625_1
styles['card-title'] = _card-title_x6625_2
styles.cardTitle = undefined
styles.crad(打ち間違い)= undefined
className = _card_x6625_1 undefined
例1:ハイフン入りのクラス名
CSSで .card-title と書いたクラスは、対応表でも card-title というキーになります。styles.cardTitle では取れません。また styles.card-title と書くと、JavaScriptでは「styles.card 引く title」という引き算として読まれ、型チェックで止まります。
src/bad/bad2.tsx(3,32): error TS2322: Type 'number' is not assignable to type 'string'.
src/bad/bad2.tsx(3,43): error TS2362: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
src/bad/bad2.tsx(3,55): error TS2552: Cannot find name 'title'. Did you mean 'Title'?
直し方は3つあります。
| 直し方 | 書き方 | 向いている場面 |
|---|---|---|
| 角かっこで取る | styles['card-title'] |
既存のCSSをそのまま使いたい |
| CSS側をキャメルケースにする | .cardTitle { ... } と styles.cardTitle |
新しく書くCSS |
| Viteの設定で両方使えるようにする | css.modules.localsConvention: 'camelCase' |
ハイフン名のCSSが多い |
3つ目の設定(先ほどの vite.camel.config.ts)でビルドすると、対応表にキャメルケースのキーが追加されました。
{card:`Card-module__card__c6V8G`,"card-title":`Card-module__card-title__Hskjd`,cardTitle:`Card-module__card-title__Hskjd`,isOpen:`Card-module__isOpen__EksQW`}
例2:クラス名の打ち間違い
styles.crad のような打ち間違いは、undefined になるだけでエラーになりません。vite/client の型では、styles は「どんな名前でも文字列が返る」オブジェクトとして扱われるため、tsc --noEmit も通ります(今回、className={styles.crad} と書いたファイルで確かめました)。
気づくための手がかりは、ブラウザの開発者ツールです。要素のクラスに undefined という文字が入っていたら、対応表にない名前を参照しています。
className = _card_x6625_1 undefined
テンプレート文字列でつなぐと undefined という文字がそのまま入りますが、clsxに渡せば undefined は取り除かれるので、余計なクラスが付くことは防げます(スタイルが当たらないこと自体は、名前を直して解決します)。
例3:ファイル名が .module.css でない
ファイル名を Card.css のまま、import styles from './Card.css' と書いた場合です。
import styles from './Card.css';
console.log('styles =', styles);
export const Card = () => <div className={styles.card}>カード</div>;
型チェック、ビルド、開発サーバーのそれぞれで、次のように知らせてくれました。
# tsc --noEmit
src/bad/bad1.tsx(4,50): error TS2339: Property 'card' does not exist on type 'typeof import("*.css")'.
# npx vite build
[MISSING_EXPORT] "default" is not exported by "src/bad/Card.css".
╭─[ src/bad/bad1.tsx:1:8 ]
│
1 │ import styles from "./Card.css";
│ ───┬──
│ ╰──── Missing export
───╯
# 開発サーバー(ブラウザのコンソール)
SyntaxError: The requested module '/src/bad/Card.css' does not provide an export named 'default'
ファイル名を Card.module.css に変え、importのパスも合わせると解決します。普通のCSSとして読み込みたいときは、import './Card.css' と、変数に受けない形で書きます。
undefinedになったときの確認表
| 確認すること | 見る場所 |
|---|---|
ファイル名が .module.css で終わっているか |
importのパスとファイル名 |
CSS側のクラス名と、styles. の後ろの名前が同じか |
CSSファイル(大文字・小文字も含めて) |
ハイフン入りの名前を styles.xxx で取っていないか |
角かっこにするか、キャメルケースにそろえる |
要素のクラスに undefined や false が入っていないか |
ブラウザの開発者ツール |
console.log(styles) で対応表に目的のキーがあるか |
ブラウザのコンソール |
Server Componentsと組み合わせるとき
Next.jsのApp Routerのように、サーバー側でコンポーネントを描画する構成では、「スタイルを作るのにブラウザのJavaScriptが必要かどうか」が選び方に関わります。サーバーでHTML文字列を作る react-dom/server を使って、HTMLに何が含まれるかをNode.jsで確かめました。
// サーバー側でHTML文字列を作ったとき、スタイルがHTMLに含まれるかを確かめる
import { createElement as h } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { styled, ServerStyleSheet } from 'styled-components';
const Inline = () => h('button', { style: { padding: '8px 16px', background: '#2563eb' } }, '主要');
const ClassName = () => h('button', { className: '_button_1pss4_1 _primary_1pss4_11' }, '主要');
const Styled = styled.button`
padding: 8px 16px;
background: #2563eb;
`;
console.log('inline :', renderToStaticMarkup(h(Inline)));
console.log('className:', renderToStaticMarkup(h(ClassName)));
console.log('styled :', renderToStaticMarkup(h(Styled, null, '主要')));
// styled-components は ServerStyleSheet で集めるとCSSを取り出せる
const sheet = new ServerStyleSheet();
const html = renderToStaticMarkup(sheet.collectStyles(h(Styled, null, '主要')));
console.log('collect :', html);
console.log('CSS :', sheet.getStyleTags().replace(/\s+/g, ' '));
sheet.seal();
inline : <button style="padding:8px 16px;background:#2563eb">主要</button>
className: <button class="_button_1pss4_1 _primary_1pss4_11">主要</button>
styled : <button class="sc-bdvwhi pEdkk">主要</button>
collect : <button class="sc-bdvwhi pEdkk">主要</button>
CSS : <style data-styled="true" data-styled-version="6.5.3">.pEdkk{padding:8px 16px;background:#2563eb;}/*!sc*/ data-styled.g1[id="sc-bdvwhi"]{content:"pEdkk,"}/*!sc*/ </style>
インラインstyleとclassNameは、HTMLに属性として出力されるだけで完結します(classNameのほうは、ビルドで作られたCSSファイルを別に読み込みます)。styled-componentsは、クラス名はHTMLに入りますが、CSSのルールは別に集めて出力する手順が必要です。
Next.jsの公式ドキュメントで確認できた内容は次のとおりです。
| 書き方 | Next.js公式ドキュメントの記載 |
|---|---|
| CSS Modules | CSSのページで、.module.css を use client なしの page.tsx からimportする例を掲載 |
| Tailwind CSS | 同じページで、use client なしの page.tsx にユーティリティクラスを書く例を掲載。コンポーネントのスタイルにはTailwindを勧め、足りないときにCSS Modulesを使う案内 |
| グローバルCSS | ルートのlayoutでimportする例を掲載。「本当に全体に効かせたいスタイル」に使う案内 |
| CSS-in-JS | CSS-in-JSのページで、styled-componentsなどは「appディレクトリのClient Componentsでサポート」と記載。use client を付けたスタイルの登録用コンポーネントでレイアウトを包む設定が必要 |
サーバー側で描画する部分を多くしたいなら、ビルド時にCSSが決まるCSS ModulesかTailwindを選ぶと、追加の設定なしで進められます。
どれを選ぶかの判断表
| 状況 | 選び方 | 理由 |
|---|---|---|
| まず1つ決めたい、普通のCSSの書き方を活かしたい | CSS Modules+clsx | 設定なしで使え、クラス名の重なりを防げる |
| デザインの部品化を速く進めたい | Tailwind CSS+clsx | CSSファイルを行き来せずに書ける |
| 値が計算で決まる(幅、位置、色の割合) | インラインstyle(またはCSS変数を渡す) | 値をそのまま渡せる |
| リセットCSSや全体の文字設定 | 素のCSS(グローバル) | 全体に効かせたいスタイルに合う |
| propsからスタイルを細かく作りたい、既にstyled-componentsを使っている | CSS-in-JS | 表現力が高い。JSの増加と実行時の処理を見込む |
| Server Componentsを中心にしたい | CSS ModulesかTailwind | ビルド時にCSSが決まる |
1つに絞る必要はありません。「全体は素のCSS、部品はCSS Modules、計算で決まる値だけインラインstyle」のように、役割で分けると無理なく組み合わせられます。
よくある質問
Q. classNameとstyleは、どちらを使うとよいですか?
A. 見た目の基本はclassName(CSS ModulesやTailwind)で書き、計算で決まる値だけstyleで渡すのがおすすめです。styleには :hover やメディアクエリを書けないため、状態による見た目の変化はクラスの付け外しで表します。
Q. clsxとclassnamesはどう違いますか? A. この記事で動かしたのはclsx 2.1.1です。今回のビルドでは、clsxの部分は0.36kBでした。条件付きクラスを組み立てるという使い方は同じなので、既にclassnamesを使っているコードは、そのまま使い続けて問題ありません。
Q. CSS Modulesのクラス名は、開発中とビルド後で変わりますか?
A. 今回のVite 8.3.2では、開発サーバーとビルド後で同じ名前(_button_1pss4_1 など)でした。形式は generateScopedName で変えられます。CSSファイルの先頭に2行足してビルドし直したところ、_card_x6625_1 が _card_s3zcd_3 に変わりました。CSSを編集すると名前も変わるので、変換後の名前をテストや別のCSSから直接参照しない作りにしておくと安心です。
Q. CSS Modulesで、外から渡されたclassNameと合わせるにはどうしますか?
A. clsx(styles.button, className) のように最後に足します。部品を使う側から余白などを調整できるようになります。
まとめ
- Reactのスタイルは5種類:素のclassName・インラインstyle・CSS Modules・Tailwind・CSS-in-JS
- CSS Modulesは
.module.cssと名付けるだけ。ビルド後は_button_1pss4_1のような一意の名前になり、同じクラス名が重ならない - 条件付きクラスはclsxで書く。テンプレート文字列に
&&を直接入れるとfalseという文字が混ざる styles.xxxがundefinedになる原因は3つ:ハイフン入りの名前、打ち間違い、ファイル名が.module.cssでない- 打ち間違いは型チェックを通るので、開発者ツールでクラスに
undefinedがないかを見る - ランタイム負荷:CSS ModulesとTailwindは追加JSなし、styled-componentsは今回約26kBのJSが増え、実行時に
styleタグを作る - Server Components中心なら、ビルド時にCSSが決まる方法を選ぶと設定が少なく済む
参考・出典
- Vite公式ドキュメント「Features」(CSS Modules、localsConvention):https://vite.dev/guide/features
- Next.js公式ドキュメント「CSS」:https://nextjs.org/docs/app/getting-started/css
- Next.js公式ドキュメント「How to use CSS-in-JS libraries」:https://nextjs.org/docs/app/guides/css-in-js
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「ReactのCSSの書き方比較|CSS Modulesとclsx」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。