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

ページ遷移でカメラのシャッターの羽根が閉じて開くと次のページになる

デモ

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

仕様

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

しくみと調整

リンクが押されたら、画面の外に置いた 6 つの軸のまわりで扇形の羽根を回し、カメラの絞りのように閉じます。閉じきったところでページを差し替え、続けて逆向きに開くと次のページになっています。羽根は扇の直線の縁だけが画面にかかるので、6 枚そろうと正六角形の穴になり、回すほど穴が回りながら縮みます。穴の内接半径は「軸までの距離 × 回した角の sin」で決まるので、角をひとつ動かすだけで 6 枚が同じ絞りを作ります。画面が左右に割れる遷移や、幕が横切る遷移と違い、四方から均等に絞り込まれる切り替えになります。

閉じるのは 0.72 秒の power1.in、0.054 秒だけ止めてから、0.72 秒の power2.out で開きます。閉じぎわが速く、開き始めも速いので、シャッターを切ったような歯切れになります。軸までの距離は、画面の中心から角までの距離の 1.36 倍です。扇の開きは 66° で、軸から画面を見込む角(この距離では約 47°)より広くとってあります。狭いと、閉じきる前に羽根の外側の縁が画面に入ってしまいます。

動かすのは角ではなく、穴の半径そのものにしてください。角をまっすぐ補間すると、穴が画面より大きいあいだは何も起きず、押してから動き出すまでに間が空きます。半径を補間して、そこから角を逆算すると、押した瞬間から絞りが動きます。扇の半径は、軸から画面の反対側の角まで届く長さにします。短いと、閉じたときに扇の外の弧が画面の中に見えます。

羽根は不透明にし、青みのある墨色から順に濃くなる 6 色を重ねます。後から描いた羽根が前の羽根を覆うので、閉じた画面は濃淡の違う 6 枚の面が風車のように並び、重なりの順序がそのまま見えます。半透明にすると、閉じきってもページが透け、差し替えが見えてしまいます。羽根の層は画面全体をふさぐので、pointer-events を切っておかないと、開いたあともページを押せないままになります。差し替えたあとは、ページの題名と URL を書き換え、読み上げとキーボードの位置を新しいページの見出しへ移します。

つまみは、羽根の枚数、閉じる時間と開く時間、軸までの距離、羽根の色の段、縁に差す光の強さです。枚数を増やすと穴が円に近づき、5 枚に減らすと星形に近い絞りになります。軸を近づけると、同じ角でも羽根が大きく走ります。動きを減らす設定では、同じ流れのまま、ほぼ一瞬で切り替えます。写真・映像・ギャラリーのように、カメラの手つきを見せたいサイトのページ遷移に向いています。

コード

HTML
<!-- 遷移で差し替えるのは data-page の中だけ。次のページも同じ形で書く -->
<div class="page" 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="/gallery/">作品</a></li>
        <li><a href="/series/">シリーズ</a></li>
        <li><a href="/studio/" aria-current="page">プロフィール</a></li>
        <li><a href="/contact/">お問い合わせ</a></li>
      </ul>
    </nav>
  </header>

  <main class="hero">
    <div class="hero__copy">
      <p class="hero__eyebrow">Photography</p>
      <h1 class="hero__title">光と影を、<br>写真に残す。</h1>
      <p class="hero__lead">ここに説明文が入ります。</p>
      <a class="button" href="/gallery/">作品を見る</a>
    </div>
    <p class="hero__art" role="img" aria-label="夕暮れの光をとらえた作品"></p>
  </main>
</div>

CSS
:root {
  --page-bg: #f2f1ed;
  --page-ink: #262626;
  --page-mute: #6b6b6b;
  --edge: rgb(255 255 255 / 0.16); /* 羽根の縁に差す光 */
  --gutter: clamp(24px, 7vw, 96px);
}

body { margin: 0; background: var(--page-bg); }
.page { min-height: 100vh; background: var(--page-bg); color: var(--page-ink); }

/* 羽根の層。画面に固定し、6 枚の扇を軸のまわりに回して絞りを開け閉めする(JS が付け外しする) */
.shutter { position: fixed; inset: 0; z-index: 100; display: block; width: 100%; height: 100%; pointer-events: none; will-change: transform; }
.shutter path { stroke: var(--edge); stroke-width: 1.5; }

