本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カード

スクロールでヒーローの画像が下のカードのグリッドへ移って並ぶ(FLIP)

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

用途
カード
動作
DOM
種類
レシピ
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ヒーローに扇状に並べた商品の写真が、スクロールに合わせて下のセクションのグリッドへ飛んでいき、枠に収まります。2 つの並びでの位置と大きさを測って差を補間する FLIP という型で、コードの節では GSAP の Flip プラグインで組んでいます。写真を扇の置き場に移して位置・大きさ・傾きを記録し、グリッドの枠へ戻してから、記録した位置から今の位置へ動かします。

動きにはイージングを付けず(none)、ヒーローの上端が画面の上端に着いてから、コレクションの下端が画面の下端に着くまでのスクロール量に、そのまま結びます(scrub)。写真は 1 枚が移る長さの 5% ずつ遅らせて飛ばし、着く直前から名前と値段を出します。写真はページの中にあるので、ページと一緒に流れながら位置の差を埋めていきます。

写真は初めからグリッドの枠に置いておき、測るときだけ扇へ移します。スクリプトが動かないときや動きを減らす設定でも、商品の一覧がそのまま並びます。写真は枠の中で position: absolute にしてください。飛んでいる間は写真に大きさが直接入るので、ふつうに置くと枠が写真に合わせて伸び、グリッドがずれて行き先まで狂います。

画面の幅が変わると扇と枠の位置も変わるので、幅が変わったときと、書体を読み込み終えたときに、測り直して作り直します。名前と値段は透明度だけで出し入れし、読み上げからは外しません。Tab で商品へ移ったときは、写真が飛んでいる途中でも名前と値段を見せます。

つまみは、扇の位置と傾き、1 枚ごとのずれ、ヒーローでの写真の大きさです。ずれを広げると、写真が 1 枚ずつ順に飛び立つように見えます。商品一覧・作品集・特集ページで、ヒーローから一覧へ導く所に向いています。

コード

HTML
<div class="flip">
  <section class="flip-hero" aria-labelledby="flip-hero-title">
    <p class="flip-hero__eyebrow">SPRING COLLECTION</p>
    <h1 class="flip-hero__title" id="flip-hero-title">朝をゆっくり<wbr>過ごす道具。</h1>
    <p class="flip-hero__lead">ここに説明文が入ります。</p>
    <!-- 扇の置き場。写真はスクリプトがここに並べてから、下のグリッドへ飛ばす(--x・--y は中心、--r は傾き) -->
    <div class="flip-hero__fan" aria-hidden="true">
      <div class="flip-hero__spot" style="--x: 14.5%; --y: 71.5%; --r: -10deg"></div>
      <div class="flip-hero__spot" style="--x: 28.8%; --y: 66.2%; --r: -6deg"></div>
      <div class="flip-hero__spot" style="--x: 43%; --y: 63.8%; --r: -2deg"></div>
      <div class="flip-hero__spot" style="--x: 57%; --y: 63.8%; --r: 2deg"></div>
      <div class="flip-hero__spot" style="--x: 71.2%; --y: 66.2%; --r: 6deg"></div>
      <div class="flip-hero__spot" style="--x: 85.5%; --y: 71.5%; --r: 10deg"></div>
    </div>
  </section>

  <section class="flip-shop" aria-labelledby="flip-shop-title">
    <div class="flip-shop__copy">
      <p class="flip-shop__eyebrow">COLLECTION</p>
      <h2 class="flip-shop__title" id="flip-shop-title">毎日使える、<br>6つの品。</h2>
      <p class="flip-shop__lead">ここに説明文が入ります。</p>
      <a class="flip-shop__button" href="/shop/">すべての商品を見る <span aria-hidden="true">→</span></a>
    </div>

    <!-- 写真は初めからグリッドの枠に置く(スクリプトが無いときや動きを減らす設定では、このまま並ぶ) -->
    <ul class="flip-shop__grid">
      <li><a class="product" href="/shop/ember-vase/">
        <span class="product__slot"><img class="product__photo" src="/img/ember-vase.jpg" alt="" width="640" height="640"></span>
        <span class="product__caption"><span>陶の花器</span> <span class="product__price">¥9,800</span></span>
      </a></li>
      <li><a class="product" href="/shop/moss-lamp/">
        <span class="product__slot"><img class="product__photo" src="/img/moss-lamp.jpg" alt="" width="640" height="640"></span>
        <span class="product__caption"><span>苔色のランプ</span> <span class="product__price">¥18,000</span></span>
      </a></li>
      <li><a class="product" href="/shop/harbor-mug/">
        <span class="product__slot"><img class="product__photo" src="/img/harbor-mug.jpg" alt="" width="640" height="640"></span>
        <span class="product__caption"><span>藍ぶちのマグ</span> <span class="product__price">¥4,200</span></span>
      </a></li>
      <li><a class="product" href="/shop/fern-pot/">
        <span class="product__slot"><img class="product__photo" src="/img/fern-pot.jpg" alt="" width="640" height="640"></span>
        <span class="product__caption"><span>シダの鉢植え</span> <span class="product__price">¥6,400</span></span>
      </a></li>
      <li><a class="product" href="/shop/dune-plates/">
        <span class="product__slot"><img class="product__photo" src="/img/dune-plates.jpg" alt="" width="640" height="640"></span>
        <span class="product__caption"><span>砂色の平皿</span> <span class="product__price">¥8,600</span></span>
      </a></li>
      <li><a class="product" href="/shop/night-candle/">
        <span class="product__slot"><img class="product__photo" src="/img/night-candle.jpg" alt="" width="640" height="640"></span>
        <span class="product__caption"><span>夜のキャンドル</span> <span class="product__price">¥3,300</span></span>
      </a></li>
    </ul>
  </section>
