T-CREATOR

Tailwind CSS コンテナクエリ即戦力レシピ:container/size/inline-size の使いどころ

Tailwind CSS コンテナクエリ即戦力レシピ:container/size/inline-size の使いどころ

レスポンシブデザインの新しい武器として、コンテナクエリが注目を集めています。従来のメディアクエリがビューポート全体のサイズを基準としていたのに対し、コンテナクエリは親要素のサイズに応じてスタイルを変更できる画期的な機能です。

Tailwind CSS v3.2 以降では、このコンテナクエリが標準機能として組み込まれました。しかし、container-typesizeinline-size の違いや使い分けに迷う方も多いのではないでしょうか。

本記事では、実務で即活用できるコンテナクエリのレシピを、具体的なコード例とともに解説します。それぞれの設定値の特性を理解し、適材適所で使い分けられるようになりましょう。

コンテナクエリ早見表

コンテナクエリの主要な設定値を一覧でまとめました。この表を参考に、用途に応じた最適な設定を選択できます。

#設定値監視対象主な用途設定クラス例ブレイクポイント例
1container-type: size幅と高さ両方カードコンポーネント、モーダル、縦横比を保つ要素@container​/​main@sm:, @md:, @lg:
2container-type: inline-size幅のみ(横方向)サイドバー、リスト項目、レスポンシブカラム@container​/​sidebar@sm:, @md:, @lg:
3container-type: normal監視なし(デフォルト)コンテナクエリを使わない通常要素指定不要-

各設定の特徴比較

#項目sizeinline-sizenormal
1パフォーマンス★★☆ やや重い★★★ 軽量★★★ 最軽量
2レイアウト影響あり(高さ計算に影響)最小限なし
3使用頻度★★☆ 中程度★★★ 高い★☆☆ 限定的
4縦方向対応✓ 対応✗ 非対応✗ 非対応
5横方向対応✓ 対応✓ 対応✗ 非対応

背景

コンテナクエリが生まれた理由

Web デザインの世界では長年、メディアクエリを使ってレスポンシブデザインを実現してきました。しかし、メディアクエリには根本的な課題がありました。

ビューポート全体のサイズしか参照できないため、コンポーネント自体の配置場所によってデザインを変えることができなかったのです。例えば、同じカードコンポーネントでも、メインカラムとサイドバーでは利用可能な幅が全く異なります。

mermaidflowchart TB
    viewport["ビューポート<br/>(画面全体)"]
    main["メインカラム<br/>(幅: 800px)"]
    sidebar["サイドバー<br/>(幅: 300px)"]
    card1["カードコンポーネント"]
    card2["カードコンポーネント"]

    viewport --> main
    viewport --> sidebar
    main --> card1
    sidebar --> card2

    style viewport fill:#e3f2fd
    style main fill:#c8e6c9
    style sidebar fill:#fff9c4
    style card1 fill:#ffccbc
    style card2 fill:#ffccbc

上図のように、同じカードコンポーネントでも配置される場所によって利用可能なスペースが異なります。メディアクエリでは画面全体のサイズしか判断できないため、この違いに対応できませんでした。

コンテナクエリの登場

CSS Container Queries の仕様が策定され、2022 年後半から主要ブラウザで実装が進みました。これにより、親要素のサイズを基準としたスタイル適用が可能になったのです。

Tailwind CSS も v3.2 でこの機能を取り入れ、開発者が簡単にコンテナクエリを活用できる環境が整いました。

課題

container-type の選択肢と混乱

コンテナクエリを使う際、最初に設定する container-type プロパティには主に 3 つの値があります。

typescript// CSS での記述例
.container {
  container-type: size;        // 幅と高さ両方を監視
  container-type: inline-size; // 幅のみを監視
  container-type: normal;      // 通常要素(デフォルト)
}

この選択肢の違いを理解せずに使うと、以下のような問題が発生します。

よくある失敗パターン

パターン 1:size を使いすぎる

幅だけを監視したい場面で size を指定してしまうと、不要な高さの監視も行われます。

