水平スクロールする 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 の相談をする!