額縁ズームトンネル

奥の額縁が次々と手前に迫り、最後の一枚の絵の中へ吸い込まれるように進むオープニング演出です。美術館・アーカイブ系サイトの作品世界への導入を、CSS 3Dだけで作ります。

#3d-tunnel#perspective#opening#zoom

ライブデモ

使用例(お題: SaaS FlowDesk)

この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- FlowDesk: オンボーディング。画面が次々手前に迫り、最後の画面へ吸い込まれる導入 -->
<div class="stage" data-tunnel aria-label="画面が手前に迫るオンボーディング演出">
  <div class="scene">
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
  </div>
  <div class="vignette" aria-hidden="true"></div>
  <div class="flash" aria-hidden="true"></div>
</div>
CSS
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #0b1430; }

.stage {
  position: relative; width: 100%; height: 100vh;
  min-height: 260px; max-height: 100%; overflow: hidden;
  background: #0b1430; perspective: 800px;
}
.scene { position: absolute; inset: 0; transform-style: preserve-3d; }

.frame {
  position: absolute; left: 50%; top: 50%;
  width: 360px; height: 270px;
  border: 12px solid;
  border-image: linear-gradient(135deg, #9FB6D6, #4D6A9A) 1;
  background: linear-gradient(160deg, #4D7CFE, #4ED1A1);
  will-change: transform, opacity;
  box-shadow: 0 8px 30px rgba(0,0,0,.4);
}
.frame img { width: 100%; height: 100%; object-fit: cover; display: block; }

.vignette {
  position: absolute; inset: 0; pointer-events: none; z-index: 2;
  background: radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 38%, rgba(4,8,22,.72) 100%);
}
.flash { position: absolute; inset: 0; pointer-events: none; z-index: 3; background: #fff; opacity: 0; }
JavaScript
// 各フレームの z を +240px/s、視点通過で最奥へリサイクル(デモと同じロジック)。
(() => {
  const stage = document.querySelector('[data-tunnel]');
  if (!stage) return;
  const frames = [...stage.querySelectorAll('.frame')];
  const flash = stage.querySelector('.flash');

  const FALLBACK = [
    'linear-gradient(160deg,#4D7CFE,#4ED1A1)',
    'linear-gradient(160deg,#7AA0FF,#4D7CFE)',
    'linear-gradient(160deg,#4ED1A1,#4D7CFE)',
    'linear-gradient(160deg,#9B6BFF,#4D7CFE)',
    'linear-gradient(160deg,#4D7CFE,#9B6BFF)'
  ];

  const setImg = (s) => {
    s.el.style.background = FALLBACK[s.n % FALLBACK.length];
    const img = s.el.querySelector('img');
    img.src = `https://picsum.photos/seed/flowdesk-frame-${s.n}/360/270`;
    img.onerror = () => { img.style.display = 'none'; };
  };

  const state = frames.map((el, i) => ({ el, z: -i * 400, off: (i % 2 ? -1 : 1) * 30, n: i }));
  state.forEach(setImg);

  const apply = (s) => {
    s.el.style.transform = `translate(-50%,-50%) translateX(${s.off}px) translateZ(${s.z.toFixed(1)}px)`;
    s.el.style.opacity = Math.max(0, Math.min(1, (s.z + 1800) / 600));
  };
  state.forEach(apply);

  let last = 0;
  const loop = (now) => {
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    for (const s of state) {
      s.z += 240 * dt;
      if (s.z > 250) {
        s.z -= 2000; s.n += 5; setImg(s);
        if (flash) {
          flash.style.transition = 'none'; flash.style.opacity = '0.25';
          requestAnimationFrame(() => { flash.style.transition = 'opacity .2s ease'; flash.style.opacity = '0'; });
        }
      }
      apply(s);
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

実装ガイド

使いどころ

美術館・アーカイブ・オンボーディングの「世界へ導入」に。奥から迫る額縁や画面に吸い込まれる没入オープニングです。

実装時の注意点

Three.js不要。CSS 3D(perspective + translateZ)で板を前進させるだけです。各フレームのzをrAFで加算し、視点通過したら最奥へリサイクル(z-=2000)して画像seedを更新、無限ループします。親にtransform-style:preserve-3d必須。zの更新はstyle.transform文字列の組み直しで行います(個別プロパティ分解はsrcdocで不安定)。

対応ブラウザ

CSS 3D transform・perspectiveは全モダンブラウザで安定動作しますが、preserve-3dの挙動にブラウザ差が出ることがあるため実機確認を推奨します。画像はonerrorでグラデにフォールバックし、対応バージョンは断定しません。

よくある失敗

preserve-3dを親に付け忘れると平面に潰れます。透明度をzに連動させないと最奥のリサイクルが「ポップ」して見えます。通過のフラッシュが無いと切替が唐突になります。画像必須にせずフォールバックを用意します。

応用例

額縁を作品やUI画面に、前進速度や横ずらしを調整、通過時の効果音、最後の1枚から本編へ遷移などに発展できます。

コード

HTML
<!-- 額縁ズームトンネル:奥の額縁が次々手前に迫り、絵の中へ吸い込まれる導入 -->
<div class="stage" data-tunnel aria-label="額縁が手前に迫るトンネル演出">
  <div class="scene">
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
  </div>
  <div class="vignette" aria-hidden="true"></div>
  <div class="flash" aria-hidden="true"></div>
</div>
CSS
:root{
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout:    cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #0F1117; }

.stage {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 260px;
  max-height: 100%;
  overflow: hidden;
  background: #0F1117;
  perspective: 800px;
}
/* 3Dコンテキスト。translateZ させる額の親に preserve-3d 必須 */
.scene { position: absolute; inset: 0; transform-style: preserve-3d; }

.frame {
  position: absolute;
  left: 50%; top: 50%;
  width: 360px; height: 270px;
  border: 14px solid;
  border-image: linear-gradient(135deg, #C9A24B, #8A6A2F) 1;
  background: linear-gradient(160deg, #9B6BFF, #FF5C5C); /* 画像フォールバック */
  will-change: transform, opacity;
}
.frame img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* 周辺減光 */
.vignette {
  position: absolute; inset: 0; pointer-events: none; z-index: 2;
  background: radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 38%, rgba(0,0,0,.72) 100%);
}
/* 通過の瞬間の白フラッシュ */
.flash {
  position: absolute; inset: 0; pointer-events: none; z-index: 3;
  background: #fff; opacity: 0;
}
JavaScript
// rAFで各額の z を +240px/s。視点通過で最奥へリサイクル。transformは文字列ごと組み直す。
(() => {
  const stage = document.querySelector('[data-tunnel]');
  if (!stage) return; // null安全
  const frames = [...stage.querySelectorAll('.frame')];
  const flash = stage.querySelector('.flash');

  const FALLBACK = [
    'linear-gradient(160deg,#9B6BFF,#FF5C5C)',
    'linear-gradient(160deg,#4D7CFE,#4ED1A1)',
    'linear-gradient(160deg,#FFC83D,#FF5C5C)',
    'linear-gradient(160deg,#4ED1A1,#FFC83D)',
    'linear-gradient(160deg,#9B6BFF,#4D7CFE)'
  ];

  const setImg = (s) => {
    s.el.style.background = FALLBACK[s.n % FALLBACK.length];
    const img = s.el.querySelector('img');
    img.src = `https://picsum.photos/seed/wedelab-frame-${s.n}/360/270`;
    img.onerror = () => { img.style.display = 'none'; }; // 失敗時はグラデを見せる
  };

  // 初期配置:z=-i*400、横ずらし±30px
  const state = frames.map((el, i) => ({ el, z: -i * 400, off: (i % 2 ? -1 : 1) * 30, n: i }));
  state.forEach(setImg);

  const apply = (s) => {
    s.el.style.transform = `translate(-50%,-50%) translateX(${s.off}px) translateZ(${s.z.toFixed(1)}px)`;
    s.el.style.opacity = Math.max(0, Math.min(1, (s.z + 1800) / 600)); // 奥は霧から湧く
  };
  state.forEach(apply);

  let last = 0;
  const loop = (now) => {
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    for (const s of state) {
      s.z += 240 * dt;
      if (s.z > 250) { // 視点通過 → 最奥へ再配置+seed更新+フラッシュ
        s.z -= 2000;
        s.n += 5;
        setImg(s);
        if (flash) {
          flash.style.transition = 'none';
          flash.style.opacity = '0.25';
          requestAnimationFrame(() => { flash.style.transition = 'opacity .2s ease'; flash.style.opacity = '0'; });
        }
      }
      apply(s);
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「額縁ズームトンネル」の効果を追加してください。

# 追加してほしい効果
額縁ズームトンネル(ページ遷移 / View Transitions)
奥の額縁が次々と手前に迫り、最後の一枚の絵の中へ吸い込まれるように進むオープニング演出です。美術館・アーカイブ系サイトの作品世界への導入を、CSS 3Dだけで作ります。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 額縁ズームトンネル:奥の額縁が次々手前に迫り、絵の中へ吸い込まれる導入 -->
<div class="stage" data-tunnel aria-label="額縁が手前に迫るトンネル演出">
  <div class="scene">
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
    <div class="frame"><img alt="" loading="lazy"></div>
  </div>
  <div class="vignette" aria-hidden="true"></div>
  <div class="flash" aria-hidden="true"></div>
</div>

【CSS】
:root{
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout:    cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #0F1117; }

.stage {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 260px;
  max-height: 100%;
  overflow: hidden;
  background: #0F1117;
  perspective: 800px;
}
/* 3Dコンテキスト。translateZ させる額の親に preserve-3d 必須 */
.scene { position: absolute; inset: 0; transform-style: preserve-3d; }

.frame {
  position: absolute;
  left: 50%; top: 50%;
  width: 360px; height: 270px;
  border: 14px solid;
  border-image: linear-gradient(135deg, #C9A24B, #8A6A2F) 1;
  background: linear-gradient(160deg, #9B6BFF, #FF5C5C); /* 画像フォールバック */
  will-change: transform, opacity;
}
.frame img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* 周辺減光 */
.vignette {
  position: absolute; inset: 0; pointer-events: none; z-index: 2;
  background: radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 38%, rgba(0,0,0,.72) 100%);
}
/* 通過の瞬間の白フラッシュ */
.flash {
  position: absolute; inset: 0; pointer-events: none; z-index: 3;
  background: #fff; opacity: 0;
}

【JavaScript】
// rAFで各額の z を +240px/s。視点通過で最奥へリサイクル。transformは文字列ごと組み直す。
(() => {
  const stage = document.querySelector('[data-tunnel]');
  if (!stage) return; // null安全
  const frames = [...stage.querySelectorAll('.frame')];
  const flash = stage.querySelector('.flash');

  const FALLBACK = [
    'linear-gradient(160deg,#9B6BFF,#FF5C5C)',
    'linear-gradient(160deg,#4D7CFE,#4ED1A1)',
    'linear-gradient(160deg,#FFC83D,#FF5C5C)',
    'linear-gradient(160deg,#4ED1A1,#FFC83D)',
    'linear-gradient(160deg,#9B6BFF,#4D7CFE)'
  ];

  const setImg = (s) => {
    s.el.style.background = FALLBACK[s.n % FALLBACK.length];
    const img = s.el.querySelector('img');
    img.src = `https://picsum.photos/seed/wedelab-frame-${s.n}/360/270`;
    img.onerror = () => { img.style.display = 'none'; }; // 失敗時はグラデを見せる
  };

  // 初期配置:z=-i*400、横ずらし±30px
  const state = frames.map((el, i) => ({ el, z: -i * 400, off: (i % 2 ? -1 : 1) * 30, n: i }));
  state.forEach(setImg);

  const apply = (s) => {
    s.el.style.transform = `translate(-50%,-50%) translateX(${s.off}px) translateZ(${s.z.toFixed(1)}px)`;
    s.el.style.opacity = Math.max(0, Math.min(1, (s.z + 1800) / 600)); // 奥は霧から湧く
  };
  state.forEach(apply);

  let last = 0;
  const loop = (now) => {
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    for (const s of state) {
      s.z += 240 * dt;
      if (s.z > 250) { // 視点通過 → 最奥へ再配置+seed更新+フラッシュ
        s.z -= 2000;
        s.n += 5;
        setImg(s);
        if (flash) {
          flash.style.transition = 'none';
          flash.style.opacity = '0.25';
          requestAnimationFrame(() => { flash.style.transition = 'opacity .2s ease'; flash.style.opacity = '0'; });
        }
      }
      apply(s);
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。