ReactでCSS Modulesを使い、スタイルのぶつかりを防ぐ方法
別々のコンポーネントの CSS に同じクラス名を書くと、あとから読み込んだ指定が両方に効いてしまいます。CSS ファイルの名前を .module.css にして、React のコンポーネントでは styles.title のようにクラス名を受け取ると、クラス名がファイルごとに別の名前に変わり、ぶつからなくなります。
筆者は検証環境で、同じ .title を書いた2つのコンポーネントを、ふつうの CSS と CSS Modules の両方で作り、ブラウザで付いたクラス名と色を調べました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、vite 8.3.5、@vitejs/plugin-react 6.1.2、typescript 7.0.2 です。開発サーバー(vite)と、本番用ビルド(vite build)を vite preview で開いたものの両方を、Chrome for Testing 153 を Playwright 1.56.1 で操作して調べ、結果は同じでした(localsConvention の確認だけは、本番用ビルドでしか行っていません)。CSS の処理は Vite の既定(PostCSS)のままで、Lightning CSS に切り替えた場合や、Next.js などほかのツールでの動きは試していません。
ふつうの CSS では、同じクラス名がぶつかる
まず、カードの見出しを水色、お知らせの見出しをピンクにするつもりで、2つの CSS ファイルに同じ .title を書きました。
Card.css は次のとおりです。
.title {
color: #067a98;
border-left: 6px solid #00c8e8;
}
Alert.css は次のとおりです。
.title {
color: #d85878;
border-left: 6px solid #d85878;
}
コンポーネントでは、それぞれの CSS を読み込み、className="title" を付けます。
import './Card.css';
export function Card() {
return <h2 className="title">カードの見出し(水色のつもり)</h2>;
}
Alert も、./Alert.css を読み込むほかは同じ書き方です。2つを並べて表示し、見出しの色を調べた結果は次のとおりです。
{"text":"カードの見出し(水色のつもり)","class":"title","color":"rgb(216, 88, 120)", ...}
{"text":"お知らせの見出し(ピンクのつもり)","class":"title","color":"rgb(216, 88, 120)", ...}
カードの見出しまでピンク(rgb(216, 88, 120))になりました。CSS ファイルをコンポーネントから読み込んでも、その中のクラス名はページ全体で共通です。検証では main.tsx で、Card.css を読み込む Card を先に、Alert.css を読み込む Alert をあとに読み込んでいます。どちらの指定も .title という同じセレクターなので、あとから読み込まれた Alert.css の指定が勝ちます。本番用ビルドの CSS を見ると、ピンクの .title の指定だけが残っていました。ビルド時の圧縮で、上書きされる側の指定が省かれたものと筆者は考えています。
React の公式リファレンスの、CSS のスタイルを当てる方法の説明にあるとおり、React は CSS ファイルの読み込み方を決めておらず、使っているビルドツールやフレームワークの説明に従います。この記事では Vite の機能を使います。
ファイル名を .module.css にして、styles からクラス名を受け取る
Vite の CSS Modules の説明によると、名前が .module.css で終わる CSS ファイルは CSS Modules として扱われます。読み込むと、元のクラス名(title など)をキー、ファイルごとに別の名前に変えたクラス名を値とするオブジェクトが返ります。
CSS の中身は、さきほどと同じ .title のまま、ファイル名だけを Card.module.css と Alert.module.css に変えました。コンポーネントは次のように書きます。
import styles from './Card.module.css';
export function Card() {
return <h2 className={styles.title}>カードの見出し(水色のつもり)</h2>;
}
className に文字列の "title" を書く代わりに、読み込んだ styles の title を渡します。結果は次のとおりです。
{"text":"カードの見出し(水色のつもり)","class":"_title_g90td_1","color":"rgb(6, 122, 152)", ...}
{"text":"お知らせの見出し(ピンクのつもり)","class":"_title_riev3_1","color":"rgb(216, 88, 120)", ...}
カードには _title_g90td_1、お知らせには _title_riev3_1 という、別々のクラス名が付きました。CSS のほうも、本番用ビルドでは ._title_g90td_1{...} と ._title_riev3_1{...} に書き換わっていました。セレクターが別になったので、カードは水色、お知らせはピンクのままです。

