額縁ズームトンネル
奥の額縁が次々と手前に迫り、最後の一枚の絵の中へ吸い込まれるように進むオープニング演出です。美術館・アーカイブ系サイトの作品世界への導入を、CSS 3Dだけで作ります。
ライブデモ
使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。