Reactで、高さの違うカードをすき間なく並べる方法

​​React​CSS,Playwright,ResizeObserver,TypeScript

高さの違うカードを、列の中ですき間なく並べるには、カードを前から順に「いちばん短い列の下」へ置いていきます。React で持つのは、並べる場所の幅と、カードごとの高さだけです。どちらも ResizeObserver で測り、その値からカードの位置を計算して、position: absolute で置きます。

ここでは、この並べ方をする Masonry コンポーネントを作り、CSS の段組み(column-width)で並べた場合と比べました。カードを足したときの動きや、画像があとから読み込まれてカードの高さが変わる場合も確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153(ヘッドレス)です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、カードの位置は Playwright から読み取りました。カードの画像は写真ではなく、pngjs で描いた単色の PNG です。Firefox と Safari、スマートフォンのブラウザ、何百枚ものカードを並べたときの速さ、サーバーでのレンダリング(SSR)、開発用の Strict Mode での動きは試していません。

「カードを追加」を3回押すと、新しいカードが左右の列のうち短いほうの下に入る
「カードを追加」を押すたびに、いちばん短い列の下へカードが入る様子。足したカードと枚数だけを、その瞬間ピンクにしている(カーソルと回数の表示は GIF 用に重ねたもの。ボタンの行も GIF 用に画面の上に固定している)

CSS の段組みでも並ぶが、カードの順番が縦になる

CSS だけで済ませるなら、段組み(マルチカラム)を使う方法があります。column-width で列の幅の目安を決め、カードが列の境目で2つに割れないよう、break-inside: avoid を付けます。

css
.columns { column-width: 220px; column-gap: 16px; }
.columns-item { break-inside: avoid; margin-bottom: 16px; }

幅 800px の画面で12枚を並べると、3列になり、カードの間はどこも 16px でした。ただし、カードは左の列の上から下へ順に入ります。

text
--- 段組み(column-width: 220px)幅 800px ---
列の数: 3(カードの幅 242.7px)
  1列目(左から 0px): カード 1, 2, 3, 4 / カードの間 16.0px / 下端 1319.3px
  2列目(左から 259px): カード 5, 6, 7, 8 / カードの間 16.0px / 下端 1137.3px
  3列目(左から 517px): カード 9, 10, 11, 12 / カードの間 16.0px / 下端 1246.5px
  重なっているカードの組: 0 / 列の下端の差: 182.0px

新しい順に並んだ一覧なら、いちばん新しい 1〜4 枚目が左の列に縦に積まれ、右上には古いカード 9 が来ます。画面を左から右へ読む人には、順番が分かりにくくなります。

さらに、段組みは中身を各列に均等に分けます(column-fill の初期値 balance の動きです)。カードを1枚足すと、分け方がやり直されます。13枚目を足したところ、すでに並んでいたカード 9〜12 の位置が変わりました。

text
段組み: カード 13 を追加 → 左から 517px の列、上から 909.1px / 位置が変わったカード: 4枚(9, 10, 11, 12)

「もっと見る」で下に足していく一覧だと、読んでいる途中のカードが別の列へ移ることになります。

上は CSS の段組みで、左の列にカード 1〜4、まん中の列に 5〜8、右の列に 9〜12 が縦に並ぶ。下はいちばん短い列へ置く並べ方で、上の段にカード 1、2、3 が横に並ぶ
幅 800px での2つの並べ方。番号はカードの順番(撮るときだけ大きく重ねたもの)

いちばん短い列の下へ置いていく Masonry コンポーネント

左から右へ読む順番を保つには、カードを前から順に見ていき、そのときいちばん短い列の下へ置きます。短い列が2つ以上あるときは、左の列を選びます。これを React で書いたのが Masonry です。並べる中身は renderItem で受け取るので、カードの見た目とは切り離してあります。

tsx
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';

type Props<T> = {
  items: T[];
  getKey: (item: T) => string;
  renderItem: (item: T) => ReactNode;
  // 列の幅の目安。この幅を下回らない範囲で、列をいくつ作るかを決める
  columnWidth?: number;
  gap?: number;
};

