水平スクロールする UI を React カスタムフックで実現する


こんにちは。Gaji-Labo の村上です。
Gaji-Labo では「手触りのいい UI」を考え、ユーザー体験を向上させるさまざまな工夫を行っています。

商品やサービスの比較表を設置する場面などで、テーブルが画面の幅を超えてしまうことがあります。
このような場合、水平スクロールが必要になることがありますが、スクロールできることに気づいてもらうのは意外と難しい課題です。

スクロールの可否が直感的に伝わらないと、ユーザーは重要な情報を見逃してしまう可能性があり、商品やサービスの魅力が正しく伝わらないリスクが生じます。
本記事では、このような課題を解決するためのアプローチとして、React カスタムフックを活用し、水平スクロールが可能な方向を視覚的にフィードバックする方法をご紹介します。
https://stackblitz.com/~/github.com/sena-m09/react-horizontal-scroll

仕様

  • 右にスクロール可能である時、右側に影が表示される
  • 左にスクロール可能である時、左側に影が表示される
  • 左右ともにスクロール可能である時、右側、左側ともに影が表示される

実装の概要

上記の仕様を実現するために、React カスタムフックを利用してスクロールの可否を管理します。
このフックを使うことで、要素のスクロール状態を追跡し、スクロール可能な方向に応じて視覚的なフィードバック(影)を動的に更新します。

コード例

カスタムフック: useHorizontalScrollState

  • スクロール状態の監視・更新
    • canScrollLeft: 左にスクロール可能か
    • canScrollRight: 右にスクロール可能か
  • スクロールバーの高さ取得
    • スクロールバーの高さを算出し、スタイルの調整に利用
  • リサイズ対応
  • スムーズなパフォーマンス
    • throttle を使用してスクロールイベントの頻度を抑制
import { useEffect, useState, useCallback } from "react";

import { throttle } from "throttle-debounce";

type HorizontalScrollState = {
  canScrollLeft: boolean;
  canScrollRight: boolean;
}

export const useHorizontalScrollState = <
  T extends HTMLElement = HTMLElement
>(): {
  refCallback: (node: T | null) => void;
  state: HorizontalScrollState;
  scrollBarHeight: number;
} => {
  const [element, setElement] = useState<T | null>(null);
  const [scrollBarHeight, setScrollBarHeight] = useState(0);
  const [state, setState] = useState<HorizontalScrollState>({
    canScrollLeft: false,
    canScrollRight: false,
  });

  const updateScrollBarHeight = useCallback(() => {
    if (!element) return;
    setScrollBarHeight(element.offsetHeight - element.clientHeight);
  }, [element]);

  const updateScrollState = useCallback(() => {
    if (!element) return;
    const { scrollLeft, clientWidth, scrollWidth } = element;
    setState({
      canScrollLeft: scrollLeft > 0,
      canScrollRight: scrollLeft + clientWidth < scrollWidth,
    });
  }, [element]);

  const handleScroll = useCallback(
    throttle(100, updateScrollState),
    [updateScrollState]
  );

  useEffect(() => {
    if (!element) return undefined;

    const resizeObserver = new ResizeObserver(() => {
      updateScrollState();
      updateScrollBarHeight();
    });
    resizeObserver.observe(element);

    element.addEventListener("scroll", handleScroll);

    updateScrollBarHeight();
    updateScrollState();

    return () => {
      resizeObserver.disconnect();
      element.removeEventListener("scroll", handleScroll);
    };
  }, [element, updateScrollState, updateScrollBarHeight]);

  return { refCallback: setElement, state, scrollBarHeight };
};

コンポーネントでの使用例

  • コンテナ要素への refCallback を設定してスクロールを監視
  • スクロール可能な方向に応じて classNames を動的に付与
  • スクロールバーの高さをカスタムCSS変数 –scroll-bar-height として適用
import { useHorizontalScrollState } from "./hooks/scroll";
import classNames from "classnames";

import './App.css';


