MOTION / 165

Scroll scrub

スクロール量に連動
スクロールした量に合わせて、位置と進捗が前後に変わります。

見本の中を操作して変化を試してください。

ラボで詳しく試す ↗

ひとつ試すなら

どこが変わるかを観察して、似た表現と見比べてみよう。

こんなときに

読む進度や、移動と連動した表現。

仕組みと使い方

枠の中だけをスクロールしてください。scrollイベントによる実装例です。

スクロール連動scrub進捗
実装コードを見る
<!doctype html><html lang="ja"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0;min-height:300px}.sample{width:100%;height:100%;min-height:190px;position:relative;box-sizing:border-box;display:grid;place-items:center;overflow:hidden;padding:18px;background:#f4f3ee;color:#242424;font-family:Arial,Meiryo,sans-serif}.sample *{box-sizing:border-box}.sample button{font:600 11px Arial,Meiryo,sans-serif;border:1px solid #333;border-radius:999px;min-height:36px;padding:8px 14px;background:#fff;color:#222;cursor:pointer;touch-action:manipulation}.sample :focus-visible{outline:2px solid #555;outline-offset:4px}.sample small{font-size:10px;color:#666;line-height:1.5}.sample .adv-wrap{width:min(100%,260px);display:flex;flex-direction:column;align-items:center;gap:10px}.sample .adv-status{min-height:13px;text-align:center;font-size:10px;color:#666}.sample svg{max-width:100%}@media(prefers-reduced-motion:reduce){.sample *{transition:none!important;animation:none!important}}.sample .scrub-port{width:100%;height:150px;overflow:auto;overscroll-behavior:contain;border:1px solid #bbb;border-radius:10px;scrollbar-width:thin}.sample .scrub-scene{position:sticky;top:0;padding:13px;background:#efeee7;z-index:1}.sample .scrub-scene small{display:flex;justify-content:space-between;font:9px monospace}.sample .scrub-lane{height:57px;padding-top:12px}.sample .scrub-ball{width:34px;height:34px;border-radius:50%;background:#222;color:white;display:grid;place-items:center;font-weight:700}.sample .scrub-track{height:3px;background:#d4d3cb;margin-top:8px}.sample .scrub-progress{display:block;width:0;height:100%;background:#333}.sample .scrub-space{height:360px;padding:38px 12px;line-height:4;text-align:center;font-size:11px;color:#777}</style><div class="sample"><div class="adv-wrap"><div class="scrub-port" tabindex="0" aria-label="この枠の中を縦にスクロール"><div class="scrub-scene"><small>SCROLL INSIDE ↓ <span class="scrub-value">0%</span></small><div class="scrub-lane"><div class="scrub-ball">g</div></div><div class="scrub-track"><i class="scrub-progress"></i></div></div><div class="scrub-space">ゆっくり動かしてみよう<br>↓<br>↓<br>↑ 戻すと、動きも戻る</div></div></div></div><script>const lifecycle=function lifecycle(root,options={}){const abort=new AbortController(),animations=new Set(),cleanup=[],pause=[];const os=globalThis.matchMedia?.('(prefers-reduced-motion:reduce)').matches||false;let reduced=options.reducedMotion??os,live=true;return{get reduced(){return reduced},get live(){return live},on(node,event,fn,opts={}){node.addEventListener(event,fn,{...opts,signal:abort.signal})},track(a){animations.add(a);a.finished.then(()=>animations.delete(a),()=>animations.delete(a));return a},stop(){animations.forEach(a=>a.cancel());animations.clear()},cleanup(fn){cleanup.push(fn)},onPause(fn){pause.push(fn)},destroy(){if(!live)return;live=false;abort.abort();this.stop();cleanup.forEach(fn=>fn())},setAuto(value){reduced=!value||os;if(reduced){this.stop();pause.forEach(fn=>fn())}}}};const control=(function scrub(root,options){const c=lifecycle(root,options),port=root.querySelector('.scrub-port'),ball=root.querySelector('.scrub-ball'),lane=root.querySelector('.scrub-lane'),bar=root.querySelector('.scrub-progress'),readout=root.querySelector('.scrub-value');let frame=0;function update(){frame=0;const max=port.scrollHeight-port.clientHeight,p=max>0?port.scrollTop/max:0;bar.style.width=p*100+'%';readout.textContent=Math.round(p*100)+'%';ball.style.transform=c.reduced?'translateX(0px)':`translateX(${p*Math.max(0,lane.clientWidth-34)}px) rotate(${p*360}deg)`}c.on(port,'scroll',()=>{if(!frame)frame=requestAnimationFrame(update)},{passive:true});c.cleanup(()=>cancelAnimationFrame(frame));c.onPause(update);update();return c})(document,{reducedMotion:matchMedia('(prefers-reduced-motion:reduce)').matches});addEventListener('pagehide',()=>control.destroy(),{once:true});</script></html>