April 29, 2026

CSS columnsの表示順を行優先に変えるMasonryレイアウトの実装

写真ギャラリーのようなレイアウトを作るとき、CSSの columns プロパティはとても便利です。 ただ、使ってみると写真の表示順が「上から下、次の列へ」となり、直感的な「左から右、次の行へ」の順番にならなくて困った経験はないでしょうか。

今回は、行優先の表示順をベースにしつつ、高さの異なる要素をバランスよく配置するMasonryレイアウトをReactで実装する方法を紹介します。

CSS columnsの表示順の問題

写真ギャラリーを実装する際、CSSの columns はとても手軽です。

.gallery {
  columns: 3;
  gap: 16px;
}

.gallery-item {
  break-inside: avoid;
  margin-bottom: 16px;
}

これだけで、高さの異なる要素が隙間なく配置される、いわゆるMasonry風のレイアウトが完成します。

課題

しかし、columns には大きな問題があります。要素の並び順が列方向(上→下→次の列)になることです。

例えば、画像が6枚あって3列の場合:

CSS columns の表示順:
┌─────────┬─────────┬─────────┐
│  画像1   │  画像3   │  画像5   │
│  画像2   │  画像4   │  画像6   │
└─────────┴─────────┴─────────┘

直感的に期待する順番は、左上から右に向かって並ぶ行優先の順番ですよね。

期待する表示順:
┌─────────┬─────────┬─────────┐
│  画像1   │  画像2   │  画像3   │
│  画像4   │  画像5   │  画像6   │
└─────────┴─────────┴─────────┘

CSS Gridで解決できる?

「それなら CSS Grid を使えばいいのでは?」と思うかもしれません。