typescript// 問題のあるコード例
<div className='@container-size'>
  {/* 幅だけ変化させたいのに高さも監視される */}
  <div className='@md:flex-row flex-col'>
    <p>コンテンツ</p>
  </div>
</div>

これにより、パフォーマンスの低下やレイアウト計算の複雑化を招く可能性があります。

パターン 2:inline-size の理解不足

inline-size は横書きの場合は幅を、縦書きの場合は高さを監視します。この特性を理解せずに使うと、意図しない挙動になることがあります。

typescript// 横書きと縦書きで挙動が変わる
<div
  className='@container'
  style={{ writingMode: 'vertical-rl' }}
>
  {/* 縦書きの場合、inline-size は高さを監視 */}
  <p className='@lg:text-xl'>テキスト</p>
</div>

パターン 3:ブレイクポイントの混在

メディアクエリとコンテナクエリのブレイクポイントを混在させると、コードの可読性が低下します。

typescript// 混乱を招くコード例
<div className='md:grid-cols-2 @lg:grid-cols-3'>
  {/* md: はビューポート、@lg: はコンテナ */}
  {/* どちらを基準にしているか分かりにくい */}
</div>

これらの課題を解決するには、それぞれの container-type の特性を理解し、適切に使い分ける必要があります。

解決策

container-type の使い分け原則

3 つの設定値を適切に使い分けるための明確な指針を示します。

inline-size を基本とする

結論から言えば、ほとんどのケースで inline-size を使うのが正解です。

理由は以下の通りです。

typescript// 推奨:inline-size を基本とする
<div className='@container'>
  {/* 幅に応じてレイアウトを変更 */}
  <div className='@sm:flex-row @lg:grid-cols-2 flex-col'>
    <Card />
  </div>
</div>

inline-size の利点:

  1. パフォーマンスへの影響が最小限
  2. Web デザインの大半は横幅でレスポンシブ対応
  3. レイアウト計算がシンプル

size を使うべき場面

縦横両方のサイズに応じてスタイルを変更したい特殊なケースでのみ size を使います。

typescript// size が適切なケース:アスペクト比を保つコンポーネント
<div className='@container/card'>
  <div className='aspect-square'>
    {/* 正方形を保ちつつ、縦横のサイズで表示を変える */}
    <img
      className='@sm:object-cover @md:object-contain object-fill'
      src='/image.jpg'
      alt='レスポンシブ画像'
    />
  </div>
</div>

size を使うべき具体例:

  1. 縦横比を保つ必要があるカードやサムネイル
  2. モーダルやダイアログで縦横のサイズに応じて配置を変える
  3. グラフやチャートで縦横のサイズに応じて要素数を調整

Tailwind CSS での実装パターン

Tailwind CSS では @container ユーティリティでコンテナクエリを設定します。

基本的な設定方法

typescript// 1. コンテナの定義
<div className='@container'>
  {/* この要素がコンテナになる(inline-size) */}
</div>
typescript// 2. 名前付きコンテナ
<div className='@container/main'>
  {/* 名前をつけることで、ネストしたコンテナを区別 */}
</div>
typescript// 3. size を使う場合
<div className='@container-size/card'>
  {/* 縦横両方を監視 */}
</div>

ブレイクポイントの活用

コンテナクエリのブレイクポイントは @ プレフィックスで表現します。

typescript// コンテナサイズに応じたスタイル変更
<div className='@container'>
  <div
    className='
    @sm:grid-cols-1
    @md:grid-cols-2
    @lg:grid-cols-3
    @xl:grid-cols-4
    grid
  '
  >
    <Item />
  </div>
</div>

デフォルトのブレイクポイント:

ブレイクポイント最小幅
@sm384px
@md448px
@lg512px
@xl576px
@2xl672px

カスタムブレイクポイントの設定

プロジェクトの要件に応じて、独自のブレイクポイントを定義することもできます。

javascript// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      containers: {
        '2xs': '16rem', // 256px
        xs: '20rem', // 320px
        sm: '24rem', // 384px
        md: '28rem', // 448px
        lg: '32rem', // 512px
      },
    },
  },
};
typescript// カスタムブレイクポイントの使用
<div className='@container'>
  <p className='@2xs:text-sm @xs:text-base @sm:text-lg'>
    コンテナサイズに応じてフォントサイズが変わります
  </p>
