ソフソフポップイン出現

ページをスクロールすると、丸い写真や図形がソフソフっと弾みながら順番に現れます。ポップなブランドサイトのような陽気な雰囲気づくりに効く、スプリング系の出現演出です。

#pop-in#spring#scroll-reveal#playful

ライブデモ

使用例(お題: カフェ MOON BREW)

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

HTML
<!-- MOON BREW: スクロールでメニュー写真とカフェ小物がソフソフ弾んで登場 -->
<div class="stage" data-stage aria-label="スクロールで弾みながら出現するメニュー">
  <div class="track" data-track>

    <div class="item circ" style="left:8%;  top:40px;   width:120px; height:120px;">
      <img src="https://picsum.photos/seed/moonbrew-pop-1/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:60%; top:90px;  width:96px;  height:96px;" viewBox="0 0 100 100" aria-hidden="true">
      <ellipse cx="50" cy="50" rx="22" ry="30" fill="#4A2C14"/>
      <path d="M50 22 Q40 50 50 78" stroke="#2c1810" stroke-width="4" fill="none"/>
    </svg>

    <div class="item circ" style="left:64%; top:250px;  width:130px; height:130px;">
      <img src="https://picsum.photos/seed/moonbrew-pop-2/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:14%; top:300px; width:100px; height:100px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 14 C66 30 66 58 50 86 C34 58 34 30 50 14 Z" fill="#7BA05B"/>
      <path d="M50 20 V80" stroke="#557a3a" stroke-width="3"/>
    </svg>

    <div class="item circ" style="left:38%; top:470px;  width:110px; height:110px;">
      <img src="https://picsum.photos/seed/moonbrew-pop-3/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:72%; top:520px; width:104px; height:80px;" viewBox="0 0 120 90" aria-hidden="true">
      <path d="M22 36 H92 L86 78 H28 Z" fill="#C97B4A"/>
      <rect x="18" y="30" width="78" height="8" rx="4" fill="#6F4E37"/>
      <path d="M92 44 q18 6 0 22" stroke="#6F4E37" stroke-width="6" fill="none"/>
    </svg>

    <div class="item circ" style="left:10%; top:640px;  width:124px; height:124px;">
      <img src="https://picsum.photos/seed/moonbrew-pop-4/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:48%; top:760px; width:92px;  height:92px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 86 C30 64 30 44 50 30 C70 44 70 64 50 86 Z" fill="#C2455A"/>
    </svg>

    <div class="item circ" style="left:70%; top:820px;  width:116px; height:116px;">
      <img src="https://picsum.photos/seed/moonbrew-pop-5/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:20%; top:980px; width:96px;  height:96px;" viewBox="0 0 100 100" aria-hidden="true">
      <circle cx="50" cy="50" r="36" fill="none" stroke="#6F4E37" stroke-width="10"/>
    </svg>

    <div class="item circ" style="left:54%; top:1080px; width:128px; height:128px;">
      <img src="https://picsum.photos/seed/moonbrew-pop-6/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:16%; top:1240px; width:88px;  height:88px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 18 l9 27 h28 l-23 17 l9 27 l-23 -17 l-23 17 l9 -27 l-23 -17 h28 Z" fill="#E0A95F"/>
    </svg>

  </div>
