デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
読みものの一覧で記事の題を押すと、ほかの行と一覧の見出しが沈みながら消え、押した題だけが位置と字の大きさを補間しながら上へ移って、そのまま記事のページの見出しになります。見出しが収まるころに、日付・リード文・本文が段落ごとに下から続きます。「一覧へ戻る」やブラウザの「戻る」では、本文が沈み、見出しが元の行へ縮みながら戻って、ほかの行が浮かび上がります。画像が広がって見出し画像になる遷移や、見出しの字が入れ替わる遷移と違い、押した文字そのものが次のページへ持ち越されるので、どの記事を開いたのかを目で追えます。
題は 0.9 秒の power3.inOut で、24px から 56px まで大きくなります。ほかの行は 0.5 秒の power2.in で 48px 沈みながら消え、押した行から遠い行ほど 0.05 秒ずつ遅らせるので、押した所から波が引くように見えます。題が動き出すのは押してから 0.1 秒後、本文が出始めるのは 0.7 秒後で、段落ごとに 0.08 秒ずらし、0.8 秒かけて 40px 下から出します。
作りは FLIP の型です。一覧の題(First)と記事の見出し(Last)の位置・幅・字の大きさを測り、画面に固定した題の写しをそのあいだで動かします。scale で拡大すると字がにじみ、折り返しの違う見出しでは縦と横の倍率がずれて歪むので、fontSize と幅を補間しています。行き先は、記事を見えないまま visibility の hidden で組んで測ります。行の高さを字の大きさに対する比にして一覧と見出しでそろえておくと、補間の途中で字が上下に跳ねません。写しは地の色で塗り、沈んでいく行の上を通るときに下の字と重なって読めなくならないようにしています。
一覧と記事は同じページの同じマスに重ねて置き、記事の日付と本文だけを押したリンクの先から読み込みます。URL は pushState で書き換え、ブラウザの「戻る」「進む」でも同じ道をたどります。開き終えたら読み上げとキーボードの位置を記事の見出しへ移し、戻ったら開いた記事の行へ戻します。一覧のスクロール位置は覚えておき、戻るときにその位置へ戻してから題を下ろすので、長い一覧の途中から開いても元の行へ帰ってきます。
つまみは、題が移る時間と ease、ほかの行が沈む量と遅れ、本文が出るまでの間です。沈む量を大きくすると一覧が落ちていくように、遅れを 0 にすると一覧がまとめて消えるように見えます。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。ブログやお知らせ、制作事例の一覧のように、題名から中身へ入っていくページの遷移に向いています。
コード
<!-- 一覧と記事を同じページに置く。記事の日付と本文は、押したリンクの先のページ(data-post-meta・data-post-body)から読み込む -->
<header class="site-header">
<a class="site-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav aria-label="メイン">
<ul class="site-header__links">
<li><a href="/works/">作品</a></li>
<li><a href="/about/">事務所について</a></li>
<li><a href="/journal/" aria-current="page">読みもの</a></li>
<li><a href="/contact/">お問い合わせ</a></li>
</ul>
</nav>
</header>
<main class="journal">
<section class="journal-list" data-list>
<div class="journal-list__head">
<p class="journal-list__eyebrow">Journal</p>
<div class="journal-list__line">
<h1 class="journal-list__title">読みもの</h1>
<p class="journal-list__count">全24件</p>
</div>
</div>
<ul class="journal-list__rows">
<li class="post-row">
<a class="post-row__link" href="/journal/margin/"><time class="post-row__date" datetime="2026-09-26">2026年9月26日</time><span class="post-row__title">写真を主役にする余白の取り方</span><span class="post-row__tag">デザイン</span><span class="post-row__thumb post-row__thumb--slate"></span></a>
</li>
<li class="post-row">
<a class="post-row__link" href="/journal/three-pages/"><time class="post-row__date" datetime="2026-09-12">2026年9月12日</time><span class="post-row__title">小さな店のサイトに要る三つの画面</span><span class="post-row__tag">設計</span><span class="post-row__thumb post-row__thumb--greige"></span></a>
</li>
<li class="post-row">
<a class="post-row__link" href="/journal/line-height/"><time class="post-row__date" datetime="2026-08-29">2026年8月29日</time><span class="post-row__title">読みやすい和文の行間と字間</span><span class="post-row__tag">文字組み</span><span class="post-row__thumb post-row__thumb--sage"></span></a>
</li>
<li class="post-row">
<a class="post-row__link" href="/journal/form/"><time class="post-row__date" datetime="2026-08-08">2026年8月8日</time><span class="post-row__title">問い合わせが増えたフォームの直し方</span><span class="post-row__tag">改善</span><span class="post-row__thumb post-row__thumb--rose"></span></a>
</li>
<li class="post-row">
<a class="post-row__link" href="/journal/transition/"><time class="post-row__date" datetime="2026-07-18">2026年7月18日</time><span class="post-row__title">動きで迷わせないページ遷移の考え方</span><span class="post-row__tag">動き</span><span class="post-row__thumb post-row__thumb--lavender"></span></a>
</li>
</ul>
</section>
<article class="post" data-post hidden>
<a class="post__back" href="/journal/"><span aria-hidden="true">←</span> 一覧へ戻る</a>
<p class="post__meta" data-post-meta></p>
<h1 class="post__title" data-post-title></h1>
<div class="post__body" data-post-body></div>
</article>
</main>
:root {
--page-bg: #f2f1ed;
--page-ink: #262626;
--page-mute: #6b6b6b;
--line: rgb(38 38 38 / 0.12);
--gutter: clamp(24px, 7vw, 96px);
}
body { margin: 0; background: var(--page-bg); color: var(--page-ink); }
[hidden] { display: none !important; }
.site-header { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 32px; padding: 32px var(--gutter); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.site-header__logo { margin-right: auto; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; white-space: nowrap; }
.site-header__links { display: flex; flex-wrap: wrap; gap: 8px 32px; margin: 0; padding: 0; list-style: none; }
.site-header a { color: var(--page-mute); text-decoration: none; }
.site-header .site-header__logo,
.site-header a[aria-current="page"] { color: var(--page-ink); }
.site-header a[aria-current="page"] { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 6px; }
/* 一覧と記事を同じマスに重ねる。遷移のあいだ、両方の位置を測れるように */
.journal { display: grid; padding: 48px var(--gutter) 96px; }
.journal > * { grid-area: 1 / 1; min-width: 0; }
.is-measuring { visibility: hidden; } /* 見えないまま組んで、行き先を測る */
.journal-list__eyebrow { margin: 0 0 12px; font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--page-mute); }
.journal-list__line { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; margin-bottom: 32px; }
.journal-list__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.journal-list__count { margin: 0; font-size: 14px; letter-spacing: 0.04em; color: var(--page-mute); }
.journal-list__rows { margin: 0; padding: 0; list-style: none; box-shadow: inset 0 -1px 0 var(--line); }
/* 行の地を塗る(沈むあいだ、下の行の字が透けないように) */
.post-row { background: var(--page-bg); box-shadow: inset 0 1px 0 var(--line); } /* 罫線は影で引き、行の高さを変えない */
.post-row__link {
display: grid;
grid-template-columns: 160px minmax(0, 1fr) auto 112px;
align-items: center;
gap: 24px;
min-height: 80px;
color: inherit;
text-decoration: none;
}
.post-row__link:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
.post-row__date { font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; color: var(--page-mute); }
.post-row__title, .title-flier { font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.post-row__tag { padding: 6px 12px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; color: var(--page-mute); white-space: nowrap; }
/* 写真の代わりの丘と日。案件では img に差し替える */
.post-row__thumb {
height: 64px;
border-radius: 4px;
background:
radial-gradient(circle 9px at 74% 34%, var(--c) 0 92%, transparent 100%),
radial-gradient(70% 80% at 24% 112%, var(--c) 0 98%, transparent 100%),
radial-gradient(60% 64% at 86% 118%, color-mix(in srgb, var(--c) 72%, var(--page-ink)) 0 98%, transparent 100%),
color-mix(in srgb, var(--c) 42%, var(--page-bg));
}
.post-row__thumb--slate { --c: #62839d; }
.post-row__thumb--greige { --c: #A98860; }
.post-row__thumb--sage { --c: #6f9770; }
.post-row__thumb--rose { --c: #ba7069; }
.post-row__thumb--lavender { --c: #857ba5; }
@media (max-width: 700px) {
.post-row__link { grid-template-columns: minmax(0, 1fr) 88px; gap: 4px 16px; padding: 16px 0; }
.post-row__date, .post-row__title, .post-row__tag { grid-column: 1; }
.post-row__tag { justify-self: start; margin-top: 4px; }
.post-row__thumb { grid-column: 2; grid-row: 1 / span 3; height: 56px; }
.post-row__title { font-size: 18px; }
}
/* 記事 */
.post__back { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 24px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--page-ink); color: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none; justify-self: start; }
.post__back:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
.post { display: grid; align-content: start; margin-top: -48px; }
.post__meta { margin: 32px 0 18px; font-size: 14px; line-height: 1; letter-spacing: 0.04em; color: var(--page-mute); }
.post__title { margin: 0; font-size: clamp(32px, 4.4vw, 56px); font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.post__title:focus { outline: none; } /* 読み上げの位置を移すためだけの焦点なので、枠は出さない */
.post__body { max-width: 40em; margin-top: 40px; font-size: 16px; line-height: 1.8; letter-spacing: 0.04em; }
.post__body > * { margin: 0 0 24px; }
.post__body .post__lead { margin-bottom: 48px; font-size: 18px; }
/* 押した題の写し。画面に固定して、一覧の行と記事の見出しのあいだを動く(位置・幅・字の大きさは JS が入れる) */
.title-flier { position: fixed; z-index: 10; margin: 0; background: var(--page-bg); pointer-events: none; }
import { gsap } from "gsap";
const SINK = 48, RISE = 40; // ほかの行が沈む量と、本文が下から来る量(px)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const list = document.querySelector("[data-list]"), post = document.querySelector("[data-post]");
const heading = post.querySelector("[data-post-title]"), rows = [...list.querySelectorAll(".post-row")];
let opened = null, busy = false; // 開いている記事の行と、開く前の一覧のスクロール位置
// 記事のページを読み、日付と本文を取り出す
async function load(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
const doc = new DOMParser().parseFromString(await response.text(), "text/html");
const body = doc.querySelector("[data-post-body]"); // 無ければ、下の children で失敗してふつうに移る
return { body: [...body.children], meta: doc.querySelector("[data-post-meta]")?.textContent ?? "" };
}
// First と Last: 画面の中の位置・幅・字の大きさ。shift は、着いたときのスクロールとの差
const measure = (el, shift = 0, r = el.getBoundingClientRect()) =>
({ left: r.left, top: r.top + shift, width: Math.ceil(r.width) + 1, fontSize: parseFloat(getComputedStyle(el).fontSize) });
// 題の写しを画面に固定して置く。字の大きさを補間するので、scale と違って字がにじまず、折り返しも着いた形に合う
const makeFlier = (from, first) =>
gsap.set(document.body.appendChild(Object.assign(document.createElement("p"), { className: "title-flier", textContent: from.textContent, ariaHidden: "true" })), first).targets()[0];
// 押した行から遠いものほど遅らせる(一覧の見出しの組は、0 行目の上の行として数える)
const parts = (row) => [list.querySelector(".journal-list__head"), ...rows].map((el, k) => ({ el, far: Math.abs(k - 1 - rows.indexOf(row)) }));
const speed = (tl) => tl.timeScale(reduceMotion ? 20 : 1); // 動きを減らす設定では、同じ流れのままほぼ一瞬で
const blocks = () => [...post.querySelectorAll(".post__back, .post__meta, [data-post-body] > *")];
async function open(row, url) {
const title = row.querySelector(".post-row__title"), loading = load(url), first = measure(title);
heading.textContent = title.textContent;
post.hidden = false;
post.classList.add("is-measuring"); // 見えないまま組んで行き先を測る。着いたときはスクロールを 0 にしている
const flier = makeFlier(title, first);
title.style.visibility = heading.style.visibility = "hidden"; // 写しが動くあいだ、元の題と見出しは隠す
const tl = speed(gsap.timeline());
parts(row).forEach(({ el, far }) => tl.to(el, { y: SINK, opacity: 0, duration: 0.5, ease: "power2.in" }, 0.05 * far));
tl.to(flier, { ...measure(heading, scrollY), duration: 0.9, ease: "power3.inOut" }, 0.1);
opened = { row, scroll: scrollY };
try {
const [{ body, meta }] = await Promise.all([loading, new Promise((done) => tl.call(done, null, 0.7))]);
post.querySelector("[data-post-meta]").textContent = meta;
post.querySelector("[data-post-body]").replaceChildren(...body);
} catch { location.href = url; return; } // 読めなければ、ふつうのページ移動に任せる
list.hidden = true; // 一覧が消えきってから入れ替え、記事の頭へスクロールする(写しは画面に固定なので動かない)
post.classList.remove("is-measuring");
scrollTo(0, 0);
speed(gsap.fromTo(blocks(), { y: RISE, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, ease: "expo.out", stagger: 0.08 }));
await tl;
heading.style.visibility = "";
flier.remove();
heading.tabIndex = -1; // 読み上げとキーボードの位置を、記事の見出しへ移す
heading.focus({ preventScroll: true });
}
async function close() {
const { row, scroll } = opened, title = row.querySelector(".post-row__title");
const flier = makeFlier(heading, measure(heading));
heading.style.visibility = "hidden";
const items = parts(row), els = items.map(({ el }) => el);
list.hidden = false;
list.classList.add("is-measuring"); // 見えないまま組み、沈めた行をいったん戻して、題の行き先を測る
gsap.set(els, { y: 0 });
const last = measure(title, scrollY - scroll);
gsap.set(els, { y: SINK });
const tl = speed(gsap.timeline())
.to(blocks(), { y: RISE, opacity: 0, duration: 0.35, ease: "power2.in", stagger: 0.04 }, 0)
.to(flier, { ...last, duration: 0.9, ease: "power3.inOut" }, 0.15)
.call(() => { post.hidden = true; list.classList.remove("is-measuring"); scrollTo(0, scroll); }, null, 0.5);
items.forEach(({ el, far }) => tl.to(el, { y: 0, opacity: 1, duration: 0.7, ease: "power3.out" }, 0.55 + 0.05 * far));
await tl;
gsap.set(els, { clearProps: "transform,opacity" });
title.style.visibility = "";
flier.remove();
opened = null;
row.querySelector("a").focus({ preventScroll: true }); // 焦点を、開いた記事の行へ戻す
}
// URL と見えている画面をそろえる。遷移の途中に戻る・進むが押されたら、終わってからそろえる
async function sync() {
if (busy) return;
const row = rows.find((r) => r.querySelector("a").href === location.href);
if (opened ? opened.row === row : !row) return;
busy = true;
await (opened ? close() : open(row, location.href));
busy = false;
sync();
}
list.addEventListener("click", (event) => {
const link = event.target.closest(".post-row__link");
if (!link || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (!busy) { history.pushState({ post: true }, "", link.href); sync(); }
});
// 戻るボタンは履歴を 1 つ戻すだけ。ブラウザの「戻る」と同じ道にそろえる
post.querySelector(".post__back").addEventListener("click", (event) => { event.preventDefault(); if (!busy) history.back(); });
addEventListener("popstate", sync);