スクロールで画像が全画面に拡大

スクロール進行で中央の画像が小さなカードから全画面へとなめらかに拡大し、キャプションが浮かび上がるシネマティック演出。プロダクトやポートフォリオの見せ場に。

#scroll#image#zoom#cinematic

ライブデモ

使用例(お題: 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。