</div>
CSS
:root{ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #F4ECDD; }

.stage {
  position: relative; width: 100%; height: 100vh;
  min-height: 260px; max-height: 100%; overflow: hidden;
  background: #F4ECDD; transition: opacity .25s ease;
}
.stage.fading { opacity: 0; }

.track { position: absolute; left: 0; top: 0; width: 100%; height: 1400px; will-change: transform; }

.item { position: absolute; transform: scale(0) rotate(-8deg); }
.item.circ {
  border-radius: 50%; border: 4px solid #fff; overflow: hidden;
  box-shadow: 0 8px 20px rgba(111,78,55,.18);
}
.item.circ img { width: 100%; height: 100%; object-fit: cover; display: block; }

.item.pop { animation: pop .55s var(--ease-spring) forwards; }
@keyframes pop {
  0%   { transform: scale(0)    rotate(-8deg); }
  60%  { transform: scale(1.08) rotate(2deg); }
  100% { transform: scale(1)    rotate(0); }
}
JavaScript
// 実scrollを使わず rAF で内部トラックをtranslateY(デモと同じロジック)。
(() => {
  const stage = document.querySelector('[data-stage]');
  const track = document.querySelector('[data-track]');
  if (!stage || !track) return;
  const items = [...track.querySelectorAll('.item')];

  const SPEED = 60;
  let scrollY = 0, last = 0, phase = 'scroll', mark = 0;

  const reset = () => { scrollY = 0; items.forEach(el => el.classList.remove('pop')); };

  const step = (now) => {
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    const winH = stage.clientHeight;
    const max = Math.max(0, track.offsetHeight - winH);

    if (phase === 'scroll') {
      scrollY += SPEED * dt;
      if (scrollY >= max) { scrollY = max; phase = 'hold'; mark = now; }
    } else if (phase === 'hold') {
      if (now - mark > 800) { stage.classList.add('fading'); phase = 'fade'; mark = now; }
    } else if (phase === 'fade') {
      if (now - mark > 250) { reset(); stage.classList.remove('fading'); phase = 'scroll'; }
    }

    track.style.transform = `translateY(${-scrollY}px)`;
    const th = winH * 0.75;
    for (const el of items) { if (el.offsetTop - scrollY < th) el.classList.add('pop'); }
    requestAnimationFrame(step);
  };
  reset();
  requestAnimationFrame(step);
})();

実装ガイド

使いどころ

ポップなブランドや飲食系LPで、要素がスクロールに合わせて陽気に登場する演出に。

実装時の注意点

実scroll/IntersectionObserverは使わず、rAFで内部トラックをtranslateYし、要素の位置で出現判定します(srcdoc内で環境非依存)。出現はscale(0)→1.08→1のスプリングで、位置差が自然なstaggerになるためdelayは付けません。最下部で停止→フェードして先頭へリセットします。

対応ブラウザ

CSS animation・transformは全モダンブラウザで安定動作します。prefers-reduced-motion時は弾みを弱める配慮を入れ、実scrollに繋ぐ場合のIntersectionObserverも全モダンブラウザで使えます。対応は実機で確認してください。

よくある失敗

実scrollイベントやIOをsrcdoc内で使うと環境依存で動かないことがあります。スプリングのオーバーシュートを大きくしすぎると安っぽくなります。一度に全部出ると階段状にならないので、位置を縦に散らします。

応用例

図形をブランド素材に、出現の回転や向きを調整、実ページのscroll-driven animationに置換、商品カードの登場などに発展できます。

コード

HTML
<!-- ソフソフポップイン出現:自動スクロールで要素がスプリングで弾みながら登場 -->
<div class="stage" data-stage aria-label="スクロールで弾みながら出現する要素群">
  <div class="track" data-track>

    <div class="item circ" style="left:8%;  top:40px;   width:120px; height:120px;">
      <img src="https://picsum.photos/seed/wedelab-popin-1/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:60%; top:90px;  width:96px;  height:96px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 4 L61 38 L97 38 L68 60 L79 96 L50 74 L21 96 L32 60 L3 38 L39 38 Z" fill="#FF5C5C"/>
    </svg>

    <div class="item circ" style="left:64%; top:250px;  width:130px; height:130px;">
      <img src="https://picsum.photos/seed/wedelab-popin-2/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:14%; top:300px; width:100px; height:100px;" viewBox="0 0 100 100" aria-hidden="true">
      <g fill="#FFC83D"><circle cx="50" cy="22" r="16"/><circle cx="78" cy="50" r="16"/><circle cx="50" cy="78" r="16"/><circle cx="22" cy="50" r="16"/></g>
      <circle cx="50" cy="50" r="14" fill="#FF9EB3"/>
    </svg>

    <div class="item circ" style="left:38%; top:470px;  width:110px; height:110px;">
      <img src="https://picsum.photos/seed/wedelab-popin-3/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:72%; top:520px; width:110px; height:70px;" viewBox="0 0 120 70" aria-hidden="true">
      <polyline points="6,40 28,12 50,40 72,12 94,40 114,16" fill="none" stroke="#4D7CFE" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
    </svg>

    <div class="item circ" style="left:10%; top:640px;  width:124px; height:124px;">
      <img src="https://picsum.photos/seed/wedelab-popin-4/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:48%; top:760px; width:92px;  height:92px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 8 L92 86 L8 86 Z" fill="#4ED1A1"/>
    </svg>

    <div class="item circ" style="left:70%; top:820px;  width:116px; height:116px;">
      <img src="https://picsum.photos/seed/wedelab-popin-5/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:20%; top:980px; width:96px;  height:96px;" viewBox="0 0 100 100" aria-hidden="true">
      <circle cx="50" cy="50" r="38" fill="none" stroke="#9B6BFF" stroke-width="12"/>
    </svg>

    <div class="item circ" style="left:54%; top:1080px; width:128px; height:128px;">
      <img src="https://picsum.photos/seed/wedelab-popin-6/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:16%; top:1240px; width:88px;  height:88px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M40 8 H60 V40 H92 V60 H60 V92 H40 V60 H8 V40 H40 Z" fill="#FFC83D"/>
    </svg>

  </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: #F7F5F0; }

.stage {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 260px;
  max-height: 100%;
  overflow: hidden;
  background: #F7F5F0;
  transition: opacity .25s ease;
}
.stage.fading { opacity: 0; }

.track {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: 1400px;
  will-change: transform;
}

.item {
  position: absolute;
  transform: scale(0) rotate(-8deg);
}
.item.circ {
  border-radius: 50%;
  border: 4px solid #fff;
  overflow: hidden;
  box-shadow: 0 8px 20px rgba(0,0,0,.12);
}
.item.circ img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* 出現:scale(0)→1.08→1、回転も戻す。スプリングで弾ませる */
.item.pop { animation: pop .55s var(--ease-spring) forwards; }
@keyframes pop {
  0%   { transform: scale(0)    rotate(-8deg); }
  60%  { transform: scale(1.08) rotate(2deg); }
  100% { transform: scale(1)    rotate(0); }
}
JavaScript
// 実scroll/IntersectionObserverは使わず、rAFで内部トラックをtranslateYする(srcdoc内で環境非依存)。
(() => {
  const stage = document.querySelector('[data-stage]');
  const track = document.querySelector('[data-track]');
  if (!stage || !track) return; // null安全
  const items = [...track.querySelectorAll('.item')];

  const SPEED = 60;           // px/s 自動スクロール速度
  let scrollY = 0, last = 0;
  let phase = 'scroll', mark = 0;

  const reset = () => { scrollY = 0; items.forEach(el => el.classList.remove('pop')); };

  const step = (now) => {
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    const winH = stage.clientHeight;
    const max = Math.max(0, track.offsetHeight - winH);

    if (phase === 'scroll') {
      scrollY += SPEED * dt;
      if (scrollY >= max) { scrollY = max; phase = 'hold'; mark = now; }
    } else if (phase === 'hold') {
      if (now - mark > 800) { stage.classList.add('fading'); phase = 'fade'; mark = now; }
    } else if (phase === 'fade') {
      if (now - mark > 250) { reset(); stage.classList.remove('fading'); phase = 'scroll'; }
    }

    track.style.transform = `translateY(${-scrollY}px)`;

    // 要素の上端が窓高の75%より上に来たら出現
    const th = winH * 0.75;
    for (const el of items) {
      if (el.offsetTop - scrollY < th) el.classList.add('pop');
    }
    requestAnimationFrame(step);
  };

  reset();
  requestAnimationFrame(step);
})();

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

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

# 追加してほしい効果
ソフソフポップイン出現(スクロール演出)
ページをスクロールすると、丸い写真や図形がソフソフっと弾みながら順番に現れます。ポップなブランドサイトのような陽気な雰囲気づくりに効く、スプリング系の出現演出です。

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

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ソフソフポップイン出現:自動スクロールで要素がスプリングで弾みながら登場 -->
<div class="stage" data-stage aria-label="スクロールで弾みながら出現する要素群">
  <div class="track" data-track>

    <div class="item circ" style="left:8%;  top:40px;   width:120px; height:120px;">
      <img src="https://picsum.photos/seed/wedelab-popin-1/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:60%; top:90px;  width:96px;  height:96px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 4 L61 38 L97 38 L68 60 L79 96 L50 74 L21 96 L32 60 L3 38 L39 38 Z" fill="#FF5C5C"/>
    </svg>

    <div class="item circ" style="left:64%; top:250px;  width:130px; height:130px;">
      <img src="https://picsum.photos/seed/wedelab-popin-2/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:14%; top:300px; width:100px; height:100px;" viewBox="0 0 100 100" aria-hidden="true">
      <g fill="#FFC83D"><circle cx="50" cy="22" r="16"/><circle cx="78" cy="50" r="16"/><circle cx="50" cy="78" r="16"/><circle cx="22" cy="50" r="16"/></g>
      <circle cx="50" cy="50" r="14" fill="#FF9EB3"/>
    </svg>

    <div class="item circ" style="left:38%; top:470px;  width:110px; height:110px;">
      <img src="https://picsum.photos/seed/wedelab-popin-3/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:72%; top:520px; width:110px; height:70px;" viewBox="0 0 120 70" aria-hidden="true">
      <polyline points="6,40 28,12 50,40 72,12 94,40 114,16" fill="none" stroke="#4D7CFE" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
    </svg>

    <div class="item circ" style="left:10%; top:640px;  width:124px; height:124px;">
      <img src="https://picsum.photos/seed/wedelab-popin-4/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:48%; top:760px; width:92px;  height:92px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M50 8 L92 86 L8 86 Z" fill="#4ED1A1"/>
    </svg>

    <div class="item circ" style="left:70%; top:820px;  width:116px; height:116px;">
      <img src="https://picsum.photos/seed/wedelab-popin-5/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:20%; top:980px; width:96px;  height:96px;" viewBox="0 0 100 100" aria-hidden="true">
      <circle cx="50" cy="50" r="38" fill="none" stroke="#9B6BFF" stroke-width="12"/>
    </svg>

    <div class="item circ" style="left:54%; top:1080px; width:128px; height:128px;">
      <img src="https://picsum.photos/seed/wedelab-popin-6/240/240" alt="" loading="lazy">
    </div>

    <svg class="item shape" style="left:16%; top:1240px; width:88px;  height:88px;" viewBox="0 0 100 100" aria-hidden="true">
      <path d="M40 8 H60 V40 H92 V60 H60 V92 H40 V60 H8 V40 H40 Z" fill="#FFC83D"/>
    </svg>

  </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: #F7F5F0; }

.stage {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 260px;
  max-height: 100%;
  overflow: hidden;
  background: #F7F5F0;
  transition: opacity .25s ease;
}
.stage.fading { opacity: 0; }

.track {
  position: absolute;
  left: 0; top: 0;
  width: 100%; height: 1400px;
  will-change: transform;
}

.item {
  position: absolute;
  transform: scale(0) rotate(-8deg);
}
.item.circ {
  border-radius: 50%;
  border: 4px solid #fff;
  overflow: hidden;
  box-shadow: 0 8px 20px rgba(0,0,0,.12);
}
.item.circ img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* 出現:scale(0)→1.08→1、回転も戻す。スプリングで弾ませる */
.item.pop { animation: pop .55s var(--ease-spring) forwards; }
@keyframes pop {
  0%   { transform: scale(0)    rotate(-8deg); }
  60%  { transform: scale(1.08) rotate(2deg); }
  100% { transform: scale(1)    rotate(0); }
}

【JavaScript】
// 実scroll/IntersectionObserverは使わず、rAFで内部トラックをtranslateYする(srcdoc内で環境非依存)。
(() => {
  const stage = document.querySelector('[data-stage]');
  const track = document.querySelector('[data-track]');
  if (!stage || !track) return; // null安全
  const items = [...track.querySelectorAll('.item')];

  const SPEED = 60;           // px/s 自動スクロール速度
  let scrollY = 0, last = 0;
  let phase = 'scroll', mark = 0;

  const reset = () => { scrollY = 0; items.forEach(el => el.classList.remove('pop')); };

  const step = (now) => {
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    const winH = stage.clientHeight;
    const max = Math.max(0, track.offsetHeight - winH);

    if (phase === 'scroll') {
      scrollY += SPEED * dt;
      if (scrollY >= max) { scrollY = max; phase = 'hold'; mark = now; }
    } else if (phase === 'hold') {
      if (now - mark > 800) { stage.classList.add('fading'); phase = 'fade'; mark = now; }
    } else if (phase === 'fade') {
      if (now - mark > 250) { reset(); stage.classList.remove('fading'); phase = 'scroll'; }
    }

    track.style.transform = `translateY(${-scrollY}px)`;

    // 要素の上端が窓高の75%より上に来たら出現
    const th = winH * 0.75;
    for (const el of items) {
      if (el.offsetTop - scrollY < th) el.classList.add('pop');
    }
    requestAnimationFrame(step);
  };

  reset();
  requestAnimationFrame(step);
})();

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

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