デモ
デモを読み込めませんでした。
仕様
- 用途
- ページ遷移
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
リンクが押されたら次のページを読み込み、画面を 8×5 のタイルに割って、1 枚ずつ「表に今のページ・裏に次のページ」の同じ部分を持つ板にします。表と裏の面には、それぞれのページの複製をタイルの位置の分だけずらして入れ、裏の面はあらかじめ 180° 回しておきます。本物のページはタイルの下で次のページに差し替え、タイルを左上から波のように rotationY で裏返すと、返った所から次のページが現れます。色面で覆う遷移と違い、ページそのものが細かく割れて返るので、ページが入れ替わる様子がそのまま見えます。
1 枚が裏返るのは 0.9 秒の power2.inOut で、最初のタイルと最後のタイルの動き出しを 0.9 秒ずらします。ずらし方は stagger の grid に行と列の数を渡し、左上の角からの距離で決めています。裏返る途中はタイルを 0.84 倍まで縮めてから戻し、見えている面は真横を向くまでに 55% の黒を重ねて暗くします。縮んだタイルの隙間に暗い地がのぞいて、奥行きが出ます。明るいページと暗いページの面が混ざって返るので、波がどこまで進んだかが明暗で読めます。
表と裏の面には backface-visibility の hidden を付けてください。付けないと裏を向いた面も描かれ、左右が反転したページが透けて見えます。遠近はタイルごとの枠に perspective で付け、タイルの幅の 5 倍にしています。面の中身はページを丸ごと複製しているので、タイルの数の 2 倍だけ複製ができ、要素の多いページでは重くなります。狭い画面では CSS 変数でタイルを 4×7 に減らしています。複製の id は外し、タイルの層に inert を付けて、焦点や読み上げが入らないようにします。
差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。ブラウザの「戻る」では右下から、「進む」ではリンクと同じ左上から裏返すので、進んだのか戻ったのかが波の向きで読めます。戻るか進むかは、履歴の state に残した深さで見分けます。
つまみは、タイルの行と列の数、1 枚が裏返る時間、波の広がり、縮む大きさと陰の濃さです。ずらし方の起点を center にすると、中央から波紋のように返ります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。建築やインテリア、写真の作品集のように、ページ全体をひとつながりの画面として見せたいサイトの遷移に向いています。
コード
<!-- 遷移で差し替えるのは data-page の中だけ。次のページも同じ形で書き、地の色はページのクラスで変える -->
<div class="page page--paper" data-page>
<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="/studio/" aria-current="page">事務所</a></li>
<li><a href="/projects/">設計事例</a></li>
<li><a href="/journal/">読みもの</a></li>
<li><a href="/contact/">お問い合わせ</a></li>
</ul>
</nav>
</header>
<main class="hero">
<p class="hero__eyebrow">Architecture & interiors</p>
<h1 class="hero__title">光の集まる<br>場所をつくる</h1>
<p class="hero__lead">ここに説明文が入ります。</p>
<a class="button" href="/projects/">設計事例を見る</a>
</main>
</div>
:root {
--tile-gap: #15130f; /* 裏返るタイルの隙間に見える暗い地 */
--gutter: clamp(24px, 7vw, 96px);
}
body { margin: 0; }
/* ページの色はページごとに持つ。明るいページと暗いページの面が混ざって裏返るので、波の進み具合が明暗で読める */
.page {
min-height: 100vh;
background: var(--page-bg);
color: var(--page-ink);
}
.page--paper { --page-bg: #f2f1ed; --page-ink: #262626; --page-mute: #6b6b6b; --button-bg: #262626; --button-ink: #ffffff; }
.page--night { --page-bg: #111111; --page-ink: #eeece6; --page-mute: #a8a6a0; --button-bg: #eeece6; --button-ink: #111111; }
/* 裏返るあいだだけ置くタイルの層。画面に固定し、--cols × --rows 枚を並べる(どれも JS が付け外しする) */
.tiles {
--cols: 8;
--rows: 5;
position: fixed;
inset: 0;
z-index: 100;
overflow: hidden;
background: var(--tile-gap);
}
/* 1 枚ごとにページを表と裏で 2 つ複製するので、狭い画面は枚数を減らして縦長に割る */
@media (max-width: 600px) {
.tiles { --cols: 4; --rows: 7; }
}
.tile { position: absolute; }
.tile__card { position: absolute; inset: 0; transform-style: preserve-3d; }
/* 表に今のページ、裏に次のページの同じ部分を入れる。裏を向いた面は描かない */
.tile__face { position: absolute; inset: 0; overflow: hidden; backface-visibility: hidden; }
.tile__face--back { transform: rotateY(180deg); }
/* 面の陰。真横を向くころにいちばん濃くする */
.tile__shade { position: absolute; inset: 0; background: #000; opacity: 0; visibility: hidden; }
.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; white-space: nowrap; }
.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; }
.hero { padding: 96px var(--gutter); }
.hero__eyebrow {
margin: 0 0 24px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--page-mute);
}
.hero__title { margin: 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.hero__lead { margin: 24px 0 40px; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.button {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 32px;
border-radius: 999px;
background: var(--button-bg);
color: var(--button-ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.page a:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }
/* 遷移のあとに見出しへ移したフォーカスは、枠を出さない(読み上げの位置を移すためだけのもの) */
.page h1[tabindex="-1"]:focus { outline: none; }
import { gsap } from "gsap";
const FLIP = 0.9; // 1 枚が裏返る時間(秒)
const SPREAD = 0.9; // 最初のタイルと最後のタイルが動き出す時刻の差(秒)
const DEPTH = 0.84; // 裏返る途中で縮む大きさ
const SHADE = 0.55; // 真横を向いたときの面の暗さ
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let busy = false;
let shownPath = location.pathname; // いま見せているページ
let depth = history.state?.depth ?? 0; // 履歴の深さ。戻る・進むのどちらかを見分ける
history.scrollRestoration = "manual"; // スクロール位置は、差し替えたあとに自分で戻す
// 次のページの HTML を読み、差し替える部分(data-page)とタイトルを取り出す
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 next = doc.querySelector("[data-page]");
if (!next) throw new Error("data-page が無い");
return { next, title: doc.title };
}
function box(className, ...children) {
const el = Object.assign(document.createElement("div"), { className });
el.append(...children);
return el;
}
// ページを複製し、見えている位置からタイルの位置の分だけずらして面に入れる。面の陰の板も重ねる
function face(page, rect, x, y, side) {
const copy = page.cloneNode(true);
copy.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); // 同じ id を何個も作らない
Object.assign(copy.style, { position: "absolute", left: `${rect.left - x}px`, top: `${rect.top - y}px`, width: `${rect.width}px`, margin: "0" });
return box(`tile__face tile__face--${side}`, copy, box("tile__shade"));
}
// origin が "start" なら左上から、"end" なら右下から順に裏返す
async function flip(url, origin, push) {
const { next, title } = await load(url);
if (push) history.pushState({ depth: ++depth }, "", url);
shownPath = new URL(url).pathname;
const current = document.querySelector("[data-page]");
const before = current.getBoundingClientRect(); // 今のページは、見えている位置のまま表に写す
const layer = Object.assign(box("tiles"), { inert: true }); // 複製のリンクに焦点や読み上げが入らないように
document.body.append(layer);
current.replaceWith(next); // 本物のページは、タイルの下で次のページに差し替える
scrollTo(0, 0);
const after = next.getBoundingClientRect();
const { clientWidth: width, clientHeight: height } = layer;
const [cols, rows] = ["--cols", "--rows"].map((name) => parseInt(getComputedStyle(layer).getPropertyValue(name)));
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const [x, y] = [Math.round((c * width) / cols), Math.round((r * height) / rows)]; // 端数は隣とそろえ、継ぎ目を空けない
const w = Math.round(((c + 1) * width) / cols) - x;
const tile = layer.appendChild(box("tile", box("tile__card", face(current, before, x, y, "front"), face(next, after, x, y, "back"))));
Object.assign(tile.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${Math.round(((r + 1) * height) / rows) - y}px`, perspective: `${w * 5}px` });
}
}
const cards = layer.querySelectorAll(".tile__card");
const shades = (side) => layer.querySelectorAll(`.tile__face--${side} > .tile__shade`);
const stagger = { grid: [rows, cols], from: origin, amount: SPREAD };
await gsap.timeline().timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、ほぼ一瞬で切り替える
.to(cards, { rotationY: 180, duration: FLIP, ease: "power2.inOut", stagger }, 0)
.to(cards, { scale: DEPTH, duration: FLIP / 2, ease: "sine.inOut", stagger: { ...stagger, yoyo: true, repeat: 1 } }, 0)
.fromTo(shades("front"), { autoAlpha: 0 }, { autoAlpha: SHADE, duration: FLIP / 2, ease: "power1.in", stagger }, 0) // 真横を向くまで暗く
.fromTo(shades("back"), { autoAlpha: SHADE }, { autoAlpha: 0, duration: FLIP / 2, ease: "power1.out", stagger }, FLIP / 2); // 回ってきた面は明るく
layer.remove();
document.title = title;
const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
heading?.setAttribute("tabindex", "-1");
heading?.focus({ preventScroll: true });
}
async function go(url, origin, push) {
if (busy) return;
busy = true;
await flip(url, origin, push).catch(() => location.assign(url)); // 読み込めなかったら、ふつうに移る
busy = false;
}
// 同じサイトのほかのページへのリンクだけ横取りする(別タブ・修飾キー付き・同じページ内のリンクは邪魔しない)
document.addEventListener("click", (event) => {
const link = event.target.closest("a[href]");
if (!link || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.target || link.hasAttribute("download")) return;
const url = new URL(link.href);
if (url.origin !== location.origin || url.pathname === location.pathname) return;
event.preventDefault();
go(url.href, "start", true);
});
// ブラウザの戻るでは右下から、進むはリンクと同じ左上から裏返す
addEventListener("popstate", (event) => {
if (location.pathname === shownPath) return;
const to = event.state?.depth ?? 0;
go(location.href, to < depth ? "end" : "start", false);
depth = to;
});