</div>

このように、container-type の特性を理解し、適切に使い分けることで、保守性の高い効率的なコードが書けるようになります。

具体例

実務で頻出する 5 つのパターンを、実装コード付きで解説します。すぐにプロジェクトで活用できるレシピとしてご活用ください。

レシピ 1:レスポンシブカードグリッド

サイドバーにもメインカラムにも配置できる、柔軟なカードグリッドを実装します。

設定のポイント

inline-size を使用し、コンテナの幅に応じてカラム数を変更します。

typescript// コンテナの定義
import React from 'react';

interface CardGridProps {
  children: React.ReactNode;
}

export const CardGrid: React.FC<CardGridProps> = ({
  children,
}) => {
  return (
    <div className='@container'>
      {/* コンテナクエリを有効化 */}
      <div
        className='
        grid
        gap-4
        @sm:grid-cols-1
        @md:grid-cols-2
        @lg:grid-cols-3
        @xl:grid-cols-4
      '
      >
        {children}
      </div>
    </div>
  );
};
typescript// カードコンポーネント
interface CardProps {
  title: string;
  description: string;
  imageUrl: string;
}

export const Card: React.FC<CardProps> = ({
  title,
  description,
  imageUrl,
}) => {
  return (
    <div
      className='
      rounded-lg
      border
      border-gray-200
      overflow-hidden
      @sm:flex-col
      @md:flex-row
      flex
    '
    >
      {/* 画像エリア */}
      <img
        src={imageUrl}
        alt={title}
        className='@sm:h-48 @md:h-auto @md:w-32 w-full object-cover'
      />

      {/* コンテンツエリア */}
      <div className='p-4'>
        <h3 className='@sm:text-lg @md:text-xl font-bold mb-2'>
          {title}
        </h3>
        <p className='@sm:text-sm @md:text-base text-gray-600'>
          {description}
        </p>
      </div>
    </div>
  );
};
typescript// 使用例
export const ProductList = () => {
  const products = [
    {
      id: 1,
      title: '商品A',
      description: '説明文...',
      imageUrl: '/a.jpg',
    },
    {
      id: 2,
      title: '商品B',
      description: '説明文...',
      imageUrl: '/b.jpg',
    },
    // ...
  ];

  return (
    <CardGrid>
      {products.map((product) => (
        <Card key={product.id} {...product} />
      ))}
    </CardGrid>
  );
};

このパターンでは、カードグリッドがサイドバー(狭い領域)に配置された場合は 1 カラム、メインエリア(広い領域)に配置された場合は最大 4 カラムまで自動的に調整されます。

レシピ 2:サイドバーナビゲーション

コンテナの幅に応じて、アイコン表示とフルテキスト表示を切り替えるナビゲーションです。

実装の流れ

typescript// ナビゲーションコンテナ
export const Sidebar = () => {
  return (
    <aside className='@container/sidebar h-screen bg-gray-50'>
      {/* サイドバー全体をコンテナに設定 */}
      <nav className='p-4'>
        <NavItems />
      </nav>
    </aside>
  );
};
typescript// ナビゲーションアイテム
interface NavItemProps {
  icon: React.ReactNode;
  label: string;
  href: string;
}

const NavItem: React.FC<NavItemProps> = ({
  icon,
  label,
  href,
}) => {
  return (
    <a
      href={href}
      className='
        flex
        items-center
        gap-3
        rounded-md
        p-3
        hover:bg-gray-200
        @sm/sidebar:justify-start
        @md/sidebar:justify-start
        justify-center
      '
    >
      {/* アイコンは常に表示 */}
      <span className='text-xl'>{icon}</span>

      {/* ラベルはコンテナが十分広いときのみ表示 */}
      <span className='@sm/sidebar:inline hidden'>
        {label}
      </span>
    </a>
  );
};
typescript// ナビゲーションリスト
const NavItems = () => {
  const items = [
    { icon: '🏠', label: 'ホーム', href: '/' },
    {
      icon: '📊',
      label: 'ダッシュボード',
      href: '/dashboard',
    },
    { icon: '⚙️', label: '設定', href: '/settings' },
  ];

  return (
    <ul className='space-y-2'>
      {items.map((item) => (
        <li key={item.href}>
          <NavItem {...item} />
        </li>
      ))}
    </ul>
  );
};

