GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css masonry layout 9パターン — 高さの違うカードが隙間なく収まる配置

css masonry layout を columns や grid の一行だけで組むと読む順が縦にねじれ、ウィンドウ幅が変わるとカードが瞬間移動する — 01番・02番・04番が最短の列への配置・行順の割り当て・FLIP のばねで解く。

九つのレンガ配置を画面でそのまま動かしてみる

自動再生 · タイルを押すとその項目へ · すべて1つのzip

css masonry layout 9パターン — 列の高さ・行の単位・ばねを計算して隙間を閉じる

一つの場面は、幅が最大 440px・高さ 216px の壁に、番号だけを書いたグラデーションのカードを積みます。カードの高さは種を固定した乱数列が出した比率をもとに決めるので毎回同じ並びになり、壁の上にあるバーのボタンで列の数・モード・フィルターをその場で変えられます。

配置は二つの層でできています。一つは目標の位置を求める計算、もう一つはその位置へ追いかける動きで、規則は場面ごとに違います。カードの高さがあらかじめ分かっていれば位置は決まりますが、画面はその位置へすぐ移るのではなく、別の仕組みで追いかけます。速度を持つばねは 04 だけ、09 は重力を積分し、01・02・06 は目標との差を一定の割合ずつ縮める指数接近、07 は速度に時間を掛けて足し、08 は時間に沿ったイージングです。03 と 05 は追いかけずに、直接計算した値をそのまま使います。九つのファイルとも CSS の transition と @keyframes は一行もなく、ライブラリも外部へのリクエストも Math.random もありません。

画像の比率がばらばらなギャラリーが目的なら 03 から、絞り込みボタンの付いた一覧なら 08 から、ウィンドウ幅が変わるレスポンシブな画面なら 04 から開くのが近道です。スライドや拡大やライトボックスといったギャラリーのほかの効果は CSS フォト ギャラリー 9選 — メイソンリーからライトボックスまで と 一行でコピペOK CSS アニメーション ギャラリー 9選 が受け持ち、この記事はカードがどの位置に座るかだけを扱います。

css masonry grid を技法別のコードで見る

ここに並べる断片は index.html の <script> の中で途切れずに続く行だけを抜いたもので、長さは25行までです。css masonry effect と呼ばれる動きの多くは、ここにある位置の計算の上に「追いかけ」を一枚重ねたものなので、計算から読んでおくと値を変えたときに何が変わるか見当がつきます。

01一番低い列を探して一枚ずつ載せる最短列の配置

カードを順に読み、その時点でいちばん低い列(同じ高さなら左)に置いて、その列の高さにカードの高さと間隔 8px を足します。高さがばらばらのカードを隙間少なく積みたいピンボードやポートフォリオの格子に向き、列の下の細い棒が各列の下端の高さを見せます。累積面積は47.7%で、動きが捉えられたフレームは23/23でした。

一番低い列を探して一枚ずつ載せる最短列の配置
新しいタブで開く

