デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
一覧で押したカードの画像が、そのまま大きく広がって、詳細ページの見出し画像として収まります。まわりのカードは下へ流れて画面の外へ消え、背景は明るい色から暗い色へ。画像が収まりきる前から、詳細の文字が行ごとにマスクの下からせり上がってきます。
作り方は FLIP という手法です。カードの画像の位置と大きさを測り、画像を詳細の見出し画像の場所へ移してから、もう一度測ります。移した瞬間に画像は行き先の位置と大きさへ跳んでしまうので、測っておいた元の見た目にいったん戻し、そこから位置、幅、高さ、角の丸みを補間していきます。途中は画像そのものの幅と高さが変わりますが、object-fit の cover を指定してあるので、ほぼ正方形のカードから横長の見出し画像へ変わっても、絵は伸び縮みしません。
広がる動きは1秒の power3.inOut にしました。始まりと終わりがゆっくりなので、画面の端まで動く大きな画像でも目で追えます。まわりのカードは、押したカードの名前も含めて、画面の下まで抜けきる量だけ位置をずらして外へ追いやります。透明にして消すのではなく動かして外すので、途中で新旧のページの文字が重なって読めなくなることがありません。背景色は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">主な制作実績</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">汐入の塩</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">潮風鉄道</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">潮風鉄道</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();
});