📱

CSS Modulesにおけるシンプルなレスポンシブ対応

に公開

本記事では、CSS ModulesとVite環境下におけるシンプルなレスポンシブの手法を紹介します。また、将来的に導入可能なコンテナスタイルクエリやif()関数を用いて、さらに記述を簡潔にできることにも触れます。

課題

レスポンシブ対応には、一般的にメディアクエリが使われます。しかし、ReactコンポーネントのCSS Modulesファイルごとにメディアクエリを書くと、768pxのようなブレークポイント定義が散在し、統一性が損なわれる恐れがあります。

メディアクエリの例
.foo {
  /* スマホ版の記述 */
}
@media (max-width: 768px) {
  .foo {
    /* タブレット版の記述 */
  }
}
@media (max-width: 1280px) {
  .foo {
    /* PC版の記述 */
  }
}

TypeScript側にも条件分岐が必要な場合、そちらでもブレークポイントの定義が必要になります。ブレークポイント定義を一元管理しつつ、簡潔に記述したいところです。

シナリオ

  • React, TypeScript, Vite
  • SPA
  • CSS Modulesでコンポーネントにスタイルを適用する
  • レスポンシブ対応
    • ブレークポイントはアプリ全体で統一(コンポーネント毎に異なることはない)
    • PC、タブレット、スマホの3種類

手法

  1. ブレークポイントクラス(screen-pc, screen-tab, screen-sp)をグローバルセレクタとして定義する。
  2. ハッシュ化回避のため、vite.config.tsのcss.modules.generateScopedNameをカスタマイズする。
  3. 画面サイズを判定し、ブレークポイントクラス名を返すカスタムフックを定義する。
  4. ルート要素などで使うラッパーコンポーネントを作成する。
  5. CSS Module内でブレークポイントクラス使ってスタイルを記述する。(例:.screen-pc .foo {...})または、コンポーネント内でカスタムフックを使って条件分岐する。

1. ブレークポイントクラスの定義

レスポンシブ対応用のブレークポイントクラス(screen-pc, screen-tab, screen-sp)を決めます。

各ブレークポイントのグローバルなスタイル情報は、次のようにCSS変数で定義できます。

styles/global.css
.screen-sp {
  --spacing-small: 8px;
  --spacing-large: 16px;
  --font-size-body: 14px;
}

.screen-tab {
  --spacing-small: 12px;
  --spacing-large: 24px;
  --font-size-body: 16px;
}

.screen-pc {
  --spacing-small: 16px;
  --spacing-large: 32px;
  --font-size-body: 18px;
}

2. ブレークポイントクラスのハッシュ化除外

ブレークポイントクラスはグローバルクラスとして扱うため、CSS Modulesのハッシュ化から除外する必要があります。
そのため、vite.config.ts の css.modules.generateScopedName をカスタマイズします。

vite.config.ts
import crypto from 'node:crypto';
import { defineConfig } from 'vite';

export default defineConfig({
  // (他の設定)

  css: {
    modules: {
      generateScopedName: (name, filename, css) => {
        // screen-pc, screen-tab, screen-sp はハッシュ化しない
        if (/^screen-(pc|tab|sp)$/.test(name)) {
          return name;
        }

        // 上記以外はハッシュ化する
        // ここでは [name]_[hash:base64:6] のような形式は使えないので、自前でハッシュを生成する
        const hash = crypto.createHash('sha1').update(`${name}${filename}${css}`).digest('hex').slice(0, 6);
        return `${name}_${hash}`;
      },
    },
  },
});

3. useResponsiveフック

画面サイズを判定し、対応するブレークポイントクラスを返すカスタムフックを作成します。ここではreact-responsiveを使っています。
768のような数値定義を、このフック一箇所に集約できます。

hooks/useResponsive.ts
import { useMediaQuery } from 'react-responsive';

type ScreenType = 'pc' | 'tab' | 'sp';

type ScreenClassName = `screen-${ScreenType}`;

/**
 * 画面サイズに応じてブレークポイントクラスを返すフック
 *
 * - **PC**: 1280px以上
 * - **Tab**: 768px以上1280px未満
 * - **SP**: 768px未満
 */
export function useResponsive(): { screenType: ScreenType; screenClassName: ScreenClassName } {
  const isPc = useMediaQuery({ minWidth: 1280 });
  const isTablet = useMediaQuery({ minWidth: 768 });

  const screenType: ScreenType = isPc ? 'pc' : isTablet ? 'tab' : 'sp';
  const screenClassName: ScreenClassName = `screen-${screenType}`;

  return { screenType, screenClassName };
}

4. Responsiveコンポーネント

ブレークポイントクラスを付与するラッパーコンポーネントを作成します。

components/Responsive.tsx
import { ReactNode } from 'react';
import { useResponsive } from '../hooks/useResponsive';

/**
 * レスポンシブ対応のためのラッパー
 */
export function Responsive({ children }: { children: ReactNode }) {
  const { screenClassName } = useResponsive();

  return <div className={screenClassName}>{children}</div>;
}

アプリのルートを<Responsive>でラップします。これで祖先要素にクラスが付与され、各CSSファイルで参照可能になります。

main.tsx
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Responsive } from './components/Responsive';
import { routeTree } from './routeTree.gen';
import './styles/global.css';

const router = createRouter({ routeTree });

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <Responsive>
      <RouterProvider router={router} />
    </Responsive>
  </StrictMode>,
);

5.コンポーネントの実装

定義したクラスとフックを使ったコンポーネントの実装例です。

CSS内でブレークポイントクラスを使用するパターン