</div>

CSS
.flip {
  --bg: #f2f1ed;          /* ヒーロー(明るい地) */
  --ink: #262626;
  --mute: #6b6b6b;
  --shop-bg: #111111;     /* コレクション(暗い地) */
  --shop-surface: #262626;
  --shop-ink: #eeece6;
  --shop-mute: #a8a6a0;
  --side: clamp(16px, 7.5vw, 96px);
  --photo: clamp(80px, 16.5vw, 240px); /* ヒーローでの写真の幅 */
  background: var(--bg);
  color: var(--ink);
}

.flip-hero { padding: 128px var(--side) 96px; text-align: center; }

.flip-hero__eyebrow, .flip-shop__eyebrow { margin: 0 0 16px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }

.flip-hero__title { margin: 0; font-size: clamp(36px, 5vw, 64px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.flip-hero__lead { margin: 16px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 扇は、写真を飛ばすときだけ出す(スクリプトが .is-flying を付ける) */
.flip-hero__fan { display: none; }
.flip.is-flying .flip-hero { position: relative; box-sizing: border-box; min-height: 100vh; }
.flip.is-flying .flip-hero__fan { display: block; position: absolute; inset: 0; pointer-events: none; }

.flip-hero__spot {
  position: absolute;
  left: calc(var(--x) - var(--photo) / 2);
  top: calc(var(--y) - var(--photo) * 0.625);
  width: var(--photo);
  aspect-ratio: 4 / 5;
  transform: rotate(var(--r));
}

.flip-shop {
  display: grid;
  grid-template-columns: 384px minmax(0, 1fr);
  align-items: center;
  gap: 48px 64px;
  box-sizing: border-box;
  min-height: 100vh;
  padding: 48px var(--side);
  background: var(--shop-bg);
  color: var(--shop-ink);
}

.flip-shop__eyebrow { color: var(--shop-mute); }
.flip-shop__title { margin: 0; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.flip-shop__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 主ボタン(暗い地では、明るい塗りに暗い文字) */
.flip-shop__button {
  display: inline-flex;
  align-items: center;
  gap: 0.3em;
  height: 48px;
  margin-top: 32px;
  padding: 0 24px;
  border-radius: 999px;
  background: var(--shop-ink);
  color: var(--shop-bg);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.flip-shop__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin: 0; padding: 0; list-style: none; }
.product { display: block; color: inherit; text-decoration: none; }
.product__slot { position: relative; display: block; aspect-ratio: 1; border-radius: 4px; background: var(--shop-surface); }

/* 写真は置き場いっぱいに広げる(縦横の比が変わっても歪まない)。絶対配置にするのは、飛んでいる間に
   写真の大きさが枠の高さを押し広げ、グリッドがずれないようにするため。飛んでいる写真は、下のボタンの押す所をふさがない */
.product__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 4px; pointer-events: none; }

.product__caption {
  display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; align-content: center;
  gap: 0 8px; min-height: 48px; font-size: 15px; font-weight: 600; letter-spacing: 0.04em;
}

.product__price { font-size: 14px; font-weight: 400; color: var(--shop-mute); font-variant-numeric: tabular-nums; }
.product:focus-visible, .flip-shop__button:focus-visible { outline: 2px solid var(--shop-ink); outline-offset: 4px; }
/* Tab で移った商品は、写真が飛んでいる途中でも名前と値段を見せる(スクリプトが書く style より強くする) */
.product:focus-visible .product__caption { opacity: 1 !important; }

/* 狭い画面では、文言の下にグリッドを 2 列で置く */
@media (max-width: 900px) {
  .flip-shop { grid-template-columns: minmax(0, 1fr); }
  .flip-shop__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
}

JavaScript
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(Flip, ScrollTrigger);

const STAGGER = 0.05; // 1 枚ごとのずれ(1 枚が移る長さ = 1 に対する比)

function flyToGrid(page) {
  const hero = page.querySelector(".flip-hero");
  const shop = page.querySelector(".flip-shop");
  const spots = gsap.utils.toArray(".flip-hero__spot", page); // 始まりの並び(扇)
  const photos = gsap.utils.toArray(".product__photo", page);
  const slots = photos.map((photo) => photo.parentElement); // 終わりの並び(グリッドの枠)
  const captions = gsap.utils.toArray(".product__caption", page);
  let ctx;

  function build() {
    ctx?.revert(); // 前に作った Flip と ScrollTrigger を取り消す(写真は枠に戻る)
    // 1. 写真を扇に置いて、位置・大きさ・傾きを測る
    photos.forEach((photo, i) => spots[i].append(photo));
    const state = Flip.getState(photos);
    // 2. 写真をグリッドの枠へ戻す
    photos.forEach((photo, i) => slots[i].append(photo));
    ctx = gsap.context(() => {
      // 3. 測った扇の位置から、枠の位置へ。ease: "none" で、スクロール量にそのまま比例させる
      const tl = Flip.from(state, { duration: 1, ease: "none", stagger: STAGGER })
        // 名前と値段は、写真が着く直前から出す(opacity だけにして、読み上げからは外さない)
        .fromTo(captions, { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.2, ease: "power1.out", stagger: STAGGER }, 0.8);
      ScrollTrigger.create({
        animation: tl,
        trigger: hero,
        start: "top top",
        endTrigger: shop,
        end: "bottom bottom",
        scrub: true, // 数値にすると、指を止めたあとも写真が遅れて追いかける
      });
    });
  }

  page.classList.add("is-flying"); // 扇の置き場を出してから測る
  build();
  // 書体が読み込まれて文言の高さが変わると、測った位置がずれる。そろったら測り直す
  let active = true;
  document.fonts.ready.then(() => active && build());

  // 画面の幅が変わると、扇と枠の位置も変わる。測り直して作り直す(高さだけの変化は、スマホのアドレスバーなので無視する)
  let width = innerWidth;
  let timer;
  const onResize = () => {
    if (innerWidth === width) return;
    width = innerWidth;
    clearTimeout(timer);
    timer = setTimeout(build, 200);
  };
  addEventListener("resize", onResize);

  return () => {
    active = false;
    removeEventListener("resize", onResize);
    clearTimeout(timer);
    ctx?.revert();
    page.classList.remove("is-flying");
  };
}

// 動きを減らす設定では飛ばさず、写真をはじめからグリッドに並べておく(設定を切り替えると付け外しされる)
gsap.matchMedia().add("(prefers-reduced-motion: no-preference)", () => {
  const undo = [...document.querySelectorAll(".flip")].map(flyToGrid);
  return () => undo.forEach((fn) => fn());
});

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

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