サイドバーが折りたたまれた状態(狭い)ではアイコンのみ、展開された状態(広い)ではアイコンとラベルの両方が表示されます。

レシピ 3:レスポンシブテーブル

テーブルをコンテナの幅に応じて、カード表示とテーブル表示で切り替えます。

データ定義

typescript// テーブルのデータ型
interface User {
  id: number;
  name: string;
  email: string;
  role: string;
  status: 'active' | 'inactive';
}
typescript// サンプルデータ
const users: User[] = [
  {
    id: 1,
    name: '山田太郎',
    email: 'yamada@example.com',
    role: '管理者',
    status: 'active',
  },
  {
    id: 2,
    name: '佐藤花子',
    email: 'sato@example.com',
    role: 'ユーザー',
    status: 'active',
  },
  // ...
];

レスポンシブ実装

typescript// テーブルコンテナ
export const UserTable: React.FC<{ users: User[] }> = ({
  users,
}) => {
  return (
    <div className='@container'>
      {/* デスクトップ:テーブル表示 */}
      <table className='@lg:table hidden w-full'>
        <thead>
          <tr className='border-b'>
            <th className='p-3 text-left'>名前</th>
            <th className='p-3 text-left'>
              メールアドレス
            </th>
            <th className='p-3 text-left'>役割</th>
            <th className='p-3 text-left'>ステータス</th>
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <TableRow key={user.id} user={user} />
          ))}
        </tbody>
      </table>

      {/* モバイル:カード表示 */}
      <div className='@lg:hidden space-y-4'>
        {users.map((user) => (
          <UserCard key={user.id} user={user} />
        ))}
      </div>
    </div>
  );
};
typescript// テーブル行コンポーネント
const TableRow: React.FC<{ user: User }> = ({ user }) => {
  return (
    <tr className='border-b hover:bg-gray-50'>
      <td className='p-3'>{user.name}</td>
      <td className='p-3'>{user.email}</td>
      <td className='p-3'>{user.role}</td>
      <td className='p-3'>
        <StatusBadge status={user.status} />
      </td>
    </tr>
  );
};
typescript// カード表示コンポーネント
const UserCard: React.FC<{ user: User }> = ({ user }) => {
  return (
    <div className='rounded-lg border border-gray-200 p-4'>
      <div className='mb-2 flex items-center justify-between'>
        <h3 className='text-lg font-bold'>{user.name}</h3>
        <StatusBadge status={user.status} />
      </div>
      <dl className='space-y-1'>
        <div className='flex'>
          <dt className='w-24 text-gray-500'>メール:</dt>
          <dd>{user.email}</dd>
        </div>
        <div className='flex'>
          <dt className='w-24 text-gray-500'>役割:</dt>
          <dd>{user.role}</dd>
        </div>
      </dl>
    </div>
  );
};
typescript// ステータスバッジ
const StatusBadge: React.FC<{ status: User['status'] }> = ({
  status,
}) => {
  const styles =
    status === 'active'
      ? 'bg-green-100 text-green-800'
      : 'bg-gray-100 text-gray-800';

  return (
    <span
      className={`rounded-full px-3 py-1 text-sm ${styles}`}
    >
      {status === 'active' ? 'アクティブ' : '非アクティブ'}
    </span>
  );
};

コンテナが狭い場合はカード形式、広い場合は通常のテーブル形式で表示されるため、どのような配置でも読みやすさを保てます。

レシピ 4:アスペクト比対応メディアカード

縦横両方のサイズを監視する size を活用した例です。

size を使う理由

このケースでは、カードの縦横比を保ちながら、サイズに応じてレイアウトを変更します。

