横に長いテーブルは、画面下までスクロールしないと横スクロールできず不便なことがあります。
そこで本記事では、テーブルの上側にも横スクロールバーを表示し、さらに上側バーをテーブル枠内で追従(sticky表示)させる方法を紹介します。
標準スクロールバーの見た目は OS / ブラウザに依存して揃えにくいため、今回はカスタムスクロールバーを自作し、テーブルの上下に設置します。
もくじ
完成イメージ
- テーブルの横スクロールバーを上と下の2か所に表示できる
- 上側のバーをテーブル枠内で追従(sticky表示)できる
- 上下のバーを同じデザインで表示できる(カスタムバー)
- ドラッグ/トラッククリックでスクロールできる
- キーボード(左右/PageUp/PageDown)でも操作できる
なぜ「カスタムスクロールバー」が必要なのか
標準のスクロールバーは、CSSで多少装飾できることもありますが、次の理由で「完全に同じ見た目」にすることは難しいです。
- OS(Windows / macOS)で描画が変わる
- ブラウザ(Chrome / Firefox 等)でサポートが違う
- macOS ではオーバーレイ表示になり、そもそも見え方が一定しないことがある
- OS/ブラウザのアップデートで見た目や仕様が変わることがある
上下に同じ見た目のバーを出したい場合は、スクロールバー自体をUIとして自作し、テーブルのscrollLeftと同期させるのが確実です。
実装の全体像
- テーブル本体:横スクロールの実体(
scrollLeftを持つ要素) - 上側のカスタムバー:sticky 追従しつつ、スクロール位置を操作
- 下側のカスタムバー:テーブル直下でも操作できるように配置
- JS:つまみを動かすとテーブルが横に動き、テーブルを横に動かすとつまみも同じ位置に動くように同期します。
ポイントは、ネイティブの横スクロールは残しつつ、見た目だけをカスタムUIで統一することです。
これなら、ホイールやトラックパッドの自然な挙動も保てます。
サンプル
| 見出し1 | 見出し2 | 見出し3 | 見出し4 | 見出し5 | 見出し6 | 見出し7 |
|---|---|---|---|---|---|---|
| データ | データ | データ | データ | データ | データ | データ |
| データ | データ | データ | データ | データ | データ | データ |
| データ | データ | データ | データ | データ | データ | データ |
| データ | データ | データ | データ | データ | データ | データ |
| データ | データ | データ | データ | データ | データ | データ |
コピペで動くサンプルコード
HTML
data-scrollbarを付けたラッパー単位で初期化されるので、同じ構造を複数置いても動きます。
Copyをクリックするとコピーできます。
<div class="table-scroll-wrap" data-scrollbar> <div class="custom-scrollbar custom-scrollbar-top" data-scrollbar-ui="top"> <button type="button" class="custom-scrollbar-btn custom-scrollbar-btn-left" data-btn="left"></button> <div class="custom-scrollbar-track" data-track> <div class="custom-scrollbar-thumb" data-thumb></div> </div> <button type="button" class="custom-scrollbar-btn custom-scrollbar-btn-right" data-btn="right"></button> </div> <div class="table-scroll-area" data-scroll-area> <table class="data-table"> <thead> <tr> <th>見出し1</th><th>見出し2</th><th>見出し3</th><th>見出し4</th><th>見出し5</th><th>見出し6</th><th>見出し7</th> </tr> </thead> <tbody> <tr> <td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td> </tr> <tr> <td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td> </tr> <tr> <td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td> </tr> <tr> <td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td> </tr> <tr> <td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td><td>データ</td> </tr> </tbody> </table> </div> <div class="custom-scrollbar custom-scrollbar-bottom" data-scrollbar-ui="bottom"> <button type="button" class="custom-scrollbar-btn custom-scrollbar-btn-left" data-btn="left"></button> <div class="custom-scrollbar-track" data-track> <div class="custom-scrollbar-thumb" data-thumb></div> </div> <button type="button" class="custom-scrollbar-btn custom-scrollbar-btn-right" data-btn="right"></button> </div></div>CSS
カスタムバーの見た目はCSSで調整しています。
Copyをクリックするとコピーできます。
.table-scroll-wrap { position: relative;}/* 上側スクロールバーを枠内で追従(sticky) */.custom-scrollbar-top { position: sticky; top: 0; z-index: 10; background: #fff;}/* テーブルのスクロール領域 */.table-scroll-area { overflow-x: auto; overflow-y: hidden; /* ネイティブの横スクロールバーは隠す */ scrollbar-width: none; -ms-overflow-style: none;}.table-scroll-area::-webkit-scrollbar { display: none;}/* サンプル用テーブル */.data-table { min-width: 1500px; border-collapse: collapse;}.data-table th,.data-table td { padding: 8px; white-space: nowrap; border: 1px solid #ccc;}/* ---- カスタムスクロールバー ---- */.custom-scrollbar { display: flex; align-items: center; gap: 2px; padding: 4px 0; background: #fcfcfc; user-select: none;}/* 左右ボタン(矢印) */.custom-scrollbar-btn { width: 16px; height: 16px; padding: 0; background: #fcfcfc; border: none; border-radius: 4px; cursor: pointer;}/* 三角マーク */.custom-scrollbar-btn::before { content: ''; display: block; width: 0; height: 0; margin: 0 auto; border-top: 6px solid transparent; border-bottom: 6px solid transparent;}.custom-scrollbar-btn:active::before { border-top-width: 5px; border-bottom-width: 5px;}.custom-scrollbar-btn-left::before { border-right: 5px solid #8b8b8b;}.custom-scrollbar-btn-right::before { border-left: 5px solid #8b8b8b;}.custom-scrollbar-btn-left:active::before { border-right: 4px solid #626262;}.custom-scrollbar-btn-right:active::before { border-left: 4px solid #626262;}/* トラック */.custom-scrollbar-track { position: relative; flex: 1; height: 10px; overflow: hidden; background: #fcfcfc;}/* つまみ */.custom-scrollbar-thumb { position: absolute; left: 0; top: 0; width: 40px; height: 100%; background: #8b8b8b; border-radius: 100vh; cursor: grab;}.custom-scrollbar-thumb:active { cursor: grabbing; background: #626262;}.custom-scrollbar-thumb:hover { background: #626262;}/* スクロール不可時はCSSで無効化(見た目+操作不可) */.is-no-scrollbar [data-scrollbar-ui] { opacity: 0.35;}.is-no-scrollbar [data-scrollbar-ui] [data-track],.is-no-scrollbar [data-scrollbar-ui] [data-thumb],.is-no-scrollbar [data-scrollbar-ui] [data-btn] { pointer-events: none;}.is-no-scrollbar [data-scrollbar-ui] [data-btn] { opacity: 0.5; cursor: default;}JavaScript(同期・ドラッグ・クリック・キーボード操作)
テーブルのscrollLeftを中心に、上/下バーのつまみ位置を同期します。
Copyをクリックするとコピーできます。
(() => { /** * value を min〜max の範囲に収める(はみ出し防止) */ const clamp = (value, min, max) => Math.min(Math.max(value, min), max); /** * 1つの table-scroll-wrap を初期化する */ const initOne = (wrapEl) => { // 実際に横スクロールする領域(scrollLeft を持つ要素) const scrollAreaEl = wrapEl.querySelector('[data-scroll-area]'); // 中の table(横幅計算・ResizeObserverに使う) const tableEl = scrollAreaEl?.querySelector('table'); // 上下のカスタムスクロールバーUI(top/bottom) const uiEls = wrapEl.querySelectorAll('[data-scrollbar-ui]'); // 必要な要素が無ければ何もしない if (!scrollAreaEl || !tableEl || uiEls.length === 0) { return; } /** * 上下のUIを扱いやすい形にまとめる * 1セット:{ track, thumb, leftBtn, rightBtn } */ const uiList = Array.from(uiEls).map((uiEl) => { const trackEl = uiEl.querySelector('[data-track]'); const thumbEl = uiEl.querySelector('[data-thumb]'); const leftBtnEl = uiEl.querySelector('[data-btn="left"]'); const rightBtnEl = uiEl.querySelector('[data-btn="right"]'); // track/thumb がなければ、このUIは無効扱い if (!trackEl || !thumbEl) { return null; } return { trackEl, thumbEl, leftBtnEl, rightBtnEl, }; }).filter(Boolean); // 有効なUIが1つも無ければ終わり if (uiList.length === 0) { return; } // requestAnimationFrame の多重実行を防ぐフラグ let rafId = 0; /** * スクロール中に何度もDOM更新すると重いので、 * 更新を次の描画タイミングにまとめる(間引き) */ const requestUpdate = () => { if (rafId) { return; } rafId = requestAnimationFrame(() => { rafId = 0; updateThumbs(); }); }; /** * 横スクロールが不要なときは wrap にクラスを付与し、 * CSS側でUI無効化(表示/操作)を担保する */ const updateVisibility = () => { const isScrollable = scrollAreaEl.scrollWidth > scrollAreaEl.clientWidth; wrapEl.classList.toggle('is-no-scrollbar', !isScrollable); }; /** * table の scrollLeft に合わせて、つまみ幅/位置を更新する * - つまみ幅:表示領域 / 全体幅 の比率 * - つまみ位置:scrollLeft / 最大scrollLeft の比率 */ const updateThumbs = () => { updateVisibility(); const scrollWidth = scrollAreaEl.scrollWidth; // 全体幅 const clientWidth = scrollAreaEl.clientWidth; // 表示領域幅 const maxScrollLeft = Math.max(0, scrollWidth - clientWidth); const scrollLeft = clamp(scrollAreaEl.scrollLeft, 0, maxScrollLeft); uiList.forEach(({ trackEl, thumbEl }) => { const trackWidth = trackEl.clientWidth; const canScroll = maxScrollLeft > 0 && trackWidth > 0; // スクロール不要/不可能なら、つまみを「消した状態」にする if (!canScroll || scrollWidth <= clientWidth) { thumbEl.style.width = '0px'; thumbEl.style.transform = 'translateX(0px)'; return; } // つまみ幅:比率に応じて計算(最小幅を確保) const ratio = clientWidth / scrollWidth; const minThumb = 24; const thumbWidth = clamp(Math.round(trackWidth * ratio), minThumb, trackWidth); // つまみが動ける最大距離(トラック幅 - つまみ幅) const maxThumbLeft = Math.max(0, trackWidth - thumbWidth); // 現在の scrollLeft を比率換算して thumbLeft に変換 const thumbLeft = maxScrollLeft > 0 ? Math.round((scrollLeft / maxScrollLeft) * maxThumbLeft) : 0; // つまみ幅/位置をDOM反映 thumbEl.style.width = `${thumbWidth}px`; thumbEl.style.transform = `translateX(${thumbLeft}px)`; }); }; /** * 「つまみの左位置(thumbLeft)」から scrollLeft を逆算して反映する * (ドラッグやトラッククリックで呼ばれる) */ const scrollToThumbLeft = (thumbLeft, trackEl, thumbEl) => { const trackWidth = trackEl.clientWidth; // つまみ幅を取得(描画済みなら rect、未描画/0なら style をフォールバック) const rectWidth = thumbEl.getBoundingClientRect().width; const styleWidth = parseFloat(thumbEl.style.width || '0'); const thumbWidth = rectWidth || styleWidth; // つまみ幅が取れない(0)なら計算不能 if (thumbWidth <= 0) { return; } const maxThumbLeft = Math.max(0, trackWidth - thumbWidth); const scrollWidth = scrollAreaEl.scrollWidth; const clientWidth = scrollAreaEl.clientWidth; const maxScrollLeft = Math.max(0, scrollWidth - clientWidth); // 動かせないなら何もしない if (maxThumbLeft <= 0 || maxScrollLeft <= 0) { return; } // つまみ位置を範囲内に収めて、比率換算で scrollLeft に変換 const nextThumbLeft = clamp(thumbLeft, 0, maxThumbLeft); scrollAreaEl.scrollLeft = (nextThumbLeft / maxThumbLeft) * maxScrollLeft; }; /** * スクロールを一定量だけ動かす */ const nudgeScroll = (delta) => { scrollAreaEl.scrollLeft += delta; requestUpdate(); }; /** * ←→ / PageUp / PageDown で横スクロール(wrap がフォーカス中のみ) */ const attachKeyboardScroll = () => { // フォーカスできるようにする(divでもTabで入れる) if (!wrapEl.hasAttribute('tabindex')) { wrapEl.setAttribute('tabindex', '0'); } const step = 40; const pageStep = () => Math.max(80, Math.floor(scrollAreaEl.clientWidth * 0.9)); wrapEl.addEventListener('keydown', (e) => { if (wrapEl.classList.contains('is-no-scrollbar')) { return; } // 入力中のフォーム要素は邪魔しない const target = e.target; const isTypingTarget = target instanceof HTMLElement && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); if (isTypingTarget) { return; } let delta = 0; if (e.key === 'ArrowLeft') { delta = -step; } else if (e.key === 'ArrowRight') { delta = step; } else if (e.key === 'PageUp') { delta = -pageStep(); } else if (e.key === 'PageDown') { delta = pageStep(); } else { return; } e.preventDefault(); nudgeScroll(delta); }); }; /** * 左右ボタン:クリックで1回移動、押しっぱなしで連続移動 * - pointer 長押し(既存) * - Space/Enter 長押し(追加) */ const attachHoldScroll = (btnEl, delta) => { if (!btnEl) { return; } const repeatDelayMs = 250; const repeatIntervalMs = 16; let holdTimerId = 0; let holdIntervalId = 0; // pointerdown のあと click が来るので二重移動防止に使用 let isPointerDown = false; // Space/Enter 長押し用 let isKeyHold = false; const startRepeat = () => { holdTimerId = window.setTimeout(() => { holdIntervalId = window.setInterval(() => { nudgeScroll(delta); }, repeatIntervalMs); }, repeatDelayMs); }; const stopRepeat = () => { window.clearTimeout(holdTimerId); window.clearInterval(holdIntervalId); holdTimerId = 0; holdIntervalId = 0; }; const canOperate = () => !wrapEl.classList.contains('is-no-scrollbar'); // click:キーボードのEnter/Spaceでも発火する btnEl.addEventListener('click', (e) => { e.preventDefault(); if (!canOperate()) { return; } // pointer操作時の click(二重)を無視 if (isPointerDown) { return; } if (isKeyHold) { return; } nudgeScroll(delta); }); // pointerdown:押した瞬間に1回動かし、押しっぱなしなら連続移動へ btnEl.addEventListener('pointerdown', (e) => { e.preventDefault(); if (!canOperate()) { return; } isPointerDown = true; btnEl.setPointerCapture(e.pointerId); nudgeScroll(delta); startRepeat(); }); const stopPointer = () => { isPointerDown = false; stopRepeat(); }; btnEl.addEventListener('pointerup', stopPointer); btnEl.addEventListener('pointercancel', stopPointer); btnEl.addEventListener('lostpointercapture', stopPointer); /** * Space/Enter 長押し:押した瞬間に1回+押し続けで連続移動 * key repeat(e.repeat)で多重起動しないようにする */ btnEl.addEventListener('keydown', (e) => { if (!canOperate()) { return; } const isHoldKey = e.key === ' ' || e.key === 'Enter'; if (!isHoldKey) { return; } e.preventDefault(); // OSのキーリピートでは起動しない if (e.repeat) { return; } isKeyHold = true; nudgeScroll(delta); startRepeat(); }); const stopKeyHold = (e) => { // keyup は Space/Enter のときだけ止める if (e && e.key && e.key !== ' ' && e.key !== 'Enter') { return; } isKeyHold = false; stopRepeat(); }; btnEl.addEventListener('keyup', stopKeyHold); btnEl.addEventListener('blur', () => stopKeyHold()); }; /** * UI(上/下バー)それぞれにイベントを設定 */ uiList.forEach(({ trackEl, thumbEl, leftBtnEl, rightBtnEl }) => { attachHoldScroll(leftBtnEl, -40); attachHoldScroll(rightBtnEl, 40); thumbEl.addEventListener('pointerdown', (e) => { e.preventDefault(); if (wrapEl.classList.contains('is-no-scrollbar')) { return; } const trackRect = trackEl.getBoundingClientRect(); const thumbRect = thumbEl.getBoundingClientRect(); const startX = e.clientX; const startThumbLeft = thumbRect.left - trackRect.left; thumbEl.setPointerCapture(e.pointerId); const onMove = (ev) => { const deltaX = ev.clientX - startX; scrollToThumbLeft(startThumbLeft + deltaX, trackEl, thumbEl); requestUpdate(); }; const onUp = (ev) => { try { thumbEl.releasePointerCapture(ev.pointerId); } catch (err) { // ignore } thumbEl.removeEventListener('pointermove', onMove); thumbEl.removeEventListener('pointerup', onUp); thumbEl.removeEventListener('pointercancel', onUp); }; thumbEl.addEventListener('pointermove', onMove); thumbEl.addEventListener('pointerup', onUp); thumbEl.addEventListener('pointercancel', onUp); }); trackEl.addEventListener('pointerdown', (e) => { if (e.target === thumbEl) { return; } if (wrapEl.classList.contains('is-no-scrollbar')) { return; } const trackRect = trackEl.getBoundingClientRect(); const thumbRect = thumbEl.getBoundingClientRect(); const clickX = e.clientX - trackRect.left; scrollToThumbLeft(clickX - (thumbRect.width / 2), trackEl, thumbEl); requestUpdate(); }); }); scrollAreaEl.addEventListener('scroll', requestUpdate, { passive: true }); const resizeObserver = new ResizeObserver(() => { requestUpdate(); }); resizeObserver.observe(scrollAreaEl); resizeObserver.observe(tableEl); // ←→ / PageUp / PageDown を有効化 attachKeyboardScroll(); requestUpdate(); }; /** * ページ内のすべての data-scrollbar を初期化する */ const initAll = () => { document.querySelectorAll('[data-scrollbar]').forEach((wrapEl) => { initOne(wrapEl); }); }; document.addEventListener('DOMContentLoaded', () => { initAll(); });})();上側バーを追従(sticky表示)させるポイント
上側を追従させるために必要なのは、基本的にこれだけです。
position: sticky; top: 0;を上側バーに付ける- 上に重なるので
z-indexとbackgroundを付ける
.custom-scrollbar-top {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
}
つまずきポイント(stickyが効かない場合)
overflow: hidden/auto/scrollが付いた祖先があると、stickyの基準や挙動が変わる(期待通りに見えない)ことがあります。
もし追従しない場合は、以下をチェックしてください。
table-scroll-wrapの親要素(さらに上の階層も含む)にoverflow: hiddenやoverflow: autoが付いていないか確認してください。- もし付いていると、
position: stickyが効かず、上のバーが固定されないことがあります。 - その場合は、
stickyを付ける要素を別の場所(overflowの影響を受けない要素)に移すか、親要素のoverflow設定を見直してください。
まとめ
横に長いテーブルは、上側にも横スクロールバーを用意しておくと、わざわざ画面下まで移動せずに操作できて快適になります。
また、標準スクロールバーは OS やブラウザによって見た目が変わるため、上下で完全に同じデザインに揃えたい場合はカスタムスクロールバーを自作するのが確実です。
さらに、上側のバーはposition: stickyを使えば、テーブル枠内で追従表示できるので、スクロール中でも常に操作しやすい状態を保てます。