CSS Modules の README では、CSS Modules を「クラス名とアニメーション名が、標準でそのファイルの中だけで使える(ローカルな)CSS ファイル」と説明しています。どのファイルでも .title のような短い名前を気にせず使えるのは、このためです。
TypeScript では、クラス名の書き間違いに気づけない
.module.css を TypeScript から読み込むには、型の定義が要ります。Vite のクライアント側の型の説明にあるとおり、tsconfig.json の types に vite/client を入れます。検証環境の vite 8.3.5 では、次の型が使われました。
type CSSModuleClasses = { readonly [key: string]: string }
declare module '*.module.css' {
const classes: CSSModuleClasses
export default classes
}
どんな名前でも string が返る型なので、存在しないクラス名を書いても型チェックでは止まりません。styles.title のつもりで styles.titel と書き間違えたコードも、tsc ではエラーになりませんでした。
import styles from '../modules/Card.module.css';
export function Typo() {
// title のつもりで titel と書き間違えた
return <h2 className={styles.titel}>書き間違えた見出し</h2>;
}
ブラウザで開くと、この見出しには class 属性そのものが付かず("class":null)、文字は黒のままでした。styles.titel には値がない(undefined)ので、エラーも出ず、スタイルが当たらないだけです。
vite/client の型では、クラス名の書き間違いは型チェックで見つかりません。スタイルが当たらないときは、まず要素に付いたクラス名をブラウザの開発者ツールで確かめます。
複数のクラスを組み合わせる
ボタンの見た目を、ふつう・主ボタン・選択中の3通りに分けてみます。CSS Modules では、composes で別のクラスの指定を取り込めます。次の Button.module.css を用意しました。
.button {
padding: 8px 16px;
border: 2px solid #00c8e8;
background: #fff;
color: #067a98;
}
.primary {
composes: button;
background: #00c8e8;
color: #fff;
}
.is-active {
outline: 3px solid #d85878;
}
コンポーネントでは、条件に合うクラス名を配列に集め、空白でつなぎます。is-active はハイフンを含むので、styles['is-active'] と書きます。
import styles from './Button.module.css';
type Props = {
primary?: boolean;
active?: boolean;
children: string;
};
export function Button({ primary = false, active = false, children }: Props) {
const className = [primary ? styles.primary : styles.button, active && styles['is-active']]
.filter(Boolean)
.join(' ');
return <button className={className}>{children}</button>;
}
<Button>ふつう</Button>、<Button primary>主ボタン</Button>、<Button primary active>選択中</Button> の3つを表示したところ、付いたクラス名は次のとおりでした。
{"text":"ふつう","class":"_button_zbak0_1", ...}
{"text":"主ボタン","class":"_primary_zbak0_8 _button_zbak0_1", ...}
{"text":"選択中","class":"_primary_zbak0_8 _button_zbak0_1 _is-active_zbak0_14", ...}
styles の中身も表示しました。
{
"button": "_button_zbak0_1",
"primary": "_primary_zbak0_8 _button_zbak0_1",
"is-active": "_is-active_zbak0_14"
}
composes: button を書いた primary には、自分のクラス名と button のクラス名の2つが、空白区切りで入っています。そのため styles.primary を渡すだけで、button の余白や枠線も当たります。CSS Modules の、組み合わせ(composes)の説明にも、組み合わせたクラスは両方のクラス名が書き出されると書かれています。同じ説明によると、composes はほかの指定より前に書く必要があります。また、composes を書けるのは、.primary のように、1つのローカルなクラス名だけをセレクターにしたルールの中です。
localsConvention を camelCaseOnly にすると、ハイフンの名前では受け取れない
CSS Modules の名前の付け方の説明では、styles.class-name のようにドットで書けないので、クラス名は camelCase をすすめています。Vite では、css.modules の設定の localsConvention で、ハイフンの名前を camelCase に変えて受け取れます。
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
},
build: { outDir: 'dist-camel' },
});
最後の build の行は、検証で、設定を変えないビルドと出力先を分けるために書いたもので、CSS Modules の設定とは関係ありません。
この設定でビルドし直すと、styles の中身は次のようになりました。
{
"button": "_button_zbak0_1",
"primary": "_primary_zbak0_8 _button_zbak0_1",
"isActive": "_is-active_zbak0_14"
}
キーが isActive に変わり、is-active はなくなりました。CSS のクラス名は _is-active_zbak0_14 のままです。
camelCaseOnly に切り替えると、styles['is-active'] のままのコードは undefined を受け取ります。検証では、選択中のボタンの class から _is-active_zbak0_14 が消え、ピンクの枠がなくなりました。型チェックもビルドも通るので、設定を変えたら styles.isActive に書き直します。
Vite の説明によると、camelCase の名前が使える設定にすると、import { isActive } from './Button.module.css' のような名前付きの読み込みも使えます。この読み込み方は、この記事では試していません。
CSS Modules でも、要素名だけのセレクターはぶつかる
CSS Modules の README によると、ローカルになるのはクラス名とアニメーション名です。要素名だけのセレクターや、:global で囲んだクラス名は、ページ全体に効きます。次の Leak.module.css で確かめました。
/* クラスを使わず、要素名だけで書いた */
p {
color: #d85878;
}
.box :global(.note) {
font-weight: bold;
}
import styles from './Leak.module.css';
export function Leak() {
return (
<div className={styles.box}>
<p className="note">Leak の中の段落</p>
</div>
);
}
export function Other() {
return <p id="other" className="note">ほかのコンポーネントの段落</p>;
}
Other は Leak.module.css を読み込んでいません。それでも、結果は次のとおりでした。
{"text":"Leak の中の段落","class":"note","color":"rgb(216, 88, 120)", ... ,"fontWeight":"700"}
{"text":"ほかのコンポーネントの段落","class":"note","color":"rgb(216, 88, 120)", ... ,"fontWeight":"400"}
p の指定は名前が変わらずに残り(ビルドした CSS でも p{color:#d85878} のまま)、Other の段落までピンクになりました。一方、太字は Leak の中の段落だけです。ビルドした CSS では ._box_1tjq0_6 .note{font-weight:700} になっていて、.note の名前は変わらず、前の .box だけがファイル固有の名前になっていました。CSS Modules の説明にある、:global の項目のとおり、:global(.note) はページ全体の .note を指します。前にローカルなクラスを置くと、効く範囲をそのクラスの中に絞れます。
外部のライブラリが付けるクラス名のように、自分では名前を変えられないクラス名に指定を足すときにも、この .box :global(.note) の形が使えます。
CSS Modules で、ぶつからない書き方にするポイント
ファイル名を .module.css にし、import styles from './Card.module.css' で読み込んで、className={styles.title} のように渡します。これで、別のファイルに同じクラス名があっても、付くクラス名はファイルごとに別のものになります。
要素名だけのセレクターは名前が変わらずページ全体に効くので、.module.css の中では、要素名だけのセレクターを書かず、必ずクラスを起点にします。:global を使うときも、.box :global(.note) のように前にローカルなクラスを置きます。vite/client の型ではクラス名の書き間違いも、localsConvention を変えたあとの古い書き方も見つからないので、スタイルが当たらないときは、要素に付いたクラス名を確かめます。