typescript// メディアカードコンテナ
export const MediaCard: React.FC<{
  imageUrl: string;
  title: string;
  description: string;
  category: string;
}> = ({ imageUrl, title, description, category }) => {
  return (
    <div className='@container-size/media aspect-video'>
      {/* size を使って縦横両方を監視 */}
      <div
        className='
        relative
        h-full
        w-full
        overflow-hidden
        rounded-lg
        @sm/media:flex-col
        @md/media:flex-row
        flex
      '
      >
        <MediaImage url={imageUrl} />
        <MediaContent
          title={title}
          description={description}
          category={category}
        />
      </div>
    </div>
  );
};
typescript// 画像エリア
const MediaImage: React.FC<{ url: string }> = ({ url }) => {
  return (
    <div
      className='
      @sm/media:h-1/2
      @md/media:h-full
      @md/media:w-1/2
      relative
      w-full
    '
    >
      <img
        src={url}
        alt='メディア画像'
        className='h-full w-full object-cover'
      />
    </div>
  );
};
typescript// コンテンツエリア
const MediaContent: React.FC<{
  title: string;
  description: string;
  category: string;
}> = ({ title, description, category }) => {
  return (
    <div
      className='
      @sm/media:h-1/2
      @md/media:h-full
      @md/media:w-1/2
      flex
      w-full
      flex-col
      justify-between
      p-4
    '
    >
      {/* カテゴリバッジ */}
      <span
        className='
        @sm/media:text-xs
        @md/media:text-sm
        mb-2
        inline-block
        rounded
        bg-blue-100
        px-2
        py-1
        text-blue-800
      '
      >
        {category}
      </span>

      {/* タイトル */}
      <h3
        className='
        @sm/media:text-base
        @md/media:text-xl
        @lg/media:text-2xl
        mb-2
        font-bold
      '
      >
        {title}
      </h3>

      {/* 説明文 */}
      <p
        className='
        @sm/media:line-clamp-2
        @md/media:line-clamp-3
        @lg/media:line-clamp-4
        text-gray-600
      '
      >
        {description}
      </p>
    </div>
  );
};

aspect-video でアスペクト比を固定しつつ、サイズに応じて画像とコンテンツの配置を縦並び・横並びで切り替えています。

レシピ 5:ネストしたコンテナ

複数のコンテナを入れ子にして、より細かい制御を実現します。

構造図

入れ子構造を図解で理解しましょう。

mermaidflowchart TB
    page["ページ全体"]
    mainContainer["メインコンテナ<br/>(@container/main)"]
    section1["セクション1<br/>(@container/section)"]
    section2["セクション2<br/>(@container/section)"]
    card1["カード"]
    card2["カード"]
    card3["カード"]

    page --> mainContainer
    mainContainer --> section1
    mainContainer --> section2
    section1 --> card1
    section1 --> card2
    section2 --> card3

    style page fill:#e3f2fd
    style mainContainer fill:#c8e6c9
    style section1 fill:#fff9c4
    style section2 fill:#fff9c4
    style card1 fill:#ffccbc
    style card2 fill:#ffccbc
    style card3 fill:#ffccbc

実装コード

