スクロール速度スキュー
rAFでスクロール速度を算出し、速くスクロールするほど要素をskewY/scaleYで歪ませます。手を止めると線形補間でなめらかに元へ戻る慣性演出。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- FlowDesk リリースノート。素早くスクロールすると各更新カードが歪む -->
<div class="fdv-scroller" id="vskScroller">
<header class="fdv-hero">
<span class="fdv-kicker">CHANGELOG</span>
<h1>FlowDesk 更新履歴</h1>
<p class="fdv-lead">最新リリースのハイライト。<br>勢いよくスクロールしてみてください。</p>
<span class="fdv-arrow" aria-hidden="true">↓</span>
</header>
<!-- これらの更新カードが速度に応じて歪む -->
<section class="vsk-list">
<article class="fdv-card" style="--h:#4f7cff">
<span class="fdv-ver">v3.8</span>
<span class="fdv-date">2026.06.01</span>
<span class="fdv-tag fdv-new">新機能</span>
<h2>AIタスク要約</h2>
<p>長い案件スレッドをワンクリックで要約。要点と次のアクションを自動抽出します。</p>
</article>
<article class="fdv-card" style="--h:#22d3ee">
<span class="fdv-ver">v3.7</span>
<span class="fdv-date">2026.05.18</span>
<span class="fdv-tag fdv-imp">改善</span>
<h2>ダッシュボード高速化</h2>
<p>初回表示を最大40%高速化。大量カードでもスクロールがなめらかになりました。</p>
</article>
<article class="fdv-card" style="--h:#a78bfa">
<span class="fdv-ver">v3.6</span>
<span class="fdv-date">2026.05.02</span>
<span class="fdv-tag fdv-new">新機能</span>
<h2>カレンダー連携</h2>
<p>外部カレンダーと双方向同期。期限の変更が自動で反映されます。</p>
</article>
<article class="fdv-card" style="--h:#34d399">
<span class="fdv-ver">v3.5</span>
<span class="fdv-date">2026.04.20</span>
<span class="fdv-tag fdv-fix">修正</span>
<h2>通知の重複を解消</h2>
<p>特定条件で通知が二重に届く不具合を修正しました。</p>
</article>
<article class="fdv-card" style="--h:#f472b6">
<span class="fdv-ver">v3.4</span>
<span class="fdv-date">2026.04.06</span>
<span class="fdv-tag fdv-imp">改善</span>
<h2>権限設定UIを刷新</h2>
<p>ロールごとの権限がツリー表示に。設定ミスが起きにくくなりました。</p>
</article>
</section>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--navy: #0f1b34;
--blue: #4f7cff;
--white: #ffffff;
}
body {
font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", sans-serif;
background: var(--navy);
color: var(--white);
-webkit-font-smoothing: antialiased;
}
/* 内部スクロール領域。ここで速度を測る */
.fdv-scroller {
width: 100%;
height: 100vh;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--blue) transparent;
}
.fdv-hero {
height: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
padding: 0 20px;
background:
radial-gradient(circle at 20% 20%, rgba(79,124,255,.4), transparent 55%),
radial-gradient(circle at 85% 85%, rgba(79,124,255,.22), transparent 55%),
var(--navy);
}
.fdv-kicker { letter-spacing: .3em; font-size: .64rem; color: #9db5ff; }
.fdv-hero h1 { font-size: 1.9rem; font-weight: 800; line-height: 1.25; }
.fdv-lead { font-size: .82rem; line-height: 1.65; color: #aeb9da; }
.fdv-arrow { font-size: 1.4rem; color: var(--blue); animation: fdvBob 1.6s ease-in-out infinite; }
@keyframes fdvBob {
0%, 100% { transform: translateY(0); opacity: .6; }
50% { transform: translateY(8px); opacity: 1; }
}
/* JSが書き込む歪み量の受け皿。デフォは無歪み */
.vsk-list {
max-width: 540px;
margin: 0 auto;
padding: 30px 24px 110px;
display: grid;
gap: 18px;
--skew: 0deg;
--stretch: 1;
}
.fdv-card {
position: relative;
padding: 20px 20px 20px 22px;
border-radius: 16px;
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.09);
border-left: 4px solid var(--h, var(--blue));
box-shadow: 0 16px 38px -26px rgba(0,0,0,.9);
/* 速度に応じた歪み。原点を上にして伸び感を出す */
transform-origin: center top;
transform: skewY(var(--skew)) scaleY(var(--stretch));
will-change: transform;
}
.fdv-ver {
font-weight: 800;
font-size: 1rem;
color: var(--h, var(--blue));
margin-right: 8px;
}
.fdv-date { font-size: .72rem; color: #8595c0; }
.fdv-tag {
float: right;
font-size: .62rem;
letter-spacing: .06em;
padding: 3px 9px;
border-radius: 999px;
}
.fdv-new { background: rgba(79,124,255,.2); color: #aac4ff; }
.fdv-imp { background: rgba(52,211,153,.18); color: #8df0c4; }
.fdv-fix { background: rgba(244,114,182,.18); color: #ffb4d6; }
.fdv-card h2 { font-size: 1.05rem; font-weight: 800; margin: 10px 0 6px; }
.fdv-card p { font-size: .86rem; line-height: 1.7; color: #c0c8e4; }
/* 動きを減らす設定では歪みを無効化 */
@media (prefers-reduced-motion: reduce) {
.fdv-card { transform: none !important; }
.fdv-arrow { animation: none; }
}
JavaScript
// FlowDesk リリースノート:スクロール速度を rAF で算出し、skewY / scaleY に反映。止まると戻る。
(() => {
const scroller = document.getElementById('vskScroller');
const list = document.querySelector('.vsk-list');
if (!scroller || !list) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return; // 動きを減らす設定では何もしない
// チューニング用パラメータ
const SKEW_MAX = 7; // 最大スキュー角(度)
const STRETCH_MAX = 0.12; // 最大の縦伸び量
const SKEW_GAIN = 0.06; // 速度→スキューの感度
const EASE = 0.12; // 復帰の補間係数
let lastTop = scroller.scrollTop;
let velocity = 0; // 直近フレームの速度(px/frame)
let skew = 0; // 現在の表示スキュー
let stretch = 1; // 現在の縦スケール
let ticking = false;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function loop() {
// スクロール差分=速度
const top = scroller.scrollTop;
velocity = top - lastTop;
lastTop = top;
// 速度から目標の歪みを決定(符号で傾く向きが変わる)
const targetSkew = clamp(velocity * SKEW_GAIN, -SKEW_MAX, SKEW_MAX);
const targetStretch = 1 + clamp(Math.abs(velocity) * SKEW_GAIN * 0.02, 0, STRETCH_MAX);
// 線形補間でなめらかに追従&復帰
skew += (targetSkew - skew) * EASE;
stretch += (targetStretch - stretch) * EASE;
// 微小値はゼロに丸めて無駄な描画を抑える
if (Math.abs(skew) < 0.01) skew = 0;
list.style.setProperty('--skew', skew.toFixed(3) + 'deg');
list.style.setProperty('--stretch', stretch.toFixed(4));
// ほぼ静止したらループ停止。スクロールで再開
if (Math.abs(velocity) < 0.05 && Math.abs(skew) < 0.02 && Math.abs(stretch - 1) < 0.001) {
ticking = false;
return;
}
requestAnimationFrame(loop);
}
function start() {
if (ticking) return;
ticking = true;
requestAnimationFrame(loop);
}
scroller.addEventListener('scroll', start, { passive: true });
// 操作前でも演出が伝わるよう、最初だけ軽く自動スクロールして見せる
let auto = true;
const stopAuto = () => { auto = false; };
['wheel', 'touchstart', 'pointerdown'].forEach(ev =>
scroller.addEventListener(ev, stopAuto, { passive: true }));
setTimeout(function demo() {
if (!auto) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (scroller.scrollTop >= max - 1) return; // 最下部で停止
scroller.scrollTop += 6; // やや速めに動かして歪みを見せる
requestAnimationFrame(demo);
}, 800);
})();
コード
HTML
<!-- スクロール速度スキュー。内部の縦スクロール領域で速度を測る -->
<div class="vsk-scroller" id="vskScroller">
<header class="vsk-hero">
<p class="vsk-kicker">VELOCITY SKEW</p>
<h1>速くスクロールすると<br>歪む</h1>
<p class="vsk-lead">スクロール速度を rAF で算出し<br>skewY と scaleY に反映。止まると戻る</p>
<span class="vsk-arrow" aria-hidden="true">↓</span>
</header>
<!-- これらのカードが速度に応じて歪む -->
<section class="vsk-list">
<article class="vsk-card" style="--h:#f97316">
<span class="vsk-num">01</span>
<h2>慣性のある質感</h2>
<p>勢いよくスクロールするほど大きく傾き、伸びます。</p>
</article>
<article class="vsk-card" style="--h:#22d3ee">
<span class="vsk-num">02</span>
<h2>rAFで速度算出</h2>
<p>毎フレームのスクロール差分から速度を求めています。</p>
</article>
<article class="vsk-card" style="--h:#a78bfa">
<span class="vsk-num">03</span>
<h2>イージング復帰</h2>
<p>手を止めると線形補間でゆっくり元の形へ戻ります。</p>
</article>
<article class="vsk-card" style="--h:#34d399">
<span class="vsk-num">04</span>
<h2>軽量実装</h2>
<p>transform変更のみ。再レイアウトを起こしません。</p>
</article>
<article class="vsk-card" style="--h:#f472b6">
<span class="vsk-num">05</span>
<h2>体感のリッチさ</h2>
<p>わずかな歪みでもスクロールが生き生きと感じられます。</p>
</article>
</section>
</div>
CSS
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a12;
--ink: #f3f4ff;
--accent: #f97316;
}
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* 内部スクロール領域。ここで速度を測る */
.vsk-scroller {
width: 100%;
height: 100vh;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.vsk-hero {
height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
background:
radial-gradient(circle at 20% 20%, rgba(249,115,22,.4), transparent 55%),
radial-gradient(circle at 85% 80%, rgba(167,139,250,.34), transparent 55%),
var(--bg);
}
.vsk-kicker { letter-spacing: .34em; font-size: .68rem; color: var(--accent); }
.vsk-hero h1 { font-size: 2.1rem; font-weight: 800; line-height: 1.2; }
.vsk-lead { font-size: .86rem; line-height: 1.6; color: #bcc0e0; }
.vsk-arrow { font-size: 1.5rem; animation: vskBob 1.6s ease-in-out infinite; }
@keyframes vskBob {
0%, 100% { transform: translateY(0); opacity: .6; }
50% { transform: translateY(8px); opacity: 1; }
}
.vsk-list {
max-width: 540px;
margin: 0 auto;
padding: 30px 24px 110px;
display: grid;
gap: 20px;
/* JSが書き込む歪み量。デフォは無歪み */
--skew: 0deg;
--stretch: 1;
}
.vsk-card {
position: relative;
padding: 24px 24px 24px 62px;
border-radius: 16px;
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.09);
border-left: 4px solid var(--h, var(--accent));
box-shadow: 0 16px 38px -26px rgba(0,0,0,.9);
/* 速度に応じた歪み。原点を上にして伸び感を出す */
transform-origin: center top;
transform: skewY(var(--skew)) scaleY(var(--stretch));
will-change: transform;
}
.vsk-num {
position: absolute;
left: 22px; top: 24px;
font-weight: 800;
font-size: 1.05rem;
color: var(--h, var(--accent));
}
.vsk-card h2 { font-size: 1.08rem; margin-bottom: 6px; }
.vsk-card p { font-size: .9rem; line-height: 1.6; color: #c5c8e6; }
/* 動きを減らす設定では歪みを無効化 */
@media (prefers-reduced-motion: reduce) {
.vsk-card { transform: none !important; }
.vsk-arrow { animation: none; }
}
JavaScript
// スクロール速度を rAF で算出し、skewY / scaleY に反映。止まると戻る。
(() => {
const scroller = document.getElementById('vskScroller');
const list = document.querySelector('.vsk-list');
if (!scroller || !list) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return; // 動きを減らす設定では何もしない
// チューニング用パラメータ
const SKEW_MAX = 7; // 最大スキュー角(度)
const STRETCH_MAX = 0.12; // 最大の縦伸び量
const SKEW_GAIN = 0.06; // 速度→スキューの感度
const EASE = 0.12; // 復帰の補間係数
let lastTop = scroller.scrollTop;
let velocity = 0; // 直近フレームの速度(px/frame)
let skew = 0; // 現在の表示スキュー
let stretch = 1; // 現在の縦スケール
let ticking = false;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function loop() {
// スクロール差分=速度
const top = scroller.scrollTop;
velocity = top - lastTop;
lastTop = top;
// 速度から目標の歪みを決定(符号で傾く向きが変わる)
const targetSkew = clamp(velocity * SKEW_GAIN, -SKEW_MAX, SKEW_MAX);
const targetStretch = 1 + clamp(Math.abs(velocity) * SKEW_GAIN * 0.02, 0, STRETCH_MAX);
// 線形補間でなめらかに追従&復帰
skew += (targetSkew - skew) * EASE;
stretch += (targetStretch - stretch) * EASE;
// 微小値はゼロに丸めて無駄な描画を抑える
if (Math.abs(skew) < 0.01) skew = 0;
list.style.setProperty('--skew', skew.toFixed(3) + 'deg');
list.style.setProperty('--stretch', stretch.toFixed(4));
// ほぼ静止したらループ停止。スクロールで再開
if (Math.abs(velocity) < 0.05 && Math.abs(skew) < 0.02 && Math.abs(stretch - 1) < 0.001) {
ticking = false;
return;
}
requestAnimationFrame(loop);
}
function start() {
if (ticking) return;
ticking = true;
requestAnimationFrame(loop);
}
scroller.addEventListener('scroll', start, { passive: true });
// 操作前でも演出が伝わるよう、最初だけ軽く自動スクロールして見せる
let auto = true;
const stopAuto = () => { auto = false; };
scroller.addEventListener('wheel', stopAuto, { passive: true });
scroller.addEventListener('touchstart', stopAuto, { passive: true });
scroller.addEventListener('pointerdown', stopAuto);
setTimeout(function demo() {
if (!auto) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (scroller.scrollTop >= max - 1) return; // 最下部で停止
scroller.scrollTop += 6; // やや速めに動かして歪みを見せる
requestAnimationFrame(demo);
}, 800);
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「スクロール速度スキュー」の効果を追加してください。
# 追加してほしい効果
スクロール速度スキュー(スクロール演出)
rAFでスクロール速度を算出し、速くスクロールするほど要素をskewY/scaleYで歪ませます。手を止めると線形補間でなめらかに元へ戻る慣性演出。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- スクロール速度スキュー。内部の縦スクロール領域で速度を測る -->
<div class="vsk-scroller" id="vskScroller">
<header class="vsk-hero">
<p class="vsk-kicker">VELOCITY SKEW</p>
<h1>速くスクロールすると<br>歪む</h1>
<p class="vsk-lead">スクロール速度を rAF で算出し<br>skewY と scaleY に反映。止まると戻る</p>
<span class="vsk-arrow" aria-hidden="true">↓</span>
</header>
<!-- これらのカードが速度に応じて歪む -->
<section class="vsk-list">
<article class="vsk-card" style="--h:#f97316">
<span class="vsk-num">01</span>
<h2>慣性のある質感</h2>
<p>勢いよくスクロールするほど大きく傾き、伸びます。</p>
</article>
<article class="vsk-card" style="--h:#22d3ee">
<span class="vsk-num">02</span>
<h2>rAFで速度算出</h2>
<p>毎フレームのスクロール差分から速度を求めています。</p>
</article>
<article class="vsk-card" style="--h:#a78bfa">
<span class="vsk-num">03</span>
<h2>イージング復帰</h2>
<p>手を止めると線形補間でゆっくり元の形へ戻ります。</p>
</article>
<article class="vsk-card" style="--h:#34d399">
<span class="vsk-num">04</span>
<h2>軽量実装</h2>
<p>transform変更のみ。再レイアウトを起こしません。</p>
</article>
<article class="vsk-card" style="--h:#f472b6">
<span class="vsk-num">05</span>
<h2>体感のリッチさ</h2>
<p>わずかな歪みでもスクロールが生き生きと感じられます。</p>
</article>
</section>
</div>
【CSS】
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a12;
--ink: #f3f4ff;
--accent: #f97316;
}
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
/* 内部スクロール領域。ここで速度を測る */
.vsk-scroller {
width: 100%;
height: 100vh;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
}
.vsk-hero {
height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
background:
radial-gradient(circle at 20% 20%, rgba(249,115,22,.4), transparent 55%),
radial-gradient(circle at 85% 80%, rgba(167,139,250,.34), transparent 55%),
var(--bg);
}
.vsk-kicker { letter-spacing: .34em; font-size: .68rem; color: var(--accent); }
.vsk-hero h1 { font-size: 2.1rem; font-weight: 800; line-height: 1.2; }
.vsk-lead { font-size: .86rem; line-height: 1.6; color: #bcc0e0; }
.vsk-arrow { font-size: 1.5rem; animation: vskBob 1.6s ease-in-out infinite; }
@keyframes vskBob {
0%, 100% { transform: translateY(0); opacity: .6; }
50% { transform: translateY(8px); opacity: 1; }
}
.vsk-list {
max-width: 540px;
margin: 0 auto;
padding: 30px 24px 110px;
display: grid;
gap: 20px;
/* JSが書き込む歪み量。デフォは無歪み */
--skew: 0deg;
--stretch: 1;
}
.vsk-card {
position: relative;
padding: 24px 24px 24px 62px;
border-radius: 16px;
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.09);
border-left: 4px solid var(--h, var(--accent));
box-shadow: 0 16px 38px -26px rgba(0,0,0,.9);
/* 速度に応じた歪み。原点を上にして伸び感を出す */
transform-origin: center top;
transform: skewY(var(--skew)) scaleY(var(--stretch));
will-change: transform;
}
.vsk-num {
position: absolute;
left: 22px; top: 24px;
font-weight: 800;
font-size: 1.05rem;
color: var(--h, var(--accent));
}
.vsk-card h2 { font-size: 1.08rem; margin-bottom: 6px; }
.vsk-card p { font-size: .9rem; line-height: 1.6; color: #c5c8e6; }
/* 動きを減らす設定では歪みを無効化 */
@media (prefers-reduced-motion: reduce) {
.vsk-card { transform: none !important; }
.vsk-arrow { animation: none; }
}
【JavaScript】
// スクロール速度を rAF で算出し、skewY / scaleY に反映。止まると戻る。
(() => {
const scroller = document.getElementById('vskScroller');
const list = document.querySelector('.vsk-list');
if (!scroller || !list) return; // null安全
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return; // 動きを減らす設定では何もしない
// チューニング用パラメータ
const SKEW_MAX = 7; // 最大スキュー角(度)
const STRETCH_MAX = 0.12; // 最大の縦伸び量
const SKEW_GAIN = 0.06; // 速度→スキューの感度
const EASE = 0.12; // 復帰の補間係数
let lastTop = scroller.scrollTop;
let velocity = 0; // 直近フレームの速度(px/frame)
let skew = 0; // 現在の表示スキュー
let stretch = 1; // 現在の縦スケール
let ticking = false;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function loop() {
// スクロール差分=速度
const top = scroller.scrollTop;
velocity = top - lastTop;
lastTop = top;
// 速度から目標の歪みを決定(符号で傾く向きが変わる)
const targetSkew = clamp(velocity * SKEW_GAIN, -SKEW_MAX, SKEW_MAX);
const targetStretch = 1 + clamp(Math.abs(velocity) * SKEW_GAIN * 0.02, 0, STRETCH_MAX);
// 線形補間でなめらかに追従&復帰
skew += (targetSkew - skew) * EASE;
stretch += (targetStretch - stretch) * EASE;
// 微小値はゼロに丸めて無駄な描画を抑える
if (Math.abs(skew) < 0.01) skew = 0;
list.style.setProperty('--skew', skew.toFixed(3) + 'deg');
list.style.setProperty('--stretch', stretch.toFixed(4));
// ほぼ静止したらループ停止。スクロールで再開
if (Math.abs(velocity) < 0.05 && Math.abs(skew) < 0.02 && Math.abs(stretch - 1) < 0.001) {
ticking = false;
return;
}
requestAnimationFrame(loop);
}
function start() {
if (ticking) return;
ticking = true;
requestAnimationFrame(loop);
}
scroller.addEventListener('scroll', start, { passive: true });
// 操作前でも演出が伝わるよう、最初だけ軽く自動スクロールして見せる
let auto = true;
const stopAuto = () => { auto = false; };
scroller.addEventListener('wheel', stopAuto, { passive: true });
scroller.addEventListener('touchstart', stopAuto, { passive: true });
scroller.addEventListener('pointerdown', stopAuto);
setTimeout(function demo() {
if (!auto) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (scroller.scrollTop >= max - 1) return; // 最下部で停止
scroller.scrollTop += 6; // やや速めに動かして歪みを見せる
requestAnimationFrame(demo);
}, 800);
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。