export function Masonry<T>({ items, getKey, renderItem, columnWidth = 220, gap = 16 }: Props<T>) {
  const containerRef = useRef<HTMLDivElement>(null);
  const observerRef = useRef<ResizeObserver | null>(null);
  const [width, setWidth] = useState(0);
  const [heights, setHeights] = useState(() => new Map<string, number>());

  // 並べる場所の幅を測り、画面の幅が変わるたびに測り直す
  useLayoutEffect(() => {
    const el = containerRef.current!;
    setWidth(el.clientWidth);
    const ro = new ResizeObserver(() => setWidth(el.clientWidth));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // カードの高さを測る。画像が読み込まれたときなど、高さが変わるたびに測り直す
  const measureRef = useCallback((el: HTMLDivElement | null) => {
    if (!el) return;
    observerRef.current ??= new ResizeObserver((entries) => {
      setHeights((prev) => {
        let next = prev;
        for (const entry of entries) {
          const key = (entry.target as HTMLElement).dataset.key!;
          const height = entry.borderBoxSize[0].blockSize;
          if (prev.get(key) === height) continue;
          if (next === prev) next = new Map(prev);
          next.set(key, height);
        }
        // 高さが1つも変わっていなければ、同じ Map を返して再レンダリングを止める
        return next;
      });
    });
    const ro = observerRef.current;
    ro.observe(el);
    return () => ro.unobserve(el);
  }, []);

  // 列の数と1列の幅
  const columns = Math.max(1, Math.floor((width + gap) / (columnWidth + gap)));
  const colWidth = (width - gap * (columns - 1)) / columns;

  // カードを前から順に、いちばん短い列の下へ置いていく
  const colHeights: number[] = new Array(columns).fill(0);
  const positions = items.map((item) => {
    const height = heights.get(getKey(item));
    // まだ高さを測れていないカードは置かない(見えないようにしておく)
    if (height === undefined) return null;
    const col = colHeights.indexOf(Math.min(...colHeights));
    const pos = { x: col * (colWidth + gap), y: colHeights[col] };
    colHeights[col] += height + gap;
    return pos;
  });
  const totalHeight = Math.max(0, Math.max(...colHeights) - gap);

  return (
    <div ref={containerRef} className="masonry" style={{ position: 'relative', height: totalHeight }}>
      {width > 0 &&
        items.map((item, i) => {
          const key = getKey(item);
          const pos = positions[i];
          return (
            <div
              key={key}
              ref={measureRef}
              data-key={key}
              className="masonry-item"
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: colWidth,
                transform: pos ? `translate(${pos.x}px, ${pos.y}px)` : undefined,
                visibility: pos ? 'visible' : 'hidden',
              }}
            >
              {renderItem(item)}
            </div>
          );
        })}
    </div>
  );
}

列の数は、列の幅の目安 columnWidth(220px)を下回らない範囲でいちばん多くなるように決め、余った幅は各列に分けます。幅 800px の画面では、並べる場所の幅が 760px なので、3列で1列 242.7px になりました。

並べる場所の幅は、useLayoutEffect の中で最初に1回読み、あとは ResizeObserver で変わるたびに読み直しています。useLayoutEffect の中で更新した state は、ブラウザが画面を描く前に反映されます。幅が 0 のまま一度描かれることはありません。

カードの高さは、カードを包む div ごとに ResizeObserver で測っています。borderBoxSize の blockSize は、横書きなら枠線までを含めた高さです。高さを測れるのはカードを DOM に入れてからなので、まだ測れていないカードは visibility: hidden で見えなくしておき、測れたら位置を決めて見せます。

監視を始めるのと止めるのは、ref に渡す関数で行っています。React 19 では、この関数が返した関数が、要素が外れるときに呼ばれます。関数を再レンダリングのたびに作り直すと、そのたびに監視の解除と登録し直しが起きるので、useCallback で同じ関数を使い続けるようにしました。また、setHeights では、高さが1つも変わっていなければ前と同じ Map を返しています。set 関数に前と同じ値を渡すと、React はコンポーネントと子の再レンダリングを省きます。そのため、測っても高さが変わらなかったときは、余計な再レンダリングを省けます。