レイアウトコンポーネントの例です。PC・タブレットは2カラム表示、スマホは1カラム表示にします。
CSS内でブレークポイントクラスを使用し、画面サイズごとに適用するスタイルを切り替えます。

PageLayout.tsx
import type { ReactNode } from 'react';
import classes from './PageLayout.module.css';

type Props = {
  main: ReactNode;
  sidebar: ReactNode;
};

/**
 * レスポンシブ対応のレイアウトコンポーネント
 * 
 * - PC: 2カラム(サイドバー幅400px)
 * - タブレット: 2カラム(サイドバー幅200px)
 * - スマホ: 1カラム(サイドバーの内容を下に配置)
 */
export function PageLayout({ main, sidebar }: Props) {
  return (
    <div className={classes.layout}>
      <main className={classes.main}>{main}</main>
      <aside className={classes.sidebar}>{sidebar}</aside>
    </div>
  );
}
PageLayout.module.css
.layout {
  width: 100%;
  display: grid;

  /* 画面サイズによって値が変わります */
  padding: var(--spacing-large);
  gap: var(--spacing-small);
}

/* 画面サイズごとのレイアウト(定義漏れ防止のため全パターンでクラス名を記述しています) */
.screen-pc .layout {
  grid-template-columns: 1fr 400px;
}
.screen-tab .layout {
  grid-template-columns: 1fr 200px;
}
.screen-sp .layout {
  grid-template-columns: 1fr;
}

コンポーネント内でuseResponsiveを使用するパターン

商品画像コンポーネントの例です。PC・タブレットはスライダー、スマホは縦並び表示にします。
画面サイズごとにレンダリングするコンポーネントを切り替えるため、コンポーネント内でuseResponsiveを使います。

ImageGallery.tsx
import { useResponsive } from '../../hooks/useResponsive';
import { Slider } from './components/Slider';
import classes from './ImageGallery.module.css';

type Props = {
  images: ReadonlyArray<{ id: string; url: string; alt: string }>;
};

/**
 * 商品画像コンポーネント
 * 
 * - PC・タブレット: スライダー
 * - スマホ: 画像を縦に並べる
 */
export function ImageGallery({ images }: Props) {
  const { screenType } = useResponsive();

  // スマホの場合縦並び表示
  if (screenType === 'sp') {
    return (
      <div className={classes['sp-list']}>
        {images.map((img) => (
          <img src={img.url} alt={img.alt} className={classes.image} key={img.id} />
        ))}
      </div>
    );
  }

  // PC・タブレットの場合スライダー表示
  return <Slider images={images} />;
}
ImageGallery.module.css
.sp-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: 4px;
}

.image {
  width: 100%;
  height: auto;
}

body直下に要素を追加するライブラリを使用する場合

モーダルなどbody直下に要素を生成するライブラリは、表示内容を別途<Responsive>でラップする必要があります。

MyModal.tsx
import type { ReactNode } from 'react';
import { Modal, ModalOverlay, Dialog } from 'react-aria-components';
import { Responsive } from './components/Responsive';

type Props = {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
};

export function MyModal({ isOpen, onClose, children }: Props) {
  return (
    <ModalOverlay isOpen={isOpen} onOpenChange={onClose}>
      <Modal>
        <Responsive> {/* ★ */}
          <Dialog>{children}</Dialog>
        </Responsive>
      </Modal>
    </ModalOverlay>
  );
}

将来的な展望

コンテナスタイルクエリやif()関数を使うと、本記事の手法と同様のことがより簡潔に記述できます。
ブラウザ対応状況から採用を見送りましたが、普及したら本記事の手法よりもこれらを使うことを推奨します。

コンテナスタイルクエリを使った方法

親要素のスタイルに基いて、適用するスタイルを切り替える機能です。
下記は、先ほどのレイアウトコンポーネントをコンテナスタイルクエリを使って書き換えた例です。
Responsiveコンポーネントではグローバルクラスの代わりにCSS変数 --screen-type を定義します。

Responsive.tsx
(略)

export function Responsive({ children }: { children: ReactNode }) {
  const { screenType } = useResponsive();

  return <div style={{ '--screen-type': screenType }}>{children}</div>
}

本記事の方法では.screen-pc .layoutのようにブレークポイントクラスを祖先要素として書く必要がありましたが、コンテナスタイルクエリでは.layout内に直接記述できます。

PageLayout.module.css
.layout {
  /* (略) */

  @container style(--screen-type: pc) {
    grid-template-columns: 1fr 400px;
  }
  @container style(--screen-type: tab) {
    grid-template-columns: 1fr 200px;
  }
  @container style(--screen-type: sp) {
    grid-template-columns: 1fr;
  }
}

2025年11月現在、Chrome/EdgeとSafariが対応していますが、Firefoxは未対応です。

if()関数を使った方法

CSSの値定義内で条件分岐できる関数です。記述がさらに簡潔になります。

PageLayout.module.css
.layout {
  (略)

  grid-template-columns: if(
    style(--screen-type: pc): 1fr 400px;
    style(--screen-type: tab): 1fr 200px;
    style(--screen-type: sp): 1fr;
  );
}

2025年11月現在、Chrome/Edgeが対応していますが、SafariとFirefoxは未対応です。

比較表

本記事の方法 コンテナスタイルクエリ if()関数
ハッシュ化からの除外処理 必要 不要 不要
条件の記述位置 クラスを祖先セレクタとして使用 ルールセット内でCSS変数を参照 プロパティ値内でCSS変数を参照
記述の簡潔さ やや煩雑 簡潔 非常に簡潔
2025/11現在の対応状況 全モダンブラウザが対応 Firefox未対応 SafariとFirefox未対応

Discussion