function App() {
  const {
    refCallback: horizontalScrollRefCallback,
    state: horizontalScrollState,
    scrollBarHeight,
  } = useHorizontalScrollState<HTMLDivElement>();

  return (
    <>
      <div className={classNames(
        "wrapper",
        horizontalScrollState.canScrollLeft && "-canScrollLeft",
        horizontalScrollState.canScrollRight && "-canScrollRight",
      )}
      style={{ "--scroll-bar-height": `${scrollBarHeight}px` } as React.CSSProperties}
       >
        <div className="container" ref={horizontalScrollRefCallback}>
          <table className="table">
            <tbody>
              <tr className="row">
                <th className="box">項目</th>
                <td className="box">テキスト</td>
                <td className="box">テキストテキスト</td>
                <td className="box">テキスト</td>
              </tr>
              <tr className="row">
                <th className="box">項目</th>
                <td className="box">テキスト</td>
                <td className="box">テキストテキスト</td>
                <td className="box">テキスト</td>
              </tr>
              <tr className="row">
                <th className="box">項目</th>
                <td className="box">テキスト</td>
                <td className="box">テキストテキスト</td>
                <td className="box">テキスト</td>
              </tr>
              <tr className="row">
                <th className="box">項目</th>
                <td className="box">テキスト</td>
                <td className="box">テキストテキスト</td>
                <td className="box">テキスト</td>
              </tr>
              <tr className="row">
                <th className="box">項目</th>
                <td className="box">テキスト</td>
                <td className="box">テキストテキスト</td>
                <td className="box">テキスト</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </>
  );
}

export default App;

スタイル例:App.css

  • スクロール可能な方向に応じて、影を box-shadow で動的に描画
  • スクロールバーの高さを CSS 変数に基づいて計算し、環境に応じて影の位置を調整
#root {
  width: 1280px;
  max-width: 100%;
  margin: 0 auto;
}

.wrapper {
  position: relative;
}

.wrapper::after {
  content: '';
  position: absolute;
  inset: 0 0 var(--scroll-bar-height);
  z-index: 100;
  pointer-events: none;
}

.wrapper.-canScrollRight::after {
  box-shadow: -4px 0px 5px 0px rgba(0, 0, 0, 0.3) inset;
}

.wrapper.-canScrollLeft::after {
  box-shadow: 4px 0px 5px 0px rgba(0, 0, 0, 0.3) inset;
}

.wrapper.-canScrollRight.-canScrollLeft::after {
  box-shadow: 4px 0px 5px 0px rgba(0, 0, 0, 0.3) inset,
    -9px 0px 5px -5px rgba(0, 0, 0, 0.3) inset;
}

.container {
  overflow-x: scroll;
}

.table {
  border-collapse: collapse;
  border-spacing: 0;
}

.box {
  position: relative;
  min-width: 600px;
  border: 2px solid #9f9f9f;
}

最後に

水平スクロール可能なUIでは、適切な視覚的フィードバックを提供することで、ユーザーが迷うことなく操作できるようになります。
今回ご紹介した useHorizontalScrollState フックのように、ユーザーが直感的に動作を予測できる UI を実装することで、「手触りのいいUI」を提供し、ユーザー体験をより良いものにする方法を模索していきたいです。

Gaji-Labo フロントエンドエンジニア向けご案内資料

Gaji-Labo は Next.js, React, TypeScript 開発の実績と知見があります

フロントエンド開発の専門家である私たちが御社の開発チームに入ることで、バックエンドも含めた全体の開発効率が上がります。

「既存のサイトを Next.js に移行したい」
「人手が足りず信頼できるエンジニアを探している」
「自分たちで手を付けてみたがいまいち上手くいかない」

フロントエンド開発に関わるお困りごとがあれば、まずは一度お気軽に Gaji-Labo にご相談ください。

オンラインでのヒアリングとフルリモートでのプロセス支援にも対応しています。

Next.js, React, TypeScript の相談をする!

投稿者 Sena Murakami

アシスタントフロントエンドエンジニア。 Webマーケティング会社でマークアップエンジニアとして経験を積み、Gaji-Laboに入社。デザインの意図を汲み取ったマークアップが得意です。チームを俯瞰してリードできるエンジニアになることが目標です。趣味はバドミントンやバレーボール、キャンプなど。