【PR】を含みます。

フロントエンド

【JavaScript】テーブルの横スクロールバーを上下に表示する方法

JavaScript テーブルの横スクロールバーを上下に表示する方法

横に長いテーブルは、画面下までスクロールしないと横スクロールできず不便なことがあります。

そこで本記事では、テーブルの上側にも横スクロールバーを表示し、さらに上側バーをテーブル枠内で追従(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をクリックするとコピーできます。

HTML
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をクリックするとコピーできます。

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をクリックするとコピーできます。

JavaScript
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-indexbackgroundを付ける
.custom-scrollbar-top {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #fff;
}

つまずきポイント(stickyが効かない場合)

overflow: hidden/auto/scrollが付いた祖先があると、stickyの基準や挙動が変わる(期待通りに見えない)ことがあります。

もし追従しない場合は、以下をチェックしてください。

  • table-scroll-wrapの親要素(さらに上の階層も含む)にoverflow: hiddenoverflow: autoが付いていないか確認してください。
  • もし付いていると、position: stickyが効かず、上のバーが固定されないことがあります。
  • その場合は、stickyを付ける要素を別の場所(overflowの影響を受けない要素)に移すか、親要素のoverflow設定を見直してください。

まとめ

横に長いテーブルは、上側にも横スクロールバーを用意しておくと、わざわざ画面下まで移動せずに操作できて快適になります。

また、標準スクロールバーは OS やブラウザによって見た目が変わるため、上下で完全に同じデザインに揃えたい場合はカスタムスクロールバーを自作するのが確実です。

さらに、上側のバーはposition: stickyを使えば、テーブル枠内で追従表示できるので、スクロール中でも常に操作しやすい状態を保てます。

-フロントエンド
-