本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ページ遷移

ページ遷移で一覧の画像が広がり、次のページの見出し画像になる(FLIP)

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

FLIP という型で作っています。一覧のカードにある画像の位置と大きさを先に測り、その画像を次のページの見出し画像の枠へ移してからもう一度測って、左右・上下・幅・高さと角の丸みを補間します。中の絵は、枠を覆う大きさ(object-fit の cover に当たるもの)を枠と一緒に動かすので、縦長のカードから横長の見出し画像へ変わっても、絵が伸び縮みしません。

広がるのは 1 秒、イージングは power3.inOut です。始まりと終わりがゆっくりなので、画面の端まで動く大きな画像でも目で追えます。周りのカードと押したカードの名前は画面の下まで抜けきる量だけ沈め、一覧の見出しは上のマスクへ抜きます。透明にして消さないので、途中で 2 つのページの文字が重なって読めなくなることがありません。地の色は 0.8 秒かけて明るい地から暗い地へ移り、暗い絵に載ることになるナビの文字は 0.5 秒で白へ寄せます。詳細の文字は、画像が収まりきる前から順にマスクの下へ出てきます。

画像を一覧の枠から詳細の枠へ移すと、その瞬間に行き先の位置と大きさへ跳びます。移す前に測っておいた見た目にいったん戻し、そこから補間するのが要点です。まわりの要素も同時に動くので、動かす画像には absolute を付けて流れから外しておかないと、測った位置がずれて着地が合いません。

つまみは、広がる時間とイージング、周りのカードが沈む量、見出し画像の高さです。実装は、GSAP の Flip.getStateFlip.from で組むか、ブラウザの View Transitions に任せるかのどちらかになります。ポートフォリオや事例紹介、商品一覧から詳細ページへ進むときの遷移に向いています。

コード

HTML
<main>
  <section class="work-list" aria-labelledby="work-heading">
    <h1 class="work-list__heading" id="work-heading">Selected work</h1>
    <ul class="work-list__grid">
      <li>
        <a class="work-card" href="/work/saltmarsh/">
          <figure class="work-card__figure">
            <span class="work-card__frame">
              <img class="work-card__image" src="/images/saltmarsh.jpg" alt="" width="1280" height="430" />
            </span>
            <figcaption class="work-card__caption">
              <b class="work-card__name">Saltmarsh</b>
              <span class="work-card__tag">ブランディング</span>
            </figcaption>
          </figure>
        </a>
      </li>
      <li>
        <a class="work-card" href="/work/northwind/">
          <figure class="work-card__figure">
            <span class="work-card__frame">
              <img class="work-card__image" src="/images/northwind.jpg" alt="" width="1280" height="430" />
            </span>
            <figcaption class="work-card__caption">
              <b class="work-card__name">Northwind</b>
              <span class="work-card__tag">Webサイト・アプリ</span>
            </figcaption>
          </figure>
        </a>
      </li>
    </ul>
  </section>

  <!-- 詳細。画像の枠は空にしておき、押されたカードの img がここへ移ってくる -->
  <article class="work-detail" aria-labelledby="detail-title" hidden>
    <div class="work-detail__hero"></div>
    <a class="work-detail__back" href="/work/">← 一覧に戻る</a>
    <div class="work-detail__text">
      <p class="work-detail__eyebrow"><span class="work-detail__reveal">Web & product</span></p>
      <h2 class="work-detail__title" id="detail-title"><span class="work-detail__reveal">Northwind</span></h2>
      <p class="work-detail__lead">
        <span class="work-detail__reveal">海沿いを走る鉄道の予約サイトを、落ち着いた時刻表と、旅の計画を一緒に立てる地図を軸に作り直しました。</span>
      </p>
    </div>
  </article>
</main>
CSS
:root {
  --paper: #f2f1ed;   /* 一覧の地 */
  --ink: #262626;     /* 一覧の文字 */
  --night: #111111;   /* 詳細の地 */
  --on-night: #eeece6;/* 詳細の文字 */
  --mute: #6b6b6b;    /* 補足の文字 */
  --radius: 4px;
  --gutter: clamp(24px, 7vw, 96px);
  --hero-height: min(60vh, 430px);
}

body {
  margin: 0;
  background-color: var(--paper);
  color: var(--ink);
  /* 一覧と詳細の違いは、幕ではなく地の色そのもので見せる */
  transition: background-color 0.8s ease, color 0.5s ease;
}

body.is-detail {
  background-color: var(--night);
  color: var(--on-night);
}

.work-list { padding: 112px var(--gutter) 64px; }
.work-list__heading { margin: 0 0 32px; font-size: 44px; letter-spacing: -0.02em; }

.work-list__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.work-card { display: block; color: inherit; text-decoration: none; }
.work-card:focus-visible { outline: 2px solid currentColor; outline-offset: 8px; }
.work-card__figure { margin: 0; }

