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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?