React + TypeScript: SimpleBarでスクロールバーのスタイルをカスタマイズする

[SimpleBar](http://grsmto.github.io/simplebar/)はスクロールバーをカスタマイズするライブラリです。スクロールバーを独自につくるのではなく、CSSのスタイルを割り当てるので、おかしな挙動は起こらず、ネイティブなスクロールのパフォーマンスが保たれます。あくまで、スクロールバーの見栄えを変えるだけです。

![SimpleBar logo](http://grsmto.github.io/simplebar/static/logo-b9548eb4e7099f8133fd4d039b2dff43.svg)

#### デザインはCSSで定める
SimpleBarは純粋なCSSでスクロールバーのスタイルを定めます。CSSで与えられるスタイルでさえあれば、自由にカスタマイズできるということです。また、macOSとWindowsで同じ見た目になるのも大きな魅力といえます。

#### 軽量なライブラリ
6KBのとても軽いライブラリです。JavaScriptはスクロールの動きそのものには触れません。ネイティブな動きとパフォーマンスが得られます。

#### モダンブラウザをサポート
ChromeとFirefox、Safariなどのモダンブラウザに加え、Internet Explorer 11をサポートします。

ライブラリの概要は[ドキュメント](https://github.com/Grsmto/simplebar/tree/master/packages/simplebar-react#simplebarreact)と[デモページ](https://grsmto.github.io/simplebar/examples/)でお確かめください。別稿の「[JavaScript + SimpleBar: スクロールバーのスタイルをカスタマイズする](https://qiita.com/FumioNonaka/items/40d9b2a196a8a2ee52bd)」でつくったつぎの作例は、標準のJavaScriptコードでSimpleBarのスタイルを割り当てました。「Left Column」にマウスポインタを重ねると、グラデーションのスクロールバーが現れます。今回のお題は、React用の[SimplebarReact](https://github.com/Grsmto/simplebar/tree/master/packages/simplebar-react#simplebarreact)で同じサンプルをつくることです。

<p class="codepen" data-height="360" data-theme-id="light" data-default-tab="html,result" data-user="FumioNonaka" data-slug-hash="ZEWzQwK" style="height: 360px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="JavaScript + SimpleBar: Customizing scrollbar style">
  <span>See the Pen <a href="https://codepen.io/FumioNonaka/pen/ZEWzQwK">
  JavaScript + SimpleBar: Customizing scrollbar style</a> by Fumio Nonaka (<a href="https://codepen.io/FumioNonaka">@FumioNonaka</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>


# ひな形プロジェクトの用意
本稿でつくるのは、つぎのサンプル001の作例です。CodeSandboxのテンプレート「React TypeScript」をひな形にしました。左カラム(「Left column」)のスクロールに、SimpleBarを使っています。

#### サンプル001■React + TypeScript: Customize scrollbar style with SimpleBar - CodeSandbox
https://codesandbox.io/s/react-typescript-customize-scrollbar-style-with-simplebar-pg1pxj

なお、本稿ではCSSについては、SimpleBarを使うための最小限の定めしか触れません。具体的な記述は、サンプル001の`src/styles.css`をご覧ください。

## CodeSandboxを使う
[CodeSandbox](https://codesandbox.io)のTemplatesから「React TypeScript」を開いてください。前掲サンプル001の依存(Dependencies)に加えたのは、つぎの5つです。

**Dependencies**
* bootstrap
* react
* react-dom
* react-scripts
* simplebar-react

## Create React Appを使う
ローカルにReact + TypeScriptのひな型プロジェクトをつくる場合は、[Create React App](https://create-react-app.dev/)を使うとよいでしょう。手順については「[Create React AppでTypeScriptが加わったひな形アプリケーションをつくる](https://www.fumiononaka.com/Business/html5/FN2006001.html#sec01)」をお読みください。[SimplebarReact](https://github.com/Grsmto/simplebar/tree/master/packages/simplebar-react#simplebarreact)は、npmもしくはYarnでインストールします。

```terminal
install simplebar-react --save
```

```terminal
yarn add simplebar-react
```

なお、本稿作例と同じスタイルにしたい場合には、[Bootstrap](https://getbootstrap.jp/)も併せてインストールしてください。


# 基本となるページの組み立て
ページを構成する要素は大きく3つ、ヘッダと左カラム、そしてメインコンテンツです(図001)。また、作例には[Bootstrap 5](https://getbootstrap.jp/)を用いました。ただし、本稿ではCSSの説明は基本的に省き、SimpleBarの扱いに関わる定めに絞って解説することにします。

#### 図001■ヘッダと左カラムにメインコンテンツで組み立てられたページ
![2007001_002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/aa823315-48ee-c9a1-c663-46bb6d09e5e6.png)

以下のコードは、アプリケーション(`src/App.tsx`)に、それぞれヘッダ(`src/components/Header.tsx`)と左カラム(`src/components/LeftColumn.tsx`)およびメインコンテンツ(`src/components/MainContents.tsx`)を静的にレイアウトしたモジュールの中身です。

```react:src/App.tsx
import 'bootstrap/dist/css/bootstrap.min.css';
import { Header } from './components/Header';
import { MainContents } from './components/MainContents';
import { LeftColumn } from './components/LeftColumn';
import './styles.css';

export default function App() {
	return (
		<div className="App">
			<Header />
			<div className="container-fluid d-flex px-0">
				<LeftColumn />
				<MainContents />
			</div>
		</div>
	);
}
```

```react:src/components/Header.tsx
import type { FC } from 'react';

export const Header: FC = () => {
	return (
		<header id="header" className="text-white bg-primary w-100 p-2 d-flex">
			<h1>Header</h1>
		</header>
	);
};
```

```react:src/components/LeftColumn.tsx
import type { FC } from 'react';

const listItems = (count: number) =>
	Array.from(new Array(count), (_, index) => (
		<li key={index}>item {String(index + 1).padStart(2, '0')}</li>
	));
export const LeftColumn: FC = () => {
	return (
		<div id="left-column" className="bg-light p-2">
			<h3>Left column</h3>
			<ul id="list" className="pl-4">
				{listItems(50)}
			</ul>
		</div>
	);
};
```

```react:src/components/MainContents.tsx
import type { FC } from 'react';

export const MainContents: FC = () => {
	return (
		<main className="px-4 py-2">
			<h2>Main contents</h2>
			<p>
				{/* ...[中略]... */}
			</p>
		</main>
	);
};
```


# ヘッダを上部に固定する
まずは、ヘッダをページ上部に固定するCSSの設定です(`src/styles.css`)。位置は`position`プロパティに`fixed`を与えて固定します。具体的な置き場所は最上部なので`top: 0`です。すると、`<body>`要素の領域に含まれなくなるので、そのままではページの上部がかぶって隠れてしまいます(図003)。

```css:src/styles.css
#header {
	position: fixed;
	top: 0;
}
```

#### 図002■ページ上部をヘッダが覆ってしまう
![2007001_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/3b5c75d1-aab2-8d52-a5ca-98ae78e50c3b.png)

本文の要素の`padding`または`margin`は、ヘッダの高さ分下げなければならないのです。もっとも、高さはウィンドウ幅やレスポンシブの設定によって変わるかもしれません。動的に定めるべきでしょう。

要素の高さは[`element.clientHeight`](https://developer.mozilla.org/docs/Web/API/Element/clientHeight)で得られます。要素参照のためのrefオブジェクトをつくるフックが[`useRef`](https://ja.reactjs.org/docs/hooks-reference.html#useref)です。ロジックは新たに定めるカスタムフックのモジュール`src/hooks/useLayout.ts`に切り出しましょう。フックの戻り値オブジェクトに含むのは、ヘッダの高さ(`headerHeight`)とrefオブジェクト(`headerRef`)です。

```react:src/hooks/useLayout.ts
import { useCallback, useEffect, useRef, useState } from 'react';

export const useLayout = () => {
	const [headerHeight, setHeaderHeight] = useState(0);
	const headerRef = useRef<HTMLElement>(null);
	const setLayout = useCallback(() => {
		const header = headerRef.current;
		if (header) {
			setHeaderHeight(header.clientHeight);
		}
	}, []);
	useEffect(() => {
		setLayout();
		window.addEventListener('resize', setLayout);
		return () => window.removeEventListener('resize', setLayout);
	}, []);
	return {
		headerHeight,
		headerRef
	};
};
```

refオブジェクトの参照(`headerRef`)は、モジュール`src/App.tsx`からプロパティとしてヘッダのコンポーネント(`Header`)に渡します。ただし、`element.clientHeight`は、読み取り専用プロパティであることにご注意ください。高さの設定に用いるのは、要素(`<div>`)の`style`属性に与えた`paddingTop`プロパティです。

```react:src/App.tsx
import { useLayout } from './hooks/useLayout';

export default function App() {
	const { headerHeight, headerRef } = useLayout();
	return (
		// <div className="App">
		<div className="App" style={{ paddingTop: headerHeight }}>
			{/* <Header /> */}
			<Header headerRef={headerRef} />

		</div>
	);
}
```

`Header`コンポーネントいついては、引数に受け取ったrefオブジェクト(`headerRef`)を最上位要素(`<header>`)の`ref`属性に加えてください。これでカスタムフック(`useLayout`)は、ヘッダの高さが動的に得られるのです。

```react:src/components/Header.tsx
// import type { FC } from 'react';
import type { FC, RefObject } from 'react';

type Props = {
	headerRef: RefObject<HTMLElement>;
};
// export const Header: FC = () => {
export const Header: FC<Props> = ({ headerRef }) => {
	return (
		// <header id="header" className="text-white bg-primary w-100 p-2 d-flex">
		<header
			id="header"
			ref={headerRef}
			className="text-white bg-primary w-100 p-2 d-flex"
		>

		</header>
	);
};
```


# SimpleBarを組み込む
SimpleBarを組み込む要素(`id`属性`left-column`)には、`overflow`プロパティに`auto`を与えてください(`src/styles.css`)。そのうえで、SimpleBarを設定する要素は`<SimpleBar>`に置き替えます(`src/components/LeftColumn.tsx`)。そして、スクロールバーを表示するには、要素に高さ(`height`)を定めなければなりません。渡されるのは、このあとカスタムフック(`useLayout`)が求める左カラムの高さ(`leftColumnHeight`)です。

```css:src/styles.css
#left-column {
	overflow: auto;

}
```

```react:src/components/LeftColumn.tsx
import SimpleBar from 'simplebar-react';

type Props = {
	leftColumnHeight: number;
};

// export const LeftColumn: FC = () => {
export const LeftColumn: FC<Props> = ({ leftColumnHeight }) => {
	return (
		// <div id="left-column" className="bg-light p-2">
		<SimpleBar
			id="left-column"
			className="bg-light p-2"
			style={{
				height: leftColumnHeight
			}}
		>

			{/* </div> */}
		</SimpleBar>
	);
};
```

カスタムフック(`useLayout`)は、すでにヘッダの高さは知っています。すると、ブラウザウィンドウのビューポートの高さ([`window.innerHeight`](https://developer.mozilla.org/docs/Web/API/Window/innerHeight))から差し引けば、左カラムの高さ(`leftColumnHeight`)は求まるのです。なお、ルートモジュール(`src/App.tsx`)でSimpleBarのCSS(`simplebar-react/dist/simplebar.min.css`)を`import`することは忘れないでください。

```react:src/hooks/useLayout.ts
export const useLayout = () => {

	const [leftColumnHeight, setLeftColumnHeight] = useState(0);

	const setLayout = useCallback(() => {

		if (header) {

			setLeftColumnHeight(window.innerHeight - header.clientHeight);
		}
	}, []);

	return {

		leftColumnHeight,
	};
};
```

```react:src/App.tsx
import 'simplebar-react/dist/simplebar.min.css';

export default function App() {
	// const { headerHeight, headerRef } = useLayout();
	const { headerHeight, headerRef, leftColumnHeight } = useLayout();
	return (
		<div className="App" style={{ paddingTop: headerHeight }}>

			<div className="container-fluid d-flex px-0">
				{/* <LeftColumn /> */}
				<LeftColumn leftColumnHeight={leftColumnHeight} />

			</div>
		</div>
	);
}
```

これで、ウィンドウに合わせて左カラムの高さが定まり、スクロールバーは自動表示されるようになりました(図003)。

#### 図003■SimpleBarのスクロールバーが自動表示される
![2008002_001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/eeb9dc4f-e277-8542-d924-860c1fe30bb7.png)


# ページ全体をスクロールしたときの不具合を直す
まだ少し、不具合が残っています。ページ全体を下にスクロールしたとき、左カラムがせり上がって、ヘッダにかぶってしまうことです(図004)。

#### 図004■ページ全体を下にスクロールすると左カラムがヘッダにかぶる
![2008002_002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/bec042b8-6ef2-9e78-9d20-7ecbe6d744a1.png)

左カラム(`LeftColumn`)の垂直位置は固定しなければなりません。考え方は、前述「[ヘッダを上部に固定する](https://qiita.com/FumioNonaka/items/bba93024b3106fca0ab8#%E3%83%98%E3%83%83%E3%83%80%E3%82%92%E4%B8%8A%E9%83%A8%E3%81%AB%E5%9B%BA%E5%AE%9A%E3%81%99%E3%82%8B)」と同じです。ただし、ヘッダの高さ(`headerHeight`)はカスタムフック(`useLayout`)により動的に算出されます。したがって、コンポーネントにはプロパティとして渡すのです。

```react:src/App.tsx
export default function App() {

	return (
		<div className="App" style={{ paddingTop: headerHeight }}>

			<div className="container-fluid d-flex px-0">
				{/* <LeftColumn leftColumnHeight={leftColumnHeight} /> */}
				<LeftColumn
					headerHeight={headerHeight}
					leftColumnHeight={leftColumnHeight}
				/>

			</div>
		</div>
	);
}
```

左カラムコンポーネント(`LeftColumn`)の垂直位置は、要素の`style`属性で固定します。`position`は`fixed`、`top`に与えるのがプロパティとして受け取ったヘッダの高さ(`headerHeight`)です。

```react:src/components/LeftColumn.tsx
type Props = {
	headerHeight: number;

};

// export const LeftColumn: FC<Props> = ({ leftColumnHeight }) => {
export const LeftColumn: FC<Props> = ({ headerHeight, leftColumnHeight }) => {
	return (
		<SimpleBar

			style={{

				position: "fixed",
				top: headerHeight
			}}
		>

		</SimpleBar>
	);
};
```

けれど、これだけではまだ足りません。前述「[ヘッダを上部に固定する](https://qiita.com/FumioNonaka/items/bba93024b3106fca0ab8#%E3%83%98%E3%83%83%E3%83%80%E3%82%92%E4%B8%8A%E9%83%A8%E3%81%AB%E5%9B%BA%E5%AE%9A%E3%81%99%E3%82%8B)」と同じように、左カラムがメインコンテンツにかぶってしまうからです(図005)。

#### 図005■メインコンテンツが左カラムに隠れてしまう
![2008002_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/ba3a2413-d927-e35b-390d-ea733862157b.png)

今度は、メインコンテンツの左端位置を左カラムの幅に固定しなければなりません。カスタムフック(`useLayout`)は、幅の状態変数(`leftColumnWidth`)と左カラムに渡すrefオブジェクト(`leftColumnRef`)をつくって戻り値に加えます。

```react:src/hooks/useLayout.ts
export const useLayout = () => {
	const [leftColumnWidth, setLeftColumnWidth] = useState(0);

	const leftColumnRef = useRef<HTMLDivElement>(null);
	const setLayout = useCallback(() => {

		const leftColumn = leftColumnRef.current;

		if (leftColumn) setLeftColumnWidth(leftColumn.clientWidth);
	}, []);

	return {

		leftColumnRef,
		leftColumnWidth
	};
};
```

ルートコンポーネント(`App`)は、カスタムフックから取り出したrefオブジェクト(`leftColumnRef`)を左カラム(`LeftColumn`)に、左カラムの幅(`leftColumnWidth`)はメインコンテンツ(`MainContents`)に渡せばよさそうです。

```react:src/App.tsx
export default function App() {
	const {

		leftColumnRef,
		leftColumnWidth
	} = useLayout();
	return (
		<div className="App" style={{ paddingTop: headerHeight }}>

			<div className="container-fluid d-flex px-0">
				<LeftColumn

					leftColumnRef={leftColumnRef}

				/>
				<MainContents leftColumnWidth={leftColumnWidth} />
			</div>
		</div>
	);
}
```

ところが、ここで問題が生じます。`SimpleBar`コンポーネントは、`clientWidth`プロパティをもちません(後述)。つまり、要素の幅が得られないのです。対応するためには、改めて`<SimpleBar>`コンポーネントを`<div>`要素で包みます。そのうえで、`style`プロパティの`height`の定め以外、属性はすべて親の要素(`<div>`)に移してください。

```react:src/components/LeftColumn.tsx
type Props = {

	leftColumnRef: RefObject<HTMLDivElement>;
};

export const LeftColumn: FC<Props> = ({

	leftColumnRef
}) => {
	return (
		<div
			id="left-column"
			ref={leftColumnRef}
			className="bg-light p-2"
			style={{
				position: 'fixed',
				top: headerHeight
			}}
		>
			<SimpleBar
				/* id="left-column"
				className="bg-light p-2" */
				style={{
					height: leftColumnHeight
					/* position: 'fixed',
					top: headerHeight */
				}}
			>

			</SimpleBar>
		</div>
	);
};
```

### SimpleBarコンポーネントにはclientWidthプロパティがない
`SimpleBar`コンポーネントにrefオブジェクトを与えようとすると、型づけは`RefObject<SimpleBar>`です。

```react:src/hooks/useLayout.ts
// const leftColumnRef = useRef<HTMLDivElement>(null);
const leftColumnRef = useRef<SimpleBar>(null);
```

このrefオブジェクトから`clientWidth`プロパティを参照しようとすると、つぎのような警告が示されて取得できません。

> Property 'clientWidth' does not exist on type 'SimpleBar'.


# CSSでスクロールバーのスタイルを変える
SimpleBarのスクロールバーのスタイルは、CSSにより定められています。つまり、[見栄えがCSSで変えられる](https://github.com/Grsmto/simplebar/tree/master/packages/simplebar#styling)ということです。ここでは、スクロールさせるスライダのカラーを、つぎのCSSでグラデーションにしてみましょう(図006)。書き上がった作例のコードと動きは、冒頭に掲げたCodeSandboxの[サンプル001](https://codesandbox.io/s/react-typescript-customize-scrollbar-style-with-simplebar-pg1pxj)でお確かめください。

```css:src/styles.css
.simplebar-scrollbar::before {
	background: linear-gradient(darkblue, skyblue);
}
```

#### 図006■メインコンテンツの位置が正しく定まってスライダはグラデーションになった
![2008002_004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/128949/49d1676b-f502-8412-8bcb-509ff15cfa89.png)