/* 羽根は墨のスレートから順に濃くする。後から描いた羽根が前の羽根を覆うので、閉じた画面は濃淡の違う 6 枚の面が風車のように並ぶ */
.shutter g:nth-child(1) path { fill: #4a5b66; }
.shutter g:nth-child(2) path { fill: #3f4e58; }
.shutter g:nth-child(3) path { fill: #344149; }
.shutter g:nth-child(4) path { fill: #2a343b; }
.shutter g:nth-child(5) path { fill: #1f272c; }
.shutter g:nth-child(6) path { fill: #141a1e; }

.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; white-space: nowrap; }
.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 {
  display: grid;
  gap: 48px;
  align-items: center;
  padding: 48px var(--gutter) 96px;
}

@media (min-width: 861px) {
  .hero { grid-template-columns: 1fr 384px; gap: 64px; }
}

.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; word-break: auto-phrase; }

/* 写真の代わりの画像。案件では img に差し替える */
.hero__art {
  margin: 0;
  height: min(480px, 60vh);
  border-radius: 4px;
  background: linear-gradient(168deg, #e8d9c5 0%, #ba7069 46%, #475c6a 100%);
}

.button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--page-ink);
  color: #ffffff;
  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; }

JavaScript
import { gsap } from "gsap";

const CLOSE = 0.72; // 羽根が閉じきるまでの時間(秒)
const HOLD = 0.054; // 閉じきってから開き始めるまで(秒)
const OPEN = 0.72; // 羽根が開ききるまでの時間(秒)
const LEAVES = 6; // 羽根の枚数。穴はこの数の正多角形になる
const PIVOT = 1.36; // 羽根の軸までの距離(画面の中心から角までの距離に対する比)
const REACH = 1.15; // 扇の半径の余裕(軸から画面の反対の角まで届く長さ)
const APERTURE = 1.02; // 開ききったときの穴の半径(同じく角までの距離に対する比)
const SPAN = 66; // 扇の開き(°)。画面を軸から見込む角より広くする
const NS = "http://www.w3.org/2000/svg";
const rad = (deg) => (deg * Math.PI) / 180;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
let busy = false;
let shownPath = location.pathname; // いま見せているページ
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 };
}

// 6 枚の羽根。軸は画面の外の円周上に等間隔、扇は軸から中心へ向かう向きを 0° として 0〜SPAN° を覆う
function makeShutter() {
  const [w, h] = [innerWidth, innerHeight];
  const corner = Math.hypot(w / 2, h / 2);
  const radius = corner * PIVOT;
  const reach = radius + corner * REACH;
  const shape = `M0 0L${reach} 0A${reach} ${reach} 0 0 1 ${reach * Math.cos(rad(SPAN))} ${reach * Math.sin(rad(SPAN))}Z`;
  const svg = document.createElementNS(NS, "svg");
  for (const [key, value] of [["class", "shutter"], ["viewBox", `0 0 ${w} ${h}`], ["aria-hidden", "true"]]) svg.setAttribute(key, value);
  const leaves = [...Array(LEAVES)].map((_, i) => {
    const leaf = document.createElementNS(NS, "g");
    const turn = (360 / LEAVES) * i;
    leaf.dataset.turn = turn;
    leaf.dataset.pivot = `${w / 2 + radius * Math.cos(rad(turn))} ${h / 2 + radius * Math.sin(rad(turn))}`;
    const path = document.createElementNS(NS, "path");
    path.setAttribute("d", shape);
    leaf.append(path);
    svg.append(leaf);
    return leaf;
  });
  return { svg, leaves, radius, hole: corner * APERTURE };
}

// 穴の半径から羽根の角を逆算して当てる。穴の内接半径は「軸までの距離 × sin(回した角)」
function turnTo(leaves, radius, r) {
  const a = (Math.asin(Math.min(1, r / radius)) * 180) / Math.PI;
  for (const leaf of leaves) leaf.setAttribute("transform", `translate(${leaf.dataset.pivot}) rotate(${Number(leaf.dataset.turn) + 180 + a})`);
}

async function swap(url, push) {
  const { next, title } = await load(url);
  if (push) history.pushState({}, "", url);
  shownPath = new URL(url).pathname;
  const { svg, leaves, radius, hole } = makeShutter();
  const state = { r: hole };
  turnTo(leaves, radius, hole); // 出す前に開ききった角を入れる(羽根が一瞬だけ画面に出るのを防ぐ)
  document.body.append(svg);
  const current = document.querySelector("[data-page]");
  // 角ではなく穴の半径を補間する。角を補間すると、穴が画面より大きいあいだ何も起きない時間ができる
  await gsap
    .timeline({ defaults: { onUpdate: () => turnTo(leaves, radius, state.r) } })
    .timeScale(reduceMotion ? 20 : 1) // 動きを減らす設定では、同じ流れのままほぼ一瞬で切り替える
    .to(state, { r: 0, duration: CLOSE, ease: "power1.in" })
    .add(() => { current.replaceWith(next); scrollTo(0, 0); }) // 差し替えは閉じきってから
    .to(state, { r: hole, duration: OPEN, delay: HOLD, ease: "power2.out" });
  svg.remove();
  document.title = title;
  const heading = next.querySelector("h1"); // 読み上げとキーボードの位置を、新しいページの見出しへ移す
  if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
}

async function go(url, push) {
  if (busy) return;
  busy = true;
  try { await swap(url, push); } catch { location.href = url; } finally { 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, true);
});

// ブラウザの戻る・進むでも、同じシャッターで移る(同じページの中の移動は除く)
addEventListener("popstate", () => {
  if (location.pathname !== shownPath) go(location.href, false);
});

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

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