.gallery {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

CSS Gridなら表示順は行優先になります。 ですが、各行の高さが最も高い要素に合わせられてしまうため、横長の画像と縦長の画像が混在すると大きな隙間が生まれてしまいます。

CSS Grid の問題:
┌─────────┬─────────┬─────────┐
│         │         │█████████│
│  画像1   │  画像2   │█画像3██│
│         │         │█████████│
│         │         │(隙間)  │ ← 縦長の画像1に高さが揃えられる
└─────────┴─────────┴─────────┘

つまり、CSS columnsは隙間なしだけど順番が列優先、CSS Gridは順番が行優先だけど隙間ができるというトレードオフがあります。

解決策: JavaScriptで各カラムに振り分け

両方の要件を満たすには、JavaScriptで要素を各カラムに振り分けるのが効果的です。

最初はシンプルに、要素をインデックス順に index % カラム数 で各列に配分する「ラウンドロビン方式」を検討しました。

ラウンドロビン方式の限界

ラウンドロビン方式の振り分け:

画像1 → 列1    画像2 → 列2    画像3 → 列3
画像4 → 列1    画像5 → 列2    画像6 → 列3

この方式は実装が非常に簡単ですが、画像ごとの高さに大きな差がある場合、カラムごとの合計高さが不揃いになるという欠点があります。

例えば、画像1が非常に縦に長く、他の画像が横長だった場合、列1だけが極端に長くなり、ページ下部に大きな空白が生まれてしまいます。

改善版: 最短列優先(Shortest Column First)アルゴリズム

そこで、現在の合計高さが最も低いカラムに次のアイテムを追加するというアルゴリズムを採用しました。

  1. アイテムごとの「正規化高さ(高さ / 幅)」を計算しておく。
  2. 各カラムの現在の合計高さを保持する。
  3. 次のアイテムを追加する際、合計高さが最小のカラムを選択して追加する。

これにより、表示順を行優先の自然な流れに保ちつつ、各カラムの高さが均等になるように最適化されます。

実装

Reactコンポーネントとして実装してみましょう。

"use client";

import { useState, useEffect, ReactNode, Children } from "react";

// ブレークポイントに合わせたカラム数設定
const BREAKPOINTS = [
  { minWidth: 1536, columns: 4 },
  { minWidth: 1280, columns: 3 },
  { minWidth: 640, columns: 2 },
  { minWidth: 0, columns: 1 },
];

function getColumnCount(width: number): number {
  for (const bp of BREAKPOINTS) {
    if (width >= bp.minWidth) return bp.columns;
  }
  return 1;
}

/**
 * 最短列優先アルゴリズムによるMasonryレイアウト
 * @param itemHeights - 各childに対応する正規化高さの配列(height / width)
 */
export function MasonryGrid({
  children,
  itemHeights,
}: {
  children: ReactNode[];
  itemHeights: number[];
}) {
  const [columnCount, setColumnCount] = useState(1);

  useEffect(() => {
    function handleResize() {
      setColumnCount(getColumnCount(window.innerWidth));
    }
    handleResize();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  const columns: ReactNode[][] = Array.from({ length: columnCount }, () => []);
  const columnHeights = new Array(columnCount).fill(0);

  Children.toArray(children).forEach((child, index) => {
    // 合計高さが最も少ない列のインデックスを見つける
    let shortestColIndex = columnHeights.indexOf(Math.min(...columnHeights));
    if (shortestColIndex === -1) shortestColIndex = 0;

    columns[shortestColIndex].push(child);
    
    // アイテムの高さを加算して合計を更新
    const height = itemHeights[index];
    columnHeights[shortestColIndex] += (typeof height === "number" && !isNaN(height)) ? height : 1;
  });

  return (
    <div className="flex gap-4">
      {columns.map((columnItems, colIndex) => (
        <div key={colIndex} className="flex-1 flex flex-col gap-4">
          {columnItems}
        </div>
      ))}
    </div>
  );
}

使い方

呼び出し側では、あらかじめ画像のアスペクト比から高さを計算して渡します。

async function ImagesContainer(props: { place: string }) {
  const images = await getImages({ folderName: props.place });

  // アスペクト比の逆数(height / width)を正規化高さとして使用
  const itemHeights = images.map(
    (image) => Number(image.height) / Number(image.width)
  );

  return (
    <MasonryGrid itemHeights={itemHeights}>
      {images.map((image) => (
        <PlaceImageCard key={image.id} image={image} place={props.place} />
      ))}
    </MasonryGrid>
  );
}

実際の例

写真ポートフォリオサイトでこのMasonryレイアウトを使ってみました。

https://aakira.studio

beforeは縦に並んでいますが、afterは横に並んでいます。 今回の改善で、写真の高さがバラバラでもカラムごとの高さが揃うようになり、ページ末尾の空白も解消されました。

  • before
before
  • after
after

各アプローチの比較

方式表示順隙間カラムの高さJS不要
CSS columns列優先なし均等
CSS Grid行優先あり不揃い
ラウンドロビン行優先なし不揃い
最短列優先行優先に近いなし均等

最短列優先アルゴリズムは、厳密なインデックス順(1, 2, 3…)からは少し前後する可能性がありますが、視覚的な並び順の自然さとレイアウトの美しさを高い次元で両立できます。

補足: CSS Grid の masonry 値について

実はCSSの仕様として grid-template-rows: masonry という値が提案されています 。 これが正式に採用されれば、CSS Gridだけで行優先かつ隙間のないMasonryレイアウトが実現できるようになります。

/* 将来的にはこれだけでOKになるかもしれない */
.gallery {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: masonry;
  gap: 16px;
}

2026年4月時点ではFirefoxでフラグ付きで試せる段階で、まだ安定して使える状況ではありません。 ブラウザの対応が進むまでは、今回紹介したJavaScriptベースの方法が現実的な解決策になるでしょう。

まとめ

CSS columns は手軽ですが表示順に課題があります。 JavaScriptを用いた「最短列優先」の振り分けアルゴリズムを導入することで、ユーザーにとって自然な表示順を維持しつつ、カラムの高さが揃った美しいMasonryレイアウトを実現できました。

ポートフォリオサイトのように、写真の向き(縦・横)が混在するケースでは特におすすめの手法です。 同じような課題を抱えている方の参考になれば幸いです!

© AAkira