もくじ
Webサイトや管理画面で、スクロールのコンテンツを直感的に操作したい場合があります。
マウスやタッチで「掴んでドラッグするだけ」でスクロールできるUIは、ユーザー体験を向上させる便利な機能です。
この記事では、JavaScriptを使ったドラッグスクロールの基本から、慣性スクロールやキーボード対応まで、初心者でも理解できる手順で解説します。
ドラッグスクロールとは?
ドラッグスクロールは、通常のスクロールバーを使わずに、要素をマウスや指で掴んで動かすことでスクロールする仕組みです。
メリットとしては以下があります。
- 視覚的に直感的で操作しやすい
- 横スクロール・縦スクロール両方に対応可能
- スクロールバーを非表示にできる
- カルーセルや画像ギャラリーに応用可能
JavaScriptでドラッグスクロールを作る方法
ドラッグスクロールの実装には、以下の3つのポイントがあります。
- マウスやタッチの動きを取得する
- スクロール位置を動かす
- 必要に応じて慣性スクロールやキーボード対応を追加する
今回は、最新のPointer Eventsを使った方法でマウス・タッチ両対応にします。
Pointer Eventsを使ったマウス・タッチ両対応
pointerdown/pointermove/pointerupは、マウス、タッチ、ペン操作を統一的に扱えるイベントです。
古いmousedown/touchstartを分ける必要がなく、コードがすっきりします。
.draggable-scrollが操作対象コンテナ.scroll-track内にスクロール可能なコンテンツを並べます(横・縦両方向に対応)tabindex="0"でキーボードアクセス可能に
【サンプル】JavaScriptでドラッグスクロール
横スクロールのサンプル
左右にドラッグして横スクロールできます。
縦スクロールのサンプル
上下にドラッグして縦スクロールできます。
【実装コード】ドラッグスクロールを実装する
HTMLの基本構造
横スクロールと縦スクロールの両方に対応できます。クラス名を変更するだけで切り替え可能です。
Copyをクリックするとコピーできます。
横スクロール用
<div class="draggable-scroll" role="region" aria-label="ドラッグで横スクロール" tabindex="0"> <div class="scroll-track scroll-track-horizontal"> <div class="card">Item 1</div> <div class="card">Item 2</div> <div class="card">Item 3</div> <div class="card">Item 4</div> <div class="card">Item 5</div> <div class="card">Item 6</div> <div class="card">Item 7</div> <div class="card">Item 8</div> </div></div>縦スクロール用
<div class="draggable-scroll" role="region" aria-label="ドラッグで縦スクロール" tabindex="0"> <div class="scroll-track scroll-track-vertical"> <div class="card">Item 1</div> <div class="card">Item 2</div> <div class="card">Item 3</div> <div class="card">Item 4</div> <div class="card">Item 5</div> <div class="card">Item 6</div> <div class="card">Item 7</div> <div class="card">Item 8</div> <div class="card">Item 9</div> <div class="card">Item 10</div> </div></div>CSSでデザインを整える
Copyをクリックするとコピーできます。
.draggable-scroll { position: relative; box-sizing: border-box; width: 100%; max-width: 900px; margin: 0 auto; padding: 12px; overflow: hidden; background: #fff; border: 1px solid #ddd; border-radius: 8px; cursor: grab; user-select: none; -webkit-overflow-scrolling: touch;}.scroll-track { display: flex; gap: 12px; padding-right: 6px; padding-bottom: 6px; scroll-behavior: auto; transform: translateZ(0); will-change: scroll-position;}@media (hover: hover) and (pointer: fine) { .scroll-track { overflow: auto; } .scroll-track::-webkit-scrollbar { width: 8px; height: 8px; } .scroll-track::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); border-radius: 100vh; }}/* タッチデバイスのみ非表示 */@media (hover: none) and (pointer: coarse) { .scroll-track { overflow: hidden; scrollbar-width: none; /* Firefox */ } .scroll-track::-webkit-scrollbar { display: none; /* Chrome, Safari, iOS Safari */ }}.scroll-track-horizontal { flex-direction: row; overflow-x: auto; overflow-y: hidden; transform: translateZ(0); will-change: scroll-position;}.scroll-track-vertical { flex-direction: column; max-height: 400px; overflow-x: hidden; overflow-y: auto; transform: translateZ(0); will-change: scroll-position;}.card { display: flex; flex-shrink: 0; justify-content: center; align-items: center; min-width: 220px; min-height: 140px; height: 140px; font-weight: 600; background: linear-gradient(180deg, #f8f9fb, #fff); border: 1px solid #eee; border-radius: 6px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);}JavaScriptでドラッグ操作を実装
Copyをクリックするとコピーできます。
document.addEventListener('DOMContentLoaded', function() { const containers = document.querySelectorAll('.draggable-scroll'); containers.forEach(function(container) { const track = container.querySelector('.scroll-track'); const isVertical = track.classList.contains('scroll-track-vertical'); const isHorizontal = track.classList.contains('scroll-track-horizontal'); let isDown = false; let startX = 0; let startY = 0; let startScrollLeft = 0; let startScrollTop = 0; let velocityX = 0; let velocityY = 0; let lastPointerX = 0; let lastPointerY = 0; let lastTimestamp = 0; let momentumFrameId = null; let moveX = 0; let moveY = 0; let isMovingFrame = false; const isCoarse = window.matchMedia('(hover: none) and (pointer: coarse)').matches; /* --- 慣性スクロール処理 --- */ const momentumScroll = function() { // 速度が十分小さければ終了 if (Math.abs(velocityX) < 0.01 && Math.abs(velocityY) < 0.01) { momentumFrameId = null; return; } // 慣性によるスクロール if (!isVertical) { track.scrollLeft -= velocityX * 20; } if (!isHorizontal) { track.scrollTop -= velocityY * 20; } // 端に到達したら停止 const atLeft = track.scrollLeft <= 0; const atRight = track.scrollLeft + track.clientWidth >= track.scrollWidth; const atTop = track.scrollTop <= 0; const atBottom = track.scrollTop + track.clientHeight >= track.scrollHeight; if ((atLeft && velocityX > 0) || (atRight && velocityX < 0)) { velocityX = 0; } if ((atTop && velocityY > 0) || (atBottom && velocityY < 0)) { velocityY = 0; } // 減衰 velocityX *= 0.95; velocityY *= 0.95; momentumFrameId = requestAnimationFrame(momentumScroll); }; /* --- Pointer Down --- */ const onPointerDown = function(e) { isDown = true; container.style.cursor = 'grabbing'; const ptX = e.clientX || e.touches?.[0]?.clientX || 0; const ptY = e.clientY || e.touches?.[0]?.clientY || 0; startX = ptX; startY = ptY; startScrollLeft = track.scrollLeft; startScrollTop = track.scrollTop; lastPointerX = ptX; lastPointerY = ptY; lastTimestamp = e.timeStamp || Date.now(); velocityX = 0; velocityY = 0; // 慣性中なら止める if (momentumFrameId !== null) { cancelAnimationFrame(momentumFrameId); momentumFrameId = null; } if (isCoarse) { track.style.overflow = 'hidden'; } }; /* --- Pointer Move --- */ const onPointerMove = function(e) { if (!isDown) return; const ptX = e.clientX || e.touches?.[0]?.clientX || 0; const ptY = e.clientY || e.touches?.[0]?.clientY || 0; moveX = ptX - startX; moveY = ptY - startY; // 端判定 const atLeft = track.scrollLeft <= 0; const atRight = track.scrollLeft + track.clientWidth >= track.scrollWidth; const atTop = track.scrollTop <= 0; const atBottom = track.scrollTop + track.clientHeight >= track.scrollHeight; const draggingLeft = moveX > 0; const draggingRight = moveX < 0; const draggingUp = moveY > 0; const draggingDown = moveY < 0; let shouldHandOff = false; if (isHorizontal) { if ((atLeft && draggingLeft) || (atRight && draggingRight)) { shouldHandOff = true; } } if (isVertical) { if ((atTop && draggingUp) || (atBottom && draggingDown)) { shouldHandOff = true; } } if (shouldHandOff) { if (isCoarse) { track.style.overflow = 'auto'; } return; } e.preventDefault(); if (!isMovingFrame) { isMovingFrame = true; requestAnimationFrame(function() { if (!isVertical) { track.scrollLeft = startScrollLeft - moveX; } if (!isHorizontal) { track.scrollTop = startScrollTop - moveY; } isMovingFrame = false; }); } // 速度計算 const dt = e.timeStamp - lastTimestamp; if (dt > 0) { velocityX = isVertical ? 0 : (ptX - lastPointerX) / dt; velocityY = isHorizontal ? 0 : (ptY - lastPointerY) / dt; } lastPointerX = ptX; lastPointerY = ptY; lastTimestamp = e.timeStamp || Date.now(); }; /* --- Pointer Up --- */ const onPointerUp = function() { if (!isDown) return; isDown = false; container.style.cursor = 'grab'; // 慣性開始 if (momentumFrameId === null) { momentumScroll(); } }; /* --- Keyboard --- */ const onKeyDown = function(e) { const step = 60; if (!isVertical) { if (e.key === 'ArrowRight') { track.scrollBy({ left: step }); e.preventDefault(); } if (e.key === 'ArrowLeft') { track.scrollBy({ left: -step }); e.preventDefault(); } if (e.key === 'Home') { track.scrollTo({ left: 0 }); e.preventDefault(); } if (e.key === 'End') { track.scrollTo({ left: track.scrollWidth }); e.preventDefault(); } } if (!isHorizontal) { if (e.key === 'ArrowDown') { track.scrollBy({ top: step }); e.preventDefault(); } if (e.key === 'ArrowUp') { track.scrollBy({ top: -step }); e.preventDefault(); } if (e.key === 'Home') { track.scrollTo({ top: 0 }); e.preventDefault(); } if (e.key === 'End') { track.scrollTo({ top: track.scrollHeight }); e.preventDefault(); } } }; /* --- Events --- */ container.addEventListener('pointerdown', onPointerDown); window.addEventListener('pointermove', onPointerMove, { passive: false }); window.addEventListener('pointerup', onPointerUp); container.addEventListener('touchstart', onPointerDown, { passive: false }); window.addEventListener('touchmove', onPointerMove, { passive: false }); window.addEventListener('touchend', onPointerUp); container.addEventListener('keydown', onKeyDown); });});アクセシビリティとキーボード対応
ドラッグスクロールを実装する際は、アクセシビリティ(操作しやすさ)への配慮も大切です。
マウスやタッチ操作だけでなく、キーボードでも快適に使えるようにしておきましょう。
tabindex="0"を指定して、要素にフォーカスできるようにする- 矢印キーで上下左右にスクロール操作できるようにする
role属性やaria-labelを追加して、スクリーンリーダーでも内容を理解しやすくする
これらを組み合わせることで、すべてのユーザーにとって使いやすいUIを実現できます。
特に企業サイトや公共サービスなどでは、こうしたアクセシビリティ対応が求められることも多いので、早めに意識しておくのがおすすめです。
まとめ
この記事では、JavaScriptでマウス・タッチ両対応のドラッグスクロールを実装する方法を解説しました。
Pointer Eventsを活用すれば、マウスやタッチ操作を統一的に扱えるため、よりシンプルでメンテナンスしやすいコードになります。
本記事のポイント
- Pointer Eventsでマウス・タッチ操作を共通化
scrollLeftやscrollTopを直接操作して自然な動きを実現- 慣性スクロールを加えると滑らかで直感的な操作感に
- キーボード操作やアクセシビリティ対応で、誰にでも使いやすいUIに
このドラッグスクロールの仕組みは、横スクロールのカルーセルや画像ギャラリー、カード型レイアウトなど、さまざまなUIで応用可能です。
ユーザー体験を高めるインタラクションとして、ぜひ取り入れてみてください。