/* 画像を詳細へ移しているあいだも枠が潰れないよう、高さは比で決める */
.work-card__frame {
  display: block;
  overflow: hidden;
  aspect-ratio: 11 / 10;
  border-radius: var(--radius);
}

/* object-fit を cover にしておくと、枠の比が変わっても絵は伸び縮みしない */
.work-card__image { display: block; width: 100%; height: 100%; object-fit: cover; }
.work-card__caption { display: flex; flex-direction: column; gap: 4px; margin-top: 16px; }

.work-card__name { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
.work-card__tag { font-size: 14px; color: var(--mute); }

.work-detail { position: relative; padding-bottom: 64px; }

/* 見出し画像の枠。ここへ img が移ってくる */
.work-detail__hero { overflow: hidden; height: var(--hero-height); }
.work-detail__hero .work-card__image { border-radius: 0; }

.work-detail__back {
  position: absolute;
  left: var(--gutter);
  top: 32px;
  padding: 0 20px;
  border-radius: 999px;
  background-color: #ffffff;
  color: var(--night);
  font-size: 14px;
  font-weight: 600;
  line-height: 40px;
  text-decoration: none;
}

.work-detail__text { padding: 40px var(--gutter) 0; }

/* せり上がる行のマスク。中の span だけを動かす */
.work-detail__eyebrow,
.work-detail__title,
.work-detail__lead { overflow: hidden; margin: 0; }

.work-detail__reveal { display: block; }
.work-detail__eyebrow { font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.work-detail__title { padding-bottom: 6px; font-size: clamp(40px, 8vw, 72px); letter-spacing: -0.02em; }
.work-detail__lead { max-width: 34em; margin-top: 16px; font-size: 18px; line-height: 1.8; }

@media (prefers-reduced-motion: reduce) { body { transition-duration: 0.01s; } }
JavaScript
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";

gsap.registerPlugin(Flip);

const EXPAND = 1; // 画像が広がる・戻る時間(秒)
const EASE = "power3.inOut";
const SINK = "110vh"; // 周りのカードが沈む量。画面の下まで抜ける
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const list = document.querySelector(".work-list");
const detail = document.querySelector(".work-detail");
const hero = detail.querySelector(".work-detail__hero");
const cards = list.querySelectorAll(".work-card");
const reveals = detail.querySelectorAll(".work-detail__reveal");
let opened = null; // 開いているカード。戻すとき、画像をこの枠へ返す

// 移す前に測り(First)、行き先の枠へ移してから測り直し(Last)、測った見た目から補間する。これが FLIP
function flip(image, slot, settle) {
  const state = Flip.getState(image, { props: "borderRadius" });
  settle?.(); // 行き先を測る前に、一覧を落ち着き先の位置へ置いておく
  slot.append(image);
  return Flip.from(state, {
    duration: EXPAND,
    ease: EASE,
    absolute: true, // まわりが同時に動いても、行き先がずれない
    zIndex: 10,
  }).timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、ほぼ一瞬で切り替える
}

function open(card) {
  opened = card;
  detail.hidden = false;
  document.body.classList.add("is-detail");
  flip(card.querySelector(".work-card__image"), hero)
    .to(cards, { y: SINK, duration: 0.7, ease: "power3.in", stagger: 0.05 }, 0)
    // 画像が収まりきる前から、下の行から順に出す
    .fromTo(reveals, { yPercent: 110 }, { yPercent: 0, duration: 0.9, ease: "expo.out", stagger: 0.05 }, 0.5);
}

function close() {
  const frame = opened.querySelector(".work-card__frame");
  opened = null;
  document.body.classList.remove("is-detail");
  // 沈んだままのカードを先に戻してから測る。測らずに移すと、画面の外が行き先になる
  flip(hero.querySelector(".work-card__image"), frame, () => gsap.set(cards, { y: 0 }))
    .to(reveals, { yPercent: 110, duration: 0.25, ease: "power2.in", stagger: 0.02 }, 0)
    .from(cards, { y: SINK, duration: 0.9, ease: "power3.out", stagger: 0.05 }, 0.1)
    .then(() => {
      detail.hidden = true;
    });
}

// リンクのクリックを横取りし、URL を先に進めてから演出する
list.addEventListener("click", (event) => {
  const card = event.target.closest(".work-card");
  if (!card || opened || event.metaKey || event.ctrlKey || event.shiftKey) return;
  event.preventDefault();
  history.pushState({ work: true }, "", card.href);
  open(card);
});

// 戻るボタンは履歴を 1 つ戻すだけ。ブラウザの戻ると同じ道にそろえる
detail.querySelector(".work-detail__back").addEventListener("click", (event) => {
  event.preventDefault();
  history.back();
});

addEventListener("popstate", () => {
  if (opened) close();
});

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

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