本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
セクション

作品の画像が石積みの格子へ上から落ちて収まり、隙間なく並ぶ

デモ

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

制作実績の節で、高さの違う作品の画像を石積み(メイソンリー)の格子に並べ、上から落として収めます。作品は並べた順に、いちばん低い列へ置きます。位置はスクリプトで決め、画像の高さは img の width と height の比から出すので、画像を読み込む前から並べられます。画面の幅が変わったら並べ直し、スクリプトが動くまでは CSS の段組みで並べておきます。

同じ列は、下の段から先に落として、石を積むように上へ重ねます。上の段から落とすと、あとから落ちる画像が、先に収まった画像の上を通り抜けて重なって見えます。落ちる時刻は列ごとに 0.09 秒、同じ列の 1 つ上の段は 0.38 秒ずらすので、左の列から順に、下から積み上がっていきます。

落ちる動きは power1.in(2 次の曲線)で、長さを落ちる距離の平方根に比例させます。こうすると、遠くから落ちる画像も近くの画像も、同じ重力で落ちているように見えます。着いたら、Math.sin で作った ease で 6px だけ弾ませて止めます。上がって元の位置へ戻る形なので、終わりの値がそのまま休みの位置になります。格子には上の縁から 12px 外までを残す clip-path をかけ、画像が縁の上から落ちてくるようにします。

全部の画像が収まってから、作品名と年を 20px の窓の下からせり上げます。落ちている途中で文字が動くと読めないので、最後にまとめて出します。出すのは格子が画面に入った 1 回だけで、IntersectionObserver で知らせを受けます。Tab で作品へ移ったときは、待たずに並べきります。

絞り込みで並びが入れ替わるギャラリーや、タイルを下から開いて見せる格子と違い、重力で積み上がる手ざわりで、仕事が集まってくる印象を出します。つまみは、列と段のずれ、重力の強さ(距離に掛ける係数)、弾む高さ、列の数と間(CSS の変数)です。動きを減らす設定では、並べるだけにします。作品集や商品の一覧のように、縦横の比がまちまちな画像を並べる節に向いています。

コード

HTML
<!-- 絵の width と height は、元の画像の縦横の比で書く(並べる位置を決めるのに使う)。作品は並べた順に、いちばん低い列へ置く -->
<section class="works" aria-labelledby="works-title">
  <div class="works__head">
    <div>
      <p class="works__eyebrow">Works</p>
      <h2 class="works__title" id="works-title">制作実績</h2>
    </div>
    <p class="works__lead">写真・装丁・展示の仕事から、最近のものを選びました。</p>
  </div>
  <ul class="works__grid">
    <li class="works__item">
      <a class="works__link" href="/works/harbor/">
        <img class="works__image" src="/img/works-harbor.jpg" alt="" width="408" height="544" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">朝の港</span><span class="works__year">2026年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/vase/">
        <img class="works__image" src="/img/works-vase.jpg" alt="" width="408" height="272" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">窓辺の器</span><span class="works__year">2026年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/house/">
        <img class="works__image" src="/img/works-house.jpg" alt="" width="408" height="612" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">坂の上の家</span><span class="works__year">2025年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/garden/">
        <img class="works__image" src="/img/works-garden.jpg" alt="" width="408" height="360" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">冬の庭</span><span class="works__year">2025年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/window/">
        <img class="works__image" src="/img/works-window.jpg" alt="" width="408" height="408" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">灯りの店</span><span class="works__year">2025年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/stairs/">
        <img class="works__image" src="/img/works-stairs.jpg" alt="" width="408" height="232" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">白い階段</span><span class="works__year">2024年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/river/">
        <img class="works__image" src="/img/works-river.jpg" alt="" width="408" height="504" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">川沿いの道</span><span class="works__year">2024年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/forest/">
        <img class="works__image" src="/img/works-forest.jpg" alt="" width="408" height="456" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">森の入口</span><span class="works__year">2024年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/rain/">
        <img class="works__image" src="/img/works-rain.jpg" alt="" width="408" height="320" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">雨のあと</span><span class="works__year">2023年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/bridge/">
        <img class="works__image" src="/img/works-bridge.jpg" alt="" width="408" height="272" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">石の橋</span><span class="works__year">2023年</span></span></span>
      </a>
    </li>
    <li class="works__item">
      <a class="works__link" href="/works/coast/">
        <img class="works__image" src="/img/works-coast.jpg" alt="" width="408" height="252" loading="lazy">
        <span class="works__caption"><span class="works__meta"><span class="works__name">海辺の町</span><span class="works__year">2023年</span></span></span>
      </a>
    </li>
  </ul>
</section>

CSS
.works {
  --ink: #eeece6;
  --mute-small: #a8a6a0;
  --surface: #262626;   /* 画像が届くまでの地 */
  --cols: 5;            /* 列の数(JS もここを読む) */
  --gap: 16px;          /* 絵どうしの間(JS もここを読む。px で書く) */
  padding: 64px max(24px, calc((100% - 1084px) / 2)) 96px;
  background: #111111;
  color: var(--ink);
}

