スクロールで画像が全画面に拡大
スクロール進行で中央の画像が小さなカードから全画面へとなめらかに拡大し、キャプションが浮かび上がるシネマティック演出。プロダクトやポートフォリオの見せ場に。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- FlowDesk プロダクト紹介。スクロールでダッシュボード画面がカード→全画面に拡大 -->
<div class="fde-scroller" id="expScroller">
<!-- 固定ステージ:中央の画面がカード→全画面へ拡大 -->
<div class="exp-stage fde-stage">
<figure class="fde-frame" id="expFrame">
<img class="fde-img" id="expImg"
src="https://picsum.photos/1200/800?random=51"
alt="FlowDesk ダッシュボード画面" loading="lazy">
<!-- UIっぽいオーバーレイ(実在ロゴは使わない) -->
<div class="fde-ui" aria-hidden="true">
<span class="fde-ui-bar"></span>
<span class="fde-ui-chip">進行中 24</span>
<span class="fde-ui-chip">完了 312</span>
</div>
<figcaption class="fde-caption" id="expCaption">
<span class="fde-eyebrow">PRODUCT TOUR</span>
<span class="fde-title">チームの“今”が、ひと目で。</span>
<span class="fde-sub">FlowDesk ダッシュボード</span>
</figcaption>
</figure>
<p class="fde-hint" id="expHint">スクロールで画面が全画面に拡大します</p>
</div>
<!-- スクロール量を稼ぐスペーサー -->
<div class="fde-spacer" aria-hidden="true"></div>
</div>
CSS
:root {
--p: 0; /* 拡大進捗 0〜1。JSが .exp-stage に上書き */
--card-w: 46%; /* カード時の幅 */
--card-h: 52%; /* カード時の高さ */
--radius: 16px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", sans-serif;
color: #fff;
background: #0a1226;
}
/* 自前スクロール領域 */
.fde-scroller {
width: 100%;
height: 100vh;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #4f7cff transparent;
background: radial-gradient(120% 90% at 50% 8%, #16284a 0%, #0a1226 70%);
}
/* 枠に貼り付く固定ステージ(JSが --p を設定) */
.fde-stage {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 画面フレーム:進捗で幅・高さ・角丸を補間 */
.fde-frame {
position: relative;
width: calc(var(--card-w) + (100% - var(--card-w)) * var(--p));
height: calc(var(--card-h) + (100% - var(--card-h)) * var(--p));
border-radius: calc(var(--radius) * (1 - var(--p)));
overflow: hidden;
box-shadow: 0 calc(30px * (1 - var(--p))) calc(60px * (1 - var(--p))) rgba(0,0,0,.6);
border: 1px solid rgba(79,124,255,.25);
will-change: width, height;
}
.fde-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: scale(calc(1 + 0.08 * var(--p)));
filter: saturate(calc(0.85 + 0.25 * var(--p))) brightness(.85);
}
/* ダッシュボードらしいUIオーバーレイ */
.fde-ui {
position: absolute;
top: 14px; left: 14px; right: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.fde-ui-bar {
flex: 1;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #4f7cff 40%, rgba(255,255,255,.25) 40%);
}
.fde-ui-chip {
font-size: .6rem;
letter-spacing: .04em;
padding: 3px 9px;
border-radius: 999px;
background: rgba(15,27,52,.7);
border: 1px solid rgba(79,124,255,.4);
color: #aac4ff;
}
/* キャプション:拡大後半で浮かび上がる */
.fde-caption {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 26px 24px;
display: flex;
flex-direction: column;
gap: 4px;
background: linear-gradient(to top, rgba(10,18,38,.85), transparent);
/* p>0.55 あたりから出現 */
opacity: clamp(0, calc((var(--p) - 0.55) * 4), 1);
transform: translateY(calc(20px * (1 - clamp(0, calc((var(--p) - 0.55) * 4), 1))));
transition: opacity .15s linear;
}
.fde-eyebrow { font-size: .68rem; font-weight: 800; letter-spacing: .3em; color: #8fb0ff; }
.fde-title { font-size: clamp(1.3rem, 5vw, 2rem); font-weight: 800; letter-spacing: .02em; }
.fde-sub { font-size: .8rem; color: #aeb9da; }
/* 開始時のヒント:拡大が進むと消える */
.fde-hint {
position: absolute;
bottom: 18px; left: 0; right: 0;
text-align: center;
font-size: .82rem;
color: rgba(255,255,255,.7);
opacity: calc(1 - var(--p) * 3);
pointer-events: none;
}
/* スクロール量を確保 */
.fde-spacer { height: 240vh; }
@media (prefers-reduced-motion: reduce) {
.fde-caption { transition: none; }
}
JavaScript
// FlowDesk プロダクト紹介:スクロール進捗を CSS変数 --p に渡し、画面を全画面へ拡大
(() => {
const scroller = document.getElementById('expScroller');
const stage = scroller && scroller.querySelector('.exp-stage');
if (!scroller || !stage) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
function render() {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? clamp(scroller.scrollTop / max, 0, 1) : 0; // 進捗 0〜1
stage.style.setProperty('--p', p.toFixed(3));
}
let ticking = false;
scroller.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { render(); ticking = false; });
}, { passive: true });
render(); // 初期状態
// 操作がなくても拡大演出が見えるよう、ゆっくり往復スクロール
let auto = !reduce;
let dir = 1;
const stop = () => { auto = false; };
['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
scroller.addEventListener(ev, stop, { passive: true }));
if (auto) {
setTimeout(function step() {
if (!auto) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
scroller.scrollTop += dir * 1.8;
if (scroller.scrollTop >= max - 1) dir = -1;
else if (scroller.scrollTop <= 1) dir = 1;
requestAnimationFrame(step);
}, 700);
}
})();
コード
HTML
<!-- 自前スクロール領域(プレビュー枠内で完結) -->
<div class="exp-scroller" id="expScroller">
<!-- 固定ステージ:中央の画像がカード→全画面へ拡大 -->
<div class="exp-stage">
<figure class="exp-frame" id="expFrame">
<img class="exp-img" id="expImg"
src="https://picsum.photos/id/1018/1200/800"
alt="山と湖の風景" loading="lazy">
<figcaption class="exp-caption" id="expCaption">
<span class="exp-eyebrow">CINEMATIC</span>
<span class="exp-title">静かなる絶景</span>
</figcaption>
</figure>
<p class="exp-hint" id="expHint">スクロールで画像が全画面に拡大します</p>
</div>
<!-- スクロール量を稼ぐスペーサー -->
<div class="exp-spacer" aria-hidden="true"></div>
</div>
CSS
:root {
--p: 0; /* 拡大進捗 0〜1。JSで上書き */
--card-w: 46%; /* カード時の幅 */
--card-h: 52%; /* カード時の高さ */
--radius: 18px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
color: #fff;
background: #07090f;
}
/* プレビュー枠を埋める自前スクロール領域 */
.exp-scroller {
width: 100%;
height: 100vh;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
background:
radial-gradient(120% 90% at 50% 10%, #1a2030 0%, #07090f 70%);
}
/* 枠に貼り付く固定ステージ */
.exp-stage {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 画像フレーム:進捗で幅・高さ・角丸を補間 */
.exp-frame {
position: relative;
width: calc(var(--card-w) + (100% - var(--card-w)) * var(--p));
height: calc(var(--card-h) + (100% - var(--card-h)) * var(--p));
border-radius: calc(var(--radius) * (1 - var(--p)));
overflow: hidden;
box-shadow: 0 calc(30px * (1 - var(--p))) calc(60px * (1 - var(--p))) rgba(0, 0, 0, .55);
will-change: width, height;
}
.exp-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
/* 拡大しきる手前で少しズームインしてシネマティックに */
transform: scale(calc(1 + 0.08 * var(--p)));
filter: saturate(calc(0.85 + 0.25 * var(--p)));
}
/* キャプション:拡大後半で浮かび上がる */
.exp-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28px 26px;
display: flex;
flex-direction: column;
gap: 4px;
background: linear-gradient(to top, rgba(0, 0, 0, .72), transparent);
/* p>0.55 あたりから出現 */
opacity: clamp(0, calc((var(--p) - 0.55) * 4), 1);
transform: translateY(calc(20px * (1 - clamp(0, calc((var(--p) - 0.55) * 4), 1))));
transition: opacity .15s linear;
}
.exp-eyebrow {
font-size: .72rem;
font-weight: 800;
letter-spacing: .32em;
color: #ffd27a;
}
.exp-title {
font-size: clamp(1.4rem, 5vw, 2.2rem);
font-weight: 800;
letter-spacing: .04em;
}
/* 開始時のヒント:拡大が進むと消える */
.exp-hint {
position: absolute;
bottom: 18px;
left: 0;
right: 0;
text-align: center;
font-size: .82rem;
color: rgba(255, 255, 255, .7);
opacity: calc(1 - var(--p) * 3);
pointer-events: none;
}
/* スクロール量を確保 */
.exp-spacer { height: 240vh; }
@media (prefers-reduced-motion: reduce) {
.exp-caption { transition: none; }
}
JavaScript
// 自前スクロール領域の進捗を CSS 変数 --p に渡し、画像を全画面へ拡大
(() => {
const scroller = document.getElementById('expScroller');
const stage = scroller && scroller.querySelector('.exp-stage');
if (!scroller || !stage) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
function render() {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? clamp(scroller.scrollTop / max, 0, 1) : 0; // 進捗 0〜1
stage.style.setProperty('--p', p.toFixed(3));
}
let ticking = false;
scroller.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { render(); ticking = false; });
}, { passive: true });
render(); // 初期状態
// 操作がなくても拡大演出が見えるよう、ゆっくり往復スクロール
let auto = !reduce;
let dir = 1;
const stop = () => { auto = false; };
['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
scroller.addEventListener(ev, stop, { passive: true }));
if (auto) {
setTimeout(function step() {
if (!auto) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
scroller.scrollTop += dir * 1.8;
if (scroller.scrollTop >= max - 1) dir = -1;
else if (scroller.scrollTop <= 1) dir = 1;
requestAnimationFrame(step);
}, 700);
}
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「スクロールで画像が全画面に拡大」の効果を追加してください。
# 追加してほしい効果
スクロールで画像が全画面に拡大(スクロール演出)
スクロール進行で中央の画像が小さなカードから全画面へとなめらかに拡大し、キャプションが浮かび上がるシネマティック演出。プロダクトやポートフォリオの見せ場に。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- 自前スクロール領域(プレビュー枠内で完結) -->
<div class="exp-scroller" id="expScroller">
<!-- 固定ステージ:中央の画像がカード→全画面へ拡大 -->
<div class="exp-stage">
<figure class="exp-frame" id="expFrame">
<img class="exp-img" id="expImg"
src="https://picsum.photos/id/1018/1200/800"
alt="山と湖の風景" loading="lazy">
<figcaption class="exp-caption" id="expCaption">
<span class="exp-eyebrow">CINEMATIC</span>
<span class="exp-title">静かなる絶景</span>
</figcaption>
</figure>
<p class="exp-hint" id="expHint">スクロールで画像が全画面に拡大します</p>
</div>
<!-- スクロール量を稼ぐスペーサー -->
<div class="exp-spacer" aria-hidden="true"></div>
</div>
【CSS】
:root {
--p: 0; /* 拡大進捗 0〜1。JSで上書き */
--card-w: 46%; /* カード時の幅 */
--card-h: 52%; /* カード時の高さ */
--radius: 18px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
color: #fff;
background: #07090f;
}
/* プレビュー枠を埋める自前スクロール領域 */
.exp-scroller {
width: 100%;
height: 100vh;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
background:
radial-gradient(120% 90% at 50% 10%, #1a2030 0%, #07090f 70%);
}
/* 枠に貼り付く固定ステージ */
.exp-stage {
position: sticky;
top: 0;
height: 100vh;
max-height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 画像フレーム:進捗で幅・高さ・角丸を補間 */
.exp-frame {
position: relative;
width: calc(var(--card-w) + (100% - var(--card-w)) * var(--p));
height: calc(var(--card-h) + (100% - var(--card-h)) * var(--p));
border-radius: calc(var(--radius) * (1 - var(--p)));
overflow: hidden;
box-shadow: 0 calc(30px * (1 - var(--p))) calc(60px * (1 - var(--p))) rgba(0, 0, 0, .55);
will-change: width, height;
}
.exp-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
/* 拡大しきる手前で少しズームインしてシネマティックに */
transform: scale(calc(1 + 0.08 * var(--p)));
filter: saturate(calc(0.85 + 0.25 * var(--p)));
}
/* キャプション:拡大後半で浮かび上がる */
.exp-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28px 26px;
display: flex;
flex-direction: column;
gap: 4px;
background: linear-gradient(to top, rgba(0, 0, 0, .72), transparent);
/* p>0.55 あたりから出現 */
opacity: clamp(0, calc((var(--p) - 0.55) * 4), 1);
transform: translateY(calc(20px * (1 - clamp(0, calc((var(--p) - 0.55) * 4), 1))));
transition: opacity .15s linear;
}
.exp-eyebrow {
font-size: .72rem;
font-weight: 800;
letter-spacing: .32em;
color: #ffd27a;
}
.exp-title {
font-size: clamp(1.4rem, 5vw, 2.2rem);
font-weight: 800;
letter-spacing: .04em;
}
/* 開始時のヒント:拡大が進むと消える */
.exp-hint {
position: absolute;
bottom: 18px;
left: 0;
right: 0;
text-align: center;
font-size: .82rem;
color: rgba(255, 255, 255, .7);
opacity: calc(1 - var(--p) * 3);
pointer-events: none;
}
/* スクロール量を確保 */
.exp-spacer { height: 240vh; }
@media (prefers-reduced-motion: reduce) {
.exp-caption { transition: none; }
}
【JavaScript】
// 自前スクロール領域の進捗を CSS 変数 --p に渡し、画像を全画面へ拡大
(() => {
const scroller = document.getElementById('expScroller');
const stage = scroller && scroller.querySelector('.exp-stage');
if (!scroller || !stage) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
function render() {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? clamp(scroller.scrollTop / max, 0, 1) : 0; // 進捗 0〜1
stage.style.setProperty('--p', p.toFixed(3));
}
let ticking = false;
scroller.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { render(); ticking = false; });
}, { passive: true });
render(); // 初期状態
// 操作がなくても拡大演出が見えるよう、ゆっくり往復スクロール
let auto = !reduce;
let dir = 1;
const stop = () => { auto = false; };
['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
scroller.addEventListener(ev, stop, { passive: true }));
if (auto) {
setTimeout(function step() {
if (!auto) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 0) return;
scroller.scrollTop += dir * 1.8;
if (scroller.scrollTop >= max - 1) dir = -1;
else if (scroller.scrollTop <= 1) dir = 1;
requestAnimationFrame(step);
}, 700);
}
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。