01 · 一番低い列を探して一枚ずつ載せる最短列の配置 — 計測 累積面積 47.727% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COLUMNSGAPSETTLESEED
    function pack(hs, cols, cw, gap) {
        const colH = [];
        for (let k = 0; k < cols; k += 1) colH.push(0);
        const out = [];
        for (let i = 0; i < hs.length; i += 1) {
            let best = 0;
            for (let k = 1; k < cols; k += 1) {
                if (colH[k] < colH[best] - 0.5) best = k;
            }
            out.push({ x: best * (cw + gap), y: colH[best] });
            colH[best] += hs[i] + gap;
        }

02左から右へ読めるように順を直した列配置

縦に埋めるモードは番号が列に沿って下りるので読む順が縦にねじれ、横の順は番号を列数で割った余りで列を決め、均衡は横の順で進みながら列が高くなりすぎたら一番低い列へ回します。同じカード九枚でルールだけを替えると列の下端の差は縦 5px・横 72px・均衡 30px になり(幅 424px の3列)、均衡で入れ替わるのはカード5と6だけです。累積面積は53.1%、動いたフレームは19/23でした。

左から右へ読めるように順を直した列配置
新しいタブで開く

02 · 左から右へ読めるように順を直した列配置 — 計測 累積面積 53.068% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

COLUMNSBALANCEGAPFOLLOW
            const c = cards[i];
            const h = c.ratio * cw;
            let col = i % COLUMNS;
            if (mode === 0) {
                col = Math.min(COLUMNS - 1, Math.floor(i / perCol));
            } else if (mode === 2) {
                let low = 0;
                for (let k = 1; k < COLUMNS; k += 1) {
                    if (colH[k] < colH[low]) low = k;
                }
                if (colH[col] - colH[low] > BALANCE * h) col = low;
            }
            c.w = cw;
            c.h = h;
            c.tx = col * (cw + GAP);
            c.ty = colH[col];
            colH[col] += h + GAP;

03画像の比率から行の数を計算するグリッドスパン

配置は本物の CSS grid が行い、JavaScript は span = ceil((h + 8) / 16) で行の数だけを決めて gridRowEnd に書き込みます。画像の比率がばらばらなギャラリーに向き、カードの中の番号の場所にはその行の数が表示されます。累積面積は60.2%、動いたフレームは23/23でした。

画像の比率から行の数を計算するグリッドスパン
新しいタブで開く

03 · 画像の比率から行の数を計算するグリッドスパン — 計測 累積面積 60.184% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ROW_UNITGAPSPAN_BIASCOLUMNS
    function spanOf(h) {
        return Math.ceil((h + GAP) / (ROW_UNIT + GAP)) + SPAN_BIAS;
    }
    function relayout() {
        wall.style.width = Math.round(root.clientWidth * frac) + "px";
        const cw = (wall.clientWidth - GAP * (COLUMNS - 1)) / COLUMNS;
        for (let i = 0; i < cards.length; i += 1) {
            const n = spanOf(cw * cards[i].ratio);
            if (n !== spans[i]) {
                spans[i] = n;
                cards[i].el.style.gridRowEnd = "span " + n;
                const badge = cards[i].el.firstChild;
                if (badge) badge.textContent = String(n);
            }
        }
        spanEl.textContent = String(spans[0]);
        shownFrac = frac;
        ready = true;
    }

04列の数が変わるときばねで定位置を探す FLIP 再配置

新しい位置を求めたとき、relayout が古い位置との差をオフセットとして残し、フレームごとに kick がばねでそのオフセットを0まで引き寄せていく方式が FLIP です。幅の差も scale で打ち消すので3列から4列へ替えるときは +0.36 だけ伸びた状態で出発し、途中で列の数をもう一度変えても今の画面上の位置から続くため跳びません。累積面積は48.4%、動いたフレームは19/23でした。

列の数が変わるときばねで定位置を探す FLIP 再配置
新しいタブで開く

04 · 列の数が変わるときばねで定位置を探す FLIP 再配置 — 計測 累積面積 48.372% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFFNESSDAMPINGSTAGGERAREA
    function kick(o, v, f) {
        const nv = (v - STIFFNESS * o * f) * Math.pow(DAMPING, f);
        return [o + nv * f, nv];
    }
        for (let i = 0; i < cards.length; i += 1) {
            const c = cards[i];
            if (ready && !reduce) {
                // First: 今の画面上の位置と大きさから新しい位置と大きさを引いた値がオフセットになる。
                c.ox = c.tx + c.ox - at[i].x;
                c.oy = c.ty + c.oy - at[i].y;
                c.osx = (c.tw * (1 + c.osx)) / cw - 1;
                c.osy = (c.th * (1 + c.osy)) / hs[i] - 1;
                c.wait = i * STAGGER;
            }
            c.tx = at[i].x;
            c.ty = at[i].y;
            c.tw = cw;
            c.th = hs[i];
            c.w = cw;
            c.h = hs[i];
            if (!ready || reduce) {
                c.ox = 0;
                c.oy = 0;
                c.osx = 0;
                c.osy = 0;
            }
        }

05一行の幅にぴったり合わせて高さを揃える均等な行

縦横比を足していき、一行の幅が壁の幅に届いたらそこで行を切り、その行の高さに倍率 s を一度だけ掛けて右端をそろえます。いっぱいに詰まった行は必ず s ≤ 1 で縮むだけなので、写真アルバムや検索結果の一覧に向きます。累積面積は44.8%、動いたフレームは19/23でした。

一行の幅にぴったり合わせて高さを揃える均等な行
新しいタブで開く

05 · 一行の幅にぴったり合わせて高さを揃える均等な行 — 計測 累積面積 44.764% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TARGET_HGAPMAX_STRETCHLERP
        while (i < cards.length) {
            const from = i;
            let sum = 0;
            while (i < cards.length) {
                sum += cards[i].ratio;
                i += 1;
                if (sum * th + GAP * (i - from - 1) >= width) break;
            }
            const n = i - from;
            let s = (width - GAP * (n - 1)) / (sum * th);
            // 最後の行が短すぎるときは伸ばさず、目標の高さを守る。
            if (s > 1 + MAX_STRETCH) s = 1;
            const rowH = th * s;
            let x = 0;
            for (let j = from; j < i; j += 1) {
                const c = cards[j];
                c.w = cards[j].ratio * rowH;
                c.h = rowH;
                c.x = x;
                c.y = y;
                x += c.w + GAP;
            }
            y += rowH + GAP;
            rows += 1;
        }

06ポインターの近くで隣が道をあける、レンズ状の配置

カードの中心とポインターの距離 d にガウス型の減衰を掛けて目標の倍率と避ける距離を決め、現在の値は指数接近で追いかけます。近いカードは大きくなり遠いカードは少し縮むので、ホバーで見渡す画像ボードに向きます。累積面積は54.4%、動いたフレームは23/23でした。

ポインターの近くで隣が道をあける、レンズ状の配置
新しいタブで開く

06 · ポインターの近くで隣が道をあける、レンズ状の配置 — 計測 累積面積 54.377% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LENS_RADIUSGAINFALLOFFLERP
            if (has) {
                const dx = c.tx + c.w / 2 - px;
                const dy = c.ty + c.h / 2 - py;
                const d = Math.sqrt(dx * dx + dy * dy);
                const g = Math.exp(-FALLOFF * (d / LENS_RADIUS) * (d / LENS_RADIUS));
                ds = 1 + GAIN * g - 0.06 * (1 - g);
                if (d > 1) {
                    ux = (dx / d) * PUSH * 4 * g * (1 - g);
                    uy = (dy / d) * PUSH * 4 * g * (1 - g);
                }
                lift = g;
                if (d < bestD) {
                    bestD = d;
                    best = i;
                }
            }
            const k = reduce ? 1 : 1 - Math.pow(1 - LERP, f);
            c.s += (ds - c.s) * k;
            c.ox += (ux - c.ox) * k;
            c.oy += (uy - c.oy) * k;

07列ごとに逆向きへ流れる無限のレンガ

列ごとに向きを交互にして SPEED × DEPTH[k] で速さを変え、位置を直接足したあと、列の高さで割った余りに巻き戻して継ぎ目を隠します。今の幅では列ごとにカードを2組つなぐので、DOM のカードは20枚から40枚に増え、ランディングの背景やロゴの壁に向きます。累積面積は62.3%で九つの中で最も広く、動いたフレームは23/23でした。

列ごとに逆向きへ流れる無限のレンガ
新しいタブで開く

07 · 列ごとに逆向きへ流れる無限のレンガ — 計測 累積面積 62.272% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPEEDDEPTHCOLUMNSGAP
            for (let n = 0; n < copies; n += 1) {
                let y = n * total;
                for (let i = 0; i < PER; i += 1) {
                    const h = ratios[k * PER + i] * cw;
                    const el = document.createElement("div");
                    el.className = CLS.card;
                    el.setAttribute("data-c", String((k * PER + i) % 6));
                    el.innerHTML = "<i>" + (k * PER + i + 1) + "</i>";
                    if (n > 0) el.setAttribute("aria-hidden", "true");
                    el.style.width = cw.toFixed(1) + "px";
                    el.style.height = h.toFixed(1) + "px";
                    el.style.transform = "translate3d(0," + y.toFixed(1) + "px,0)";
                    col.appendChild(el);
                    y += h + GAP;
                }
            }

08フィルターを押すと順番に位置が移るシャッフル

絞り込まれたカードだけをもう一度最短列の配置に載せて目標位置を求め、カードごとの出発を順番に STAGGER を掛けた分だけ遅らせて、時間のイージングで動かします。外れたカードはその場で倍率 0.5・透明度 0 に畳まれ、カテゴリーで絞り込むギャラリーに向きます。累積面積は50.9%、動いたフレームは22/23でした。

フィルターを押すと順番に位置が移るシャッフル
新しいタブで開く

08 · フィルターを押すと順番に位置が移るシャッフル — 計測 累積面積 50.885% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGGERDURATIONEASE_POWERCOLUMNS
            if (visible(c)) {
                c.tx = at[rank].x;
                c.ty = at[rank].y;
                c.ds = 1;
                c.dop = 1;
                c.t0 = now + rank * STAGGER;
                rank += 1;
            } else {
                c.ds = 0.5;
                c.dop = 0;
                c.t0 = now;
            }

09上から落ちて弾み、積み上がるレンガ

カードごとに DROP_DELAY だけ遅れて出発し、重力で落ちて目標の高さに触れると速度の0.32倍で弾み、縦に潰れてから伸びて止まります。最初の着地は233msから1517msの間に散らばり、全員が止まるまで1850msかかるので、読み込み直後の登場演出に向きます。累積面積は46.3%、動いたフレームは22/23でした。

上から落ちて弾み、積み上がるレンガ
新しいタブで開く

09 · 上から落ちて弾み、積み上がるレンガ — 計測 累積面積 46.294% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GRAVITYBOUNCESQUASHDROP_DELAY
        for (let s = 0; s < n; s += 1) {
            for (let i = 0; i < cards.length; i += 1) {
                const c = cards[i];
                c.sq *= Math.exp(-14 * h);
                if (c.rest === 1 || ts - startAt < i * DROP_DELAY) continue;
                c.vy += GRAVITY * h;
                c.y += c.vy * h;
                if (c.y >= c.ty) {
                    const hit = c.vy;
                    c.y = c.ty;
                    if (hit > 90) {
                        c.vy = -hit * BOUNCE;
                        c.sq = Math.max(c.sq, Math.min(0.3, (SQUASH * hit) / 4200));
                    } else {
                        c.vy = 0;
                        c.rest = 1;
                    }
                }
            }
        }

値が配置を変えるところ — 列の数・ばねの強さ・レンズの半径

数字で配置が大きく変わる値は三つあります。以下の数字はソースの同じ関数を Node で直接呼んで数えたもので、壁の幅 440px を前提にしているため、ブラウザーで描画して測った値とは別のものとして読んでください。

列を増やすとカードは細くなり下端がそろう

01 の COLUMNS を3から5にすると、カードの幅は 141px から 82px に狭まり、いちばん長い列の下端は 190px から 66px に下がり、列の下端の差も 54px から 36px に縮みます。同じ九枚がより広く散るので、一つの列に積み上がる高さが短くなるわけです。

強さを上げると行き過ぎは倍になり落ち着くのは遅れる

04 の STIFFNESS を0.12から0.3に上げて DAMPING はそのままにすると、100px 動かしたときに目標を行き過ぎる幅が 21px から 41px に広がります。初めて 1px 以内に入る時刻は約0.28秒でほとんど変わらず、そのあと 1px 以内にとどまり続ける時刻は0.45秒から0.55秒に遅れます。

レンズの半径を広げると縮められていたカードが解ける

06 の LENS_RADIUS を140から240に広げてポインターを壁の中央に置くと、1.1倍を超えて大きくなるカードが12枚中5枚から6枚に増え、最大の倍率は1.31から1.45に上がります。0.97倍を下回って縮められるカードは6枚から0枚になり、レンズが広いほど壁全体が一斉にふくらみます。

実際にブラウザーで描画して得た測定は次の表にまとめました。累積面積がいちばん小さいのは 05 の44.8%で、いちばん大きいのは 07 の62.3%です。動いたフレームが23に届かない場面は 02(19)・04(19)・05(19)・08(22)・09(22)の五つで、その理由までは確かめていません。

項目 累積面積 平均強度 動いたフレーム
01 最短列の配置 47.7% 102.9 23/23
02 列を選ぶ三つのルール 53.1% 58.7 19/23
03 grid のスパン 60.2% 123.3 23/23
04 FLIP とばね 48.4% 115.0 19/23
05 均等な行 44.8% 65.5 19/23
06 レンズ配置 54.4% 98.4 23/23
07 流れる列 62.3% 159.2 23/23
08 フィルターのシャッフル 50.9% 63.6 22/23
09 落下して積む 46.3% 126.8 22/23

どこで壊れるか — カードが数百枚のとき・狭い画面・動きを減らす設定

カードの高さが比率の掛け算なので実際の記事カードにはそのまま使えない

01・02・03・06・07・08・09 の七つの場面は高さを 比率 × 列幅 で決め、04 は幅が狭まると高くなる 比率 × AREA / 列幅 のモデル、05 は行の高さに比率を掛けて幅を決めます。画像や文章の入った実際のカードは中身が高さを決めるので、一度描いてから高さを測り、その値を 01 の pack のような配置の計算に渡す必要があります。

最短の列は読む順を捨てる

01 の pack は高さだけを見て列を選ぶため、番号が列をまたいで飛びます。横に読まれる一覧なら 02 の横の順を使えますが、列の下端の差が 5px から 72px に開く代償を払うことになり、均衡のルールは 30px で中間を取ります。

grid のスパンは行ごとに隙間を残す

03 は span × 16 - 8 がカードの高さを覆うように切り上げるので、カード一枚につき最大 15px の余りが出て、行の単位を大きくするほど増えます。幅が変わるとスパンが計算し直され、カードの高さは補間なしに行の単位一つ分ずつ跳びます。

FLIP の幅の補正が文字を一瞬ゆがめる

04 は列の数を変えるとき幅の差を scale で打ち消してから始めるので、3列から4列では +0.36 だけ伸びた状態で出発します。番号の文字と角はその分ゆがんでから戻り、強さだけを上げると 21px だった行き過ぎが 41px になります。

均等な行の最後の行は右端が合わない

05 では最後の行の伸びる倍率が1.35を超えると伸ばさないので、右端があいたままになります。目標の高さ44では二行目の5枚の右端が237.6pxで終わり、55では7枚ずつの二行がどちらも 440px にぴったり収まります。

レンズはタッチとキーボードで入り方が違う

06 はポインターがマウスでないとき、指を離した pointerup でレンズを消します。キーボードではカード12枚が tabIndex を持つので、Tab で一枚ずつフォーカスを移したときだけそのカードの中心にレンズが置かれ、拡大したカードは壁の内側に押さえられるため、角では押しのけられる向きが塞がれます。

幅が変わるたびに DOM が作り直される

07 は幅が変わると build() でカードをまるごと作り直します。今は40個なので負担はありませんが、カードが数百枚になるとウィンドウの端をドラッグしている間に引っかかるかもしれません。

落下するレンガは互いにぶつからない

09 には同じ列のカード同士の衝突判定がなく、それぞれ自分の目標の高さでだけ止まります。着地の時刻がずれて重ならずに済んでいるだけなので、DROP_DELAY を減らすと上のカードが下のカードの弾みと重なります。

自分で値を変えて境界を確かめる

01 の COLUMNS、04 の STIFFNESS、06 の LENS_RADIUS を一つずつ変えてみると、上の数字がどこで切り替わるかを手で確かめられます。ファイル一式は、続く圧縮ファイルを vbqsakg9 で開くと取り出せて、九つの場面のソースと React 版が一緒に入っています。

変形三つとアクセシビリティ

名前 値 感じ
列の数を変える 01 COLUMNS 3 → 5 カードの幅が 141px から 82px に細くなり、いちばん長い列の下端は 190px から 66px に下がって、列の下端の差は 54px から 36px に縮みます。
ばねの強さを変える 04 STIFFNESS 0.12 → 0.3 初めて届く時刻は約0.28秒で変わらず、行き過ぎは 21px から 41px に倍増し、落ち着くのは0.45秒から0.55秒に遅れます。
レンズの半径を変える 06 LENS_RADIUS 140 → 240 1.1倍を超えるカードが5枚から6枚に、最大の倍率が1.31から1.45に増え、縮められるカードは6枚から0枚になります。

三つの変形の数字は、ノードでソースの関数を直接呼んで数えた値です。

操作は 06 を除く八つの場面で本物の <button> が受け持ちます。06 はボタンを持たず、カード12枚が tabIndex を持つので Tab で届きます。keydown のハンドラーと preventDefault は九つのファイルのどこにもなく、キーボードはボタン本来の動作である Enter と Space だけで動きます。状態行の aria-live はデモを再生しているあいだは off で、入力が届くと polite に切り替わり、九つの場面とも ResizeObserver で幅の変化を受けて位置を計算し直します。

prefers-reduced-motion が有効だと、九つの場面はどれも自動のデモとアニメーションのループを始めず、結果の配置を一度だけ描きます。入力は補間なしにそのまま反映され、01 の 積み直す と 07 の 停止 のボタンはこのとき隠れます。08 で絞り込まれたカードは透明度が0になるだけで DOM に残るので、リンクカードに替えて使うときは hidden か inert を別に付けてください。

css masonry flexbox だけで足りるか?

列の中にカードを順に入れていく方式は、02 の縦に埋めると同じ順番になります。列の下端の差は 5px といちばん小さくなりますが、番号が列に沿って下りるので読む順はいちばん大きくずれます。横の順を守るには 02 のルールのようにカード一枚ごとに列を計算する必要があり、高さの違うカードを一行にそろえる仕事は 05 のように行ごとに別に計算します。この記事の九つのファイルはカードの位置をすべて計算で決めていて、行の数を grid に渡す 03 だけが配置を CSS に任せています。

場面ごとに向く場所は違います。01 はピンボード、02 は読む順が大事なカード一覧、03 は画像の比率がばらばらなギャラリー、04 はレスポンシブな切り替え、05 は写真アルバム、06 はホバーで見渡す画像ボード、07 はランディングの背景、08 はカテゴリーで絞り込むギャラリー、09 は最初の登場演出に付きます。作品をまとめて見せる画面の組み方は ポートフォリオ ギャラリー UI 9選 — ドラッグからモーフ切替まで で、カードを押して大きく開く切り替えは css ライトボックス 拡大アニメーション 9種まとめ で続きます。幅の変化を受ける観察者は ResizeObserver に、列に分ける基本のルールは CSS multi-column layout にまとまっています。

css masonry image gallery を使い回す前によくある質問

css masonry grid generator のように値を変えて試すにはどこを触りますか

各場面の index.html の <script> の中にある const の値を書き換えます。列の数は COLUMNS、間隔は GAP、カードの高さを決める乱数列の種は SEED です。04 と 05 には COLUMNS がなく、09 では種が makeCards の引数に直接書かれています。

カードの数が増えてもそのまま使えますか

九つの場面は8〜20枚のカードで測ったので、それより多い場合は確かめていません。01 の pack はカード一枚ごとに列の数だけ比べるので増える量はカードの数に比例しますが、07 のように DOM を作り直す場面は数百枚で別に測る必要があります。

ライブラリや画像は必要ですか

必要ありません。九つのファイルとも外部へのリクエストがなく、番号だけを書いたグラデーションの箱で動くので、一つの場面を使うなら index.html と style.css の二つだけを持っていけば動きます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。