使う側では、カードの配列と、キーを返す関数、カードの描き方を渡します。

tsx
import { useState } from 'react';
import { Masonry } from './Masonry';
import { CardView } from './CardView';
import { makeCard } from './cards';

export function App({ imageSize = true }: { imageSize?: boolean }) {
  const [cards, setCards] = useState(() => Array.from({ length: 12 }, (_, i) => makeCard(i + 1)));
  return (
    <main>
      <div className="toolbar">
        <button onClick={() => setCards((prev) => [...prev, makeCard(prev.length + 1)])}>カードを追加</button>
        <span className="count">{cards.length}枚</span>
      </div>
      <Masonry
        items={cards}
        getKey={(card) => String(card.id)}
        renderItem={(card) => <CardView card={card} imageSize={imageSize} />}
      />
    </main>
  );
}

カードの中身は、画像と見出し、文です。画像には width と height を書いています(imageSize は、書かない場合と比べるために足した props です)。

tsx
import type { Card } from './cards';

// 画像には width と height を書いておく。読み込む前から、縦横比の分の高さが取られる
export function CardView({ card, imageSize = true }: { card: Card; imageSize?: boolean }) {
  return (
    <article className="card">
      <img
        src={`img/${card.imageHeight}.png`}
        alt=""
        {...(imageSize ? { width: 400, height: card.imageHeight } : {})}
      />
      <h2>カード {card.id}</h2>
      <p>{card.text}</p>
    </article>
  );
}
css
.card { background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgb(0 0 0 / 0.15); }
.card img { display: block; width: 100%; height: auto; }

並べた結果と、カードを足したときの動き

幅 800px の画面で12枚を並べた結果です。カード 1、2、3 が上の段に左から並び、そのあとは短い列へ入っていきます。どの列でもカードの間は 16px で、重なりはありませんでした。並べる場所の高さは、いちばん長い2列目の下端と同じ 1327.7px です。

text
--- Masonry(いちばん短い列へ置く)幅 800px ---
列の数: 3(カードの幅 242.7px)
  1列目(左から 0px): カード 1, 5, 7, 10 / カードの間 16.0px / 下端 1196.1px
  2列目(左から 259px): カード 2, 6, 9, 12 / カードの間 16.0px / 下端 1327.7px
  3列目(左から 517px): カード 3, 4, 8, 11 / カードの間 16.0px / 下端 1179.4px
  重なっているカードの組: 0 / 列の下端の差: 148.3px
並べる場所の高さ: 1327.7px

カードを1枚足すと、いちばん短い3列目(下端 1179.4px)の下、1179.4 + 16 = 1195.4px の所に入りました。ほかのカードは動きません。

text
Masonry: カード 13 を追加 → 左から 517px の列、上から 1195.4px / 位置が変わったカード: 0枚

足したカードより前のカードは、同じ順番・同じ高さで計算し直すので、位置は変わりません。下へ足していく一覧と相性のよい並べ方です。

画面の幅を変えると、ResizeObserver が並べる場所の幅の変化を伝え、列の数が変わりました。幅 520px では2列、360px では1列です。列の幅が変わると文の折り返しが変わり、カードの高さも変わりますが、それもカードごとの ResizeObserver で測り直されます。

text
幅 520px:
列の数: 2(カードの幅 236.0px)
  1列目(左から 0px): カード 1, 3, 5, 7, 9, 11, 13 / カードの間 16.0px / 下端 2042.0px
  2列目(左から 252px): カード 2, 4, 6, 8, 10, 12 / カードの間 16.0px / 下端 1880.4px
  重なっているカードの組: 0 / 列の下端の差: 161.6px