typescript// ページレイアウト
export const DashboardPage = () => {
  return (
    <div className='@container/main'>
      {/* メインコンテナ */}
      <div
        className='
        @lg/main:grid-cols-2
        @xl/main:grid-cols-3
        grid
        gap-6
        p-6
      '
      >
        <StatsSection />
        <ChartSection />
        <ActivitySection />
      </div>
    </div>
  );
};
typescript// 統計セクション(ネストしたコンテナ)
const StatsSection = () => {
  return (
    <section className='@container/section'>
      <h2 className='mb-4 text-xl font-bold'>統計情報</h2>
      <div
        className='
        @sm/section:grid-cols-1
        @md/section:grid-cols-2
        grid
        gap-4
      '
      >
        <StatCard
          title='売上'
          value='¥1,234,567'
          change='+12%'
        />
        <StatCard
          title='訪問者'
          value='45,678'
          change='+8%'
        />
      </div>
    </section>
  );
};
typescript// 統計カード
const StatCard: React.FC<{
  title: string;
  value: string;
  change: string;
}> = ({ title, value, change }) => {
  const isPositive = change.startsWith('+');

  return (
    <div className='rounded-lg border border-gray-200 p-4'>
      <p className='text-sm text-gray-500'>{title}</p>
      <p
        className='
        @sm/section:text-xl
        @md/section:text-2xl
        my-2
        font-bold
      '
      >
        {value}
      </p>
      <p
        className={`
        text-sm
        ${isPositive ? 'text-green-600' : 'text-red-600'}
      `}
      >
        {change}
      </p>
    </div>
  );
};
typescript// チャートセクション
const ChartSection = () => {
  return (
    <section className='@container/section'>
      <h2 className='mb-4 text-xl font-bold'>売上推移</h2>
      <div
        className='
        @sm/section:h-48
        @md/section:h-64
        @lg/section:h-80
        rounded-lg
        border
        border-gray-200
        p-4
      '
      >
        {/* チャートコンポーネント */}
        <div className='h-full w-full bg-gray-100 flex items-center justify-center'>
          <p className='text-gray-400'>
            チャート表示エリア
          </p>
        </div>
      </div>
    </section>
  );
};
typescript// アクティビティセクション
const ActivitySection = () => {
  return (
    <section className='@container/section'>
      <h2 className='mb-4 text-xl font-bold'>
        最近のアクティビティ
      </h2>
      <ul className='space-y-3'>
        <ActivityItem
          user='山田太郎'
          action='新規注文を作成しました'
          time='2分前'
        />
        <ActivityItem
          user='佐藤花子'
          action='商品をレビューしました'
          time='15分前'
        />
        <ActivityItem
          user='鈴木一郎'
          action='プロフィールを更新しました'
          time='1時間前'
        />
      </ul>
    </section>
  );
};
typescript// アクティビティアイテム
const ActivityItem: React.FC<{
  user: string;
  action: string;
  time: string;
}> = ({ user, action, time }) => {
  return (
    <li
      className='
      @sm/section:flex-col
      @md/section:flex-row
      flex
      items-start
      gap-2
      rounded-lg
      border
      border-gray-200
      p-3
    '
    >
      <div className='flex-1'>
        <p className='font-semibold'>{user}</p>
        <p className='text-sm text-gray-600'>{action}</p>
      </div>
      <span
        className='
        @sm/section:self-start
        @md/section:self-center
        text-xs
        text-gray-400
      '
      >
        {time}
      </span>
    </li>
  );
};

名前付きコンテナ(​/​main​/​section)を使うことで、それぞれのコンテナを明確に区別し、適切なブレイクポイントを適用できます。

まとめ

Tailwind CSS のコンテナクエリは、コンポーネント単位でレスポンシブデザインを実現する強力な機能です。本記事で解説した内容を振り返りましょう。

重要ポイントの再確認

container-type の使い分けは以下の原則に従います。

  1. 基本は inline-size - 幅だけを監視する大半のケースで使用
  2. 特殊な場合に size - 縦横両方のサイズが重要な場合のみ使用
  3. 名前付きコンテナ - ネストする場合は必ず名前を付けて区別

パフォーマンスを考慮することも忘れてはいけません。

  1. 不要な size の使用を避ける
  2. コンテナの数を必要最小限にする
  3. ブレイクポイントは実際に必要な箇所だけに設定

実装の際のチェックリストを用意しました。

#確認項目詳細
1container-type の選択inline-size で十分か、size が必要か
2名前付けの必要性ネストする場合は名前を付けているか
3ブレイクポイントの整合性メディアクエリと混在していないか
4パフォーマンス影響過剰なコンテナ設定になっていないか
5アクセシビリティ小さいサイズでも操作可能か

コンテナクエリは、再利用可能なコンポーネント設計に欠かせない技術となっています。従来のメディアクエリと組み合わせることで、より柔軟で保守性の高い UI を構築できるでしょう。

本記事で紹介したレシピを起点に、プロジェクトに最適なパターンを見つけてください。実際に手を動かして試すことで、コンテナクエリの真価を実感できるはずです。

関連リンク

コンテナクエリをさらに深く学ぶための公式ドキュメントや参考資料をまとめました。