デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
FLIP という型で作っています。一覧のカードにある画像の位置と大きさを先に測り、その画像を次のページの見出し画像の枠へ移してからもう一度測って、左右・上下・幅・高さと角の丸みを補間します。中の絵は、枠を覆う大きさ(object-fit の cover に当たるもの)を枠と一緒に動かすので、縦長のカードから横長の見出し画像へ変わっても、絵が伸び縮みしません。
広がるのは 1 秒、イージングは power3.inOut です。始まりと終わりがゆっくりなので、画面の端まで動く大きな画像でも目で追えます。周りのカードと押したカードの名前は画面の下まで抜けきる量だけ沈め、一覧の見出しは上のマスクへ抜きます。透明にして消さないので、途中で 2 つのページの文字が重なって読めなくなることがありません。地の色は 0.8 秒かけて明るい地から暗い地へ移り、暗い絵に載ることになるナビの文字は 0.5 秒で白へ寄せます。詳細の文字は、画像が収まりきる前から順にマスクの下へ出てきます。
画像を一覧の枠から詳細の枠へ移すと、その瞬間に行き先の位置と大きさへ跳びます。移す前に測っておいた見た目にいったん戻し、そこから補間するのが要点です。まわりの要素も同時に動くので、動かす画像には absolute を付けて流れから外しておかないと、測った位置がずれて着地が合いません。
つまみは、広がる時間とイージング、周りのカードが沈む量、見出し画像の高さです。実装は、GSAP の Flip.getState と Flip.from で組むか、ブラウザの View Transitions に任せるかのどちらかになります。ポートフォリオや事例紹介、商品一覧から詳細ページへ進むときの遷移に向いています。
コード
<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>
: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; } }
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();
});