.works__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: last baseline; gap: 16px 24px; margin-bottom: 40px; }
.works__eyebrow { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute-small); }
.works__title { margin: 16px 0 0; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.works__lead { margin: 0; font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; color: var(--mute-small); word-break: auto-phrase; }

/* スクリプトが動くまでは、段組みで上から詰めて並べる */
.works__grid { margin: 0; padding: 0; list-style: none; columns: var(--cols); column-gap: var(--gap); }
.works__item { break-inside: avoid; margin-bottom: var(--gap); }

/* 石積み: 位置はスクリプトが決める。上の縁より上(弾む分の 12px の外)は切り抜き、絵は縁の上から落ちてくる */
.works__grid.is-masonry { position: relative; columns: auto; clip-path: inset(-12px -100vw -100vw); }
.works__grid.is-masonry .works__item { position: absolute; margin: 0; }

.works__link { display: block; color: inherit; text-decoration: none; }
.works__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.works__image { display: block; width: 100%; height: auto; border-radius: 2px; background: var(--surface); }

/* 作品名と年は、20px の窓の下からせり上げる */
.works__caption { display: block; height: 20px; margin-top: 8px; overflow: hidden; }
.works__meta { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; line-height: 20px; }
.works__name { font-size: 14px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
.works__year { font-size: 12px; letter-spacing: 0.04em; color: var(--mute-small); font-variant-numeric: tabular-nums; white-space: nowrap; }

@media (max-width: 960px) {
  .works { --cols: 3; }
}

@media (max-width: 600px) {
  .works { --cols: 2; --gap: 12px; }
  .works__name { font-size: 13px; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const COL_LAG = 0.09; // 列ごとのずれ(秒)
const ROW_LAG = 0.38; // 同じ列の 1 つ上の段までの間(秒)
const LIFT = 12; // 格子の上の縁より上に見せる余白(CSS の clip-path と同じ値)
const bounce = (p) => Math.sin(Math.PI * p); // 着いたあとの弾み(上がって元の位置へ戻る)

document.querySelectorAll(".works").forEach((works) => {
  const grid = works.querySelector(".works__grid");
  const items = [...grid.querySelectorAll(".works__item")];
  let placed = [];

  // 並べた順に、いちばん低い列へ置く。絵の高さは img の width と height の比で、読み込む前から決まる
  function layout() {
    const style = getComputedStyle(works);
    const cols = parseInt(style.getPropertyValue("--cols"), 10);
    const gap = parseFloat(style.getPropertyValue("--gap"));
    grid.classList.add("is-masonry");
    const width = (grid.clientWidth - gap * (cols - 1)) / cols;
    const heights = new Array(cols).fill(0);
    const counts = new Array(cols).fill(0);
    placed = items.map((item) => {
      item.style.width = `${width}px`;
      const col = heights.indexOf(Math.min(...heights));
      const top = heights[col];
      item.style.left = `${col * (width + gap)}px`;
      item.style.top = `${top}px`;
      heights[col] += item.offsetHeight + gap;
      return { item, col, top, rank: counts[col]++ };
    });
    grid.style.height = `${Math.max(...heights) - gap}px`;
    // 下から数えて何段目か(下の段から先に落とす)
    placed.forEach((p) => (p.below = counts[p.col] - 1 - p.rank));
  }
  layout();
  new ResizeObserver(layout).observe(grid);

  // 動きを減らす設定では、並べるだけにする
  if (reduceMotion) return;

  // 同じ列は下の段から落とし、石を積むように重ねる(上の段から落とすと、あとの絵が収まった絵の上を通り抜ける)。
  // 落ちる長さは距離の平方根に比例させる(同じ重力に見える)。全部が収まってから、作品名と年をせり上げる
  const drop = gsap.timeline({ paused: true });
  let landed = 0;
  placed.forEach(({ item, col, top, below }) => {
    const s = col * COL_LAG + below * ROW_LAG;
    const fall = top + item.querySelector(".works__image").offsetHeight + LIFT + 8;
    const time = 0.42 * Math.sqrt(fall / 400);
    drop
      .fromTo(item, { y: -fall }, { y: 0, duration: time, ease: "power1.in" }, s)
      .fromTo(item, { y: 0 }, { y: -6, duration: 0.22, ease: bounce, immediateRender: false }, s + time);
    landed = Math.max(landed, s + time + 0.22);
  });
  drop.fromTo(grid.querySelectorAll(".works__meta"), { yPercent: 100 }, { yPercent: 0, duration: 0.55, ease: "power3.out", stagger: 0.05 }, landed + 0.1);

  // 格子の上端が画面の下から 15% の線を越えたら、1 回だけ落とす
  const io = new IntersectionObserver(
    ([entry]) => {
      if (!entry.isIntersecting) return;
      io.disconnect();
      drop.play();
    },
    { rootMargin: "0px 0px -15% 0px" }
  );
  io.observe(grid);
  // Tab で作品へ移ったときは、画面に入るのを待たずに並べきる
  grid.addEventListener("focusin", () => {
    io.disconnect();
    if (drop.paused()) drop.progress(1);
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。