ソフソフポップイン出現
ページをスクロールすると、丸い写真や図形がソフソフっと弾みながら順番に現れます。ポップなブランドサイトのような陽気な雰囲気づくりに効く、スプリング系の出現演出です。
ライブデモ
使用例(お題: カフェ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。