幅 360px:
列の数: 1(カードの幅 328.0px)
  1列目(左から 0px): カード 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 / カードの間 16.0px / 下端 4705.6px
  重なっているカードの組: 0 / 列の下端の差: 0.0px

幅を 800px に戻すと、13枚目を足したあとと同じ3列の並びに戻りました。

画像があとから届くと、カードの高さが変わる

カードの画像は、ページより遅れて届くことがあります。検証用のサーバーで画像だけを 600ms 遅らせて、届く前と届いたあとを比べました。

画像に width と height を書いた場合は、届く前からカード 1 の高さが 313.2px で、届いたあとも変わりませんでした。MDN の img 要素の説明にあるとおり、ブラウザは2つの値から縦横比を求め、読み込む前から場所を取ります。CSS で width: 100%; height: auto; にしていても、この縦横比が使われました。

text
--- 画像に width と height を書いた場合(画像は 600ms 遅れて届く)---
読み込む前: 届いていた画像 0枚 / カード 1 の高さ 313.2px
読み込んだあと: カード 1 の高さ 313.2px / 読み込みの前後で高さが変わったカード: 0枚 / 位置が変わったカード: 0枚

書かない場合は、届く前のカード 1 の高さが 94.8px で、画像が届くと 313.2px になりました。12枚すべての高さが変わり、9枚の位置が変わっています。カードは測り直した高さで並べ直されるので重なりはしませんが、読み込みの途中で並びが入れ替わります。

text
--- 画像に width と height を書かない場合(画像は 600ms 遅れて届く)---
読み込む前: 届いていた画像 0枚 / カード 1 の高さ 94.8px
列の数: 3(カードの幅 242.7px)
  1列目(左から 0px): カード 1, 4, 7, 9, 12 / カードの間 16.0px / 下端 515.5px
  2列目(左から 259px): カード 2, 6, 11 / カードの間 16.0px / 下端 473.1px
  3列目(左から 517px): カード 3, 5, 8, 10 / カードの間 16.0px / 下端 494.3px
  重なっているカードの組: 0 / 列の下端の差: 42.4px
読み込んだあと: カード 1 の高さ 313.2px / 高さが変わったカード: 12枚 / 位置が変わったカード: 9枚
ポイント

カードの高さは、置いたあとにも変わります。高さを最初の1回だけ測るのではなく、ResizeObserver で変わるたびに測り直してください。画像には width と height を書いておくと、並びが途中で入れ替わりません。

最初の1回しか測らないとどうなるかも確かめました。ResizeObserver を差し替えて、要素ごとに最初の通知だけをコールバックへ渡すようにしたページです。画像に width と height を書かず、同じように 600ms 遅らせています。出力は、画像が届いたあとの結果だけを載せます(届く前の結果は、width と height を書かない場合と同じです)。

text
--- 同じ条件で、カードの高さを最初の1回しか測らない場合 ---
列の数: 3(カードの幅 242.7px)
  1列目(左から 0px): カード 1, 4, 7, 9, 12 / カードの間 -202.4, -153.9, -105.3, -226.7px / 下端 661.1px
  2列目(左から 259px): カード 2, 6, 11 / カードの間 -226.7, -141.7px / 下端 642.9px
  3列目(左から 517px): カード 3, 5, 8, 10 / カードの間 -178.1, -129.6, -202.4px / 下端 688.4px
  重なっているカードの組: 14 / 列の下端の差: 45.5px

画像が届く前の高さのまま位置が決まっているので、カードの間がマイナスになり、14組のカードが重なりました。

CSS だけで同じ並べ方をする仕様

CSS にも、同じように、カードをそのときいちばん短い列へ置いていく仕組みが検討されています。CSS Grid Layout Module Level 3 の草案にある display: grid-lanes です。ただ、検証に使った Chromium 153 では、CSS.supports('display', 'grid-lanes') は false を返しました。

text
CSS.supports('display', 'grid-lanes'): false

Chromium 153 のように、まだ対応していないブラウザでは、ここで作ったように JavaScript で位置を計算することになります。

​Posted by 86