React Hooks + TypeScript + styled-components で "Netflix"のように滑らかなスライダーを作る

## 概要

時は大リモートワーク時代...。毎日何かしらのアニメ・ドラマ・映画を流しながら作業をしているという人も多いのではないでしょうか。

自分も例に漏れずそんな感じの日々を送っており、中でも特にお世話になっているサブスクリプションサービスが「Netflix」です。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/2102c0d7-b2c1-d02c-3f62-c2efe88fbdba.gif)

黒基調のサイトデザインで「クールだなぁ」なんていつも漠然と感じているわけですが、特にスライダー部分はダイナミックな動きで印象的ですよね。

今回はこれに似たスライダーを React Hooks + TypeScript + styled-components で作ってみたいと思います。

## 完成イメージ

![nfs.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/92e2da67-4a65-ce88-396b-763f144654a5.gif)

[https://react-smooth-slider.vercel.app/](https://react-smooth-slider.vercel.app/)

ぬるぬる動く滑らかなスライダーになっています。

## 仕様

- 言語
    - TypeScript
- ライブラリ
    - React
    - styled-components
    - Material-UI
- 画像データ
    - Pixabay API

## 下準備編

具体的なコードを書いていく前に、いくつかやっておかなければならない事があるので先に済ませておきましょう。

### create-react-app

まずは定番のコマンドでアプリの雛型を作成します。

```
$ npx create-react-app react-smooth-slider --template typescript
$ cd react-smooth-slider
```

### tsconfig.json に「baseUrl」を追記

```json:./tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,

    ...省略...

    "baseUrl": "src" ← 追記
  },
  "include": [
    "src"
  ]
}
```

「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。

これをやっておくと、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになります。

**baseUrlを指定しない場合**

```tsx
import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス
```

**baseUrlを指定した場合**

```tsx
import Hoge from "components/Hoge" // baseUrlからの相対パス
```

いちいち「../../」みたいな記述を長々としなくて済むので個人的には非常に楽です。

### 各種ライブラリをインストール

必要なライブラリをインストールします。

```
$ yarn add @material-ui/core styled-components
$ yarn add -D @types/styled-components
```

Material-UIはほとんど使いませんが、後述のローディングアニメーションを作成する際に「CircularProgress」というコンポーネントを拝借したいので一応インストールしてください。

### Pixabay API のキーを取得

スライドに使う画像は自前で用意しても良いのですが、せっかくなので今回は「[Pixabay](https://pixabay.com/ja/)」という画像配信サービスのAPIで取得したいと思います。

ドキュメントが整備されていて非常に使いやすく、配信されている画像はどれもクオリティが高いので良い感じのアプリが作れそうです。

![スクリーンショット 2021-11-22 20.50.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/78a2d750-06ee-ffc7-3a80-9a43d151404d.png)

[https://pixabay.com/ja/service/about/api/](https://pixabay.com/ja/service/about/api/)

まず、トップページにある「Get Started」ボタンをクリック。

![スクリーンショット 2021-11-22 20.52.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/475fd2e7-6d32-2408-c7b2-b5ec2882ec70.png)

すると、APIのドキュメントが記載されたページに飛ぶのですが、リクエストの際に「APIキー」が必要なことが分かります。

これは無料のユーザー登録をすれば簡単に取得できるので、そのまま「Sign up」をクリックしてください。

![pixabay.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/7ec6dfe0-bca5-794a-a8a5-e151fabffbe8.png)

適当に情報を入力してユーザー登録を済ませましょう。

![スクリーンショット 2021-11-23 1.43.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/66961f79-2ee7-5426-1d4f-d9e0b0b910bb.png)

ユーザー登録完了後、先ほどのドキュメントページに再びアクセスすると、自分専用のAPIキーが表示されているはず。後で使う事になるのでメモっておいてください。

## 実装編

諸々の下準備が済んだら、いよいよコードを書いていきましょう。

### 各種コンポーネント

```
$ mkdir -p src/components src/components/styles
$ touch src/components/styles/Slider.ts src/components/styles/SliderItem.ts src/components/styles/index.ts
$ touch src/components/Slider.tsx src/components/SliderItem.tsx
```

```ts:./src/components/styles/Slider.ts
import styled from "styled-components";
import { StyledSliderItem } from "components/styles";

interface StyledSliderWrapperProps {
  zoomFactor: number;
  visibleSlides: number;
}

export const StyledSliderWrapper = styled.div<StyledSliderWrapperProps>`
  ${({ zoomFactor, visibleSlides }) => `
    overflow: hidden;
    position: relative;
    background: #333132;
    padding: ${(zoomFactor / visibleSlides) * 0.7 + "%"} 0;
  `}
`;

interface StyledSliderProps {
  visibleSlides: number;
  pageTransition: number;
  transformValue: string;
  zoomFactor: number;
  slideMargin: number;
  ref: any;
}

export const StyledSlider = styled.div<StyledSliderProps>`
  ${({ pageTransition, transformValue }) => `
    display: flex;
    padding: 0 55px;
    transition: transform ${pageTransition}ms ease;

    :hover ${StyledSliderItem} {
      transform: translateX(${transformValue});
    }
  `}
`;

interface StyledButtonWrapperProps {
  isForward: boolean;
  zoomFactor: number;
}

export const StyledButtonWrapper = styled.div<StyledButtonWrapperProps>`
  ${({ isForward, zoomFactor }) => `
    position: absolute;
    border-radius: ${isForward ? "0.5vw 0 0 0.5vw" : "0 0.5vw 0.5vw 0"};
    box-sizing: border-box;
    top: 0;
    ${isForward ? "right: 0;" : "left: 0;"};
    width: 55px;
    height: 100%;
    padding: ${zoomFactor / 8 + "%"} 0;
  `}
`;

interface StyledButtonProps {
  isForward: boolean;
}

export const StyledButton = styled.button<StyledButtonProps>`
  ${({ isForward }) => `
    display: block;
    background: rgb(0, 0, 0, 0.7);
    border: 0;
    border-radius: ${isForward ? "0.5vw 0 0 0.5vw" : "0 0.5vw 0.5vw 0"};
    top: 0;
    ${isForward ? "right: 0;" : "left: 0;"};
    width: 100%;
    height: 100%;
    color: #fff;
    font-size: 3rem;
    font-weight: 800;
    cursor: pointer;
    outline: none;
    transition: all 0.7s;
    user-select: none;

    :hover {
      opacity: 0.5;
    }
  `}
`;
```

```ts:./src/components/styles/SliderItem.ts
import styled from "styled-components";

interface StyledSliderItemProps {
  zoomFactor: number;
  slideMargin: number;
  visibleSlides: number;
  className: string;
}

export const StyledSliderItem = styled.div<StyledSliderItemProps>`
  ${({ slideMargin, visibleSlides, zoomFactor }) => `
    margin: 0 ${slideMargin}px;
    transition: transform 500ms ease;
    border-radius: 0.5vw;
    cursor: pointer;
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    display: flex;
    transform: scale(1);
    user-select: none;
    flex: 0 0 calc(100% / ${visibleSlides} - ${slideMargin * 2}px);

    img {
      height: 100%;
      width: 100%;
      border-radius: 0.5vw;
      box-sizing: border-box;
      -webkit-user-drag: none;
    }

    :hover {
      transform: scale(${zoomFactor / 100 + 1}) !important;
    }

    :hover ~ * {
      transform: translateX(${zoomFactor / 2 + "%"}) !important;
    }

    &.left {
      transform-origin: left;
      :hover ~ * {
        transform: translateX(${zoomFactor + "%"}) !important;
      }
    }

    &.right {
      transform-origin: right;
      :hover ~ * {
        transform: translateX(0%) !important;
      }
    }
  `}
`;
```

```ts:./src/components/styles/index.ts
export * from "components/styles/Slider";
export * from "components/styles/SliderItem";
```

```tsx:./src/components/Slider.tsx
import React, { useState, useEffect, useRef } from "react";

import {
  StyledSliderWrapper,
  StyledSlider,
  StyledButtonWrapper,
  StyledButton,
} from "components/styles";

import SliderItem from "components/SliderItem";

interface SliderProps {
  children?: any;
  zoomFactor: number;
  slideMargin: number;
  maxVisibleSlides: number;
  pageTransition: number;
}

const Slider: React.FC<SliderProps> = ({
  children,
  zoomFactor,
  slideMargin,
  maxVisibleSlides,
  pageTransition,
}) => {
  const [currentPage, setCurrentPage] = useState<number>(0);
  const [transformValue, setTransformValue] = useState<string>(
    `-${zoomFactor / 2}%`
  );
  const [windowWidth, setWindowWith] = useState<number>(0);
  const sliderRef = useRef<HTMLElement>(null);

  /* 画面サイズによってスライドの表示枚数を決定 */
  const numberOfSlides = (
    maxVisibleSlides: number,
    windowWidth: number
  ): number => {
    if (windowWidth > 1024) return maxVisibleSlides; /* パソコンを想定 */
    if (windowWidth > 768) return 4; /* タブレットを想定 */

    return 3; /* スマホを想定 */
  };

  const visibleSlides = numberOfSlides(maxVisibleSlides, windowWidth);
  const totalPages: number = Math.ceil(children.length / visibleSlides) - 1;

  /* 画面サイズを測定 */
  useEffect(() => {
    const resizeObserver = new ResizeObserver((entries) => {
      setWindowWith(entries[0].contentRect.width);
    });
    // @ts-ignore
    resizeObserver.observe(sliderRef.current);
  }, [sliderRef]);

  /* スライダーの位置を調整 */
  useEffect(() => {
    if (sliderRef && sliderRef.current) {
      if (currentPage > totalPages) setCurrentPage(totalPages);

      sliderRef.current.style.transform = `translate3D(-${
        currentPage * windowWidth
      }px, 0, 0)`;
    }
  }, [sliderRef, currentPage, windowWidth, totalPages]);

  /* ページ推移中はスライドのホバー効果を無効にする（でないと推移中にマウスがスライドの上に乗った場合の見栄えが悪くなってしまう） */
  const disableHoverEffect = () => {
    if (sliderRef.current) sliderRef.current.style.pointerEvents = "none";

    setTimeout(() => {
      if (sliderRef.current) sliderRef.current.style.pointerEvents = "all";
    }, pageTransition);
  };

  /* ページを推移させる */
  const handleSlideMove = (forward: boolean) => {
    disableHoverEffect();
    setCurrentPage(currentPage + (forward ? 1 : -1));

    if (sliderRef.current)
      sliderRef.current.style.transform = `translate3D(-${
        (currentPage + (forward ? 1 : -1)) * windowWidth
      }px, 0, 0)`;
  };

  /* マウスオーバー時の挙動 */
  const handleMouseOver = (id: number) => {
    if (id % visibleSlides === 1) setTransformValue("0%"); /* left */
    if (id % visibleSlides === 0)
      setTransformValue(`-${zoomFactor}%`); /* right */
  };

  /* マウスオアウト時の挙動 */
  const handleMouseOut = () => {
    setTransformValue(`-${zoomFactor / 2}%`);
  };

  const assignSlideClass = (index: number, visibleSlides: number) => {
    const classes = ["right", "left"];

    return classes[index % visibleSlides] || "";
  };

  return (
    <StyledSliderWrapper zoomFactor={zoomFactor} visibleSlides={visibleSlides}>
      <StyledSlider
        visibleSlides={visibleSlides}
        transformValue={transformValue}
        zoomFactor={zoomFactor}
        slideMargin={slideMargin}
        pageTransition={pageTransition}
        ref={sliderRef}
      >
        {children.map((child: any, index: number) => (
          <SliderItem
            key={index}
            slideMargin={slideMargin}
            visibleSlides={visibleSlides}
            zoomFactor={zoomFactor}
            slideClass={assignSlideClass(index + 1, visibleSlides)}
            id={index + 1}
            callback={handleMouseOver}
            callbackOut={handleMouseOut}
          >
            {child}
          </SliderItem>
        ))}
      </StyledSlider>
      {currentPage > 0 && (
        /* バックボタン */
        <StyledButtonWrapper zoomFactor={zoomFactor} isForward={false}>
          <StyledButton
            isForward={false}
            onClick={() => handleSlideMove(false)}
          >
            &#8249;
          </StyledButton>
        </StyledButtonWrapper>
      )}
      {currentPage !== totalPages && (
        /* フォワードボタン */
        <StyledButtonWrapper zoomFactor={zoomFactor} isForward={true}>
          <StyledButton isForward={true} onClick={() => handleSlideMove(true)}>
            &#8250;
          </StyledButton>
        </StyledButtonWrapper>
      )}
    </StyledSliderWrapper>
  );
};

export default Slider;
```

```tsx:./src/components/SliderItem.tsx
import React from "react";

import { StyledSliderItem } from "components/styles";

interface SliderItemProps {
  slideClass: string;
  zoomFactor: number;
  id: number;
  callback: (id: number) => void;
  callbackOut: () => void;
  slideMargin: number;
  visibleSlides: number;
}

const SliderItem: React.FC<SliderItemProps> = ({
  slideMargin,
  visibleSlides,
  zoomFactor,
  slideClass,
  id,
  callback,
  callbackOut,
  children,
}) => (
  <StyledSliderItem
    zoomFactor={zoomFactor}
    slideMargin={slideMargin}
    visibleSlides={visibleSlides}
    className={slideClass}
    onMouseOver={() => callback(id)}
    onMouseOut={callbackOut}
  >
    {children}
  </StyledSliderItem>
);

export default SliderItem;
```

### App.tsx

```tsx:./src/App.tsx
import React, { useState, useEffect } from "react";

import { CircularProgress } from "@material-ui/core";
import Slider from "./components/Slider";

const SliderProps = {
  zoomFactor: 100 /* ホバー時にどれくらいズームするか */,
  slideMargin: 5 /* スライド間の余白 */,
  maxVisibleSlides: 5 /* 1ページあたりのスライド枚数*/,
  pageTransition: 500 /* 次のページへの推移速度 */,
};

interface Picture {
  id: number;
  webformatURL: string;
}

const App: React.FC = () => {
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [pictures, setPictures] = useState<Picture[]>([]);

  /* 画像データは Pixabay API 経由で取得（無料で高画質な写真を提供してくれるサービス） */
  const fetchPctures = async () => {
    const baseUrl = "https://pixabay.com/api/";

    const perPage = 20; /* 1ページあたりの取得件数 */
    const key = "PIXABAY_API_KEY"; /* Pixabay APIキー */

    const query = new URLSearchParams({
      per_page: perPage.toString(),
      key: key,
    }); /* クエリパラメータを作成 */

    const res = await (await fetch(`${baseUrl}?${query}`)).json();

    setPictures(res.hits);
    setIsLoading(false);
  };

  useEffect(() => {
    fetchPctures();
  }, []);

  /* ローディング中はアニメーションを流す */
  if (isLoading) return <CircularProgress />;

  return (
    <Slider {...SliderProps}>
      {pictures.map((picture) => (
        <div key={picture.id}>
          <img src={picture.webformatURL} alt="slide-pict" />
        </div>
      ))}
    </Slider>
  );
};

export default App;
```

[https://pixabay.com/api/docs/](https://pixabay.com/api/docs/)

Pixabay API を叩く際に付与できるパラメータは色々あるので、ドキュメントを参考に各自お好みで調整してください。

また、レスポンスは以下のような形式になっています。

```json
{
"total": 4692,
"totalHits": 500,
"hits": [
    {
        "id": 195893,
        "pageURL": "https://pixabay.com/en/blossom-bloom-flower-195893/",
        "type": "photo",
        "tags": "blossom, bloom, flower",
        "previewURL": "https://cdn.pixabay.com/photo/2013/10/15/09/12/flower-195893_150.jpg"
        "previewWidth": 150,
        "previewHeight": 84,
        "webformatURL": "https://pixabay.com/get/35bbf209e13e39d2_640.jpg",
        "webformatWidth": 640,
        "webformatHeight": 360,
        "largeImageURL": "https://pixabay.com/get/ed6a99fd0a76647_1280.jpg",
        "fullHDURL": "https://pixabay.com/get/ed6a9369fd0a76647_1920.jpg",
        "imageURL": "https://pixabay.com/get/ed6a9364a9fd0a76647.jpg",
        "imageWidth": 4000,
        "imageHeight": 2250,
        "imageSize": 4731420,
        "views": 7671,
        "downloads": 6439,
        "likes": 5,
        "comments": 2,
        "user_id": 48777,
        "user": "Josch13",
        "userImageURL": "https://cdn.pixabay.com/user/2013/11/05/02-10-23-764_250x250.jpg",
    },
    {
        "id": 73424,
        ...
    },
    ...
  ]
}
```


取得できる画像のサイズなどにいくつかの選択肢がありますが、今回のスライダーを作成する上では幅640pxの画像で十分だと思うので `webformatURL` を受け取る想定でコードを書きました。

この辺も色々いじってみると面白いかもしれません。

## 動作確認

![nfs.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/fa5acb20-59fc-5366-42fb-b282419f8479.gif)

[http://localhost:3000/](http://localhost:3000/)

APIキーなどに間違いが無ければこんな感じで動くはずです。

## あとがき

以上、React Hooks + TypeScript + styled-components で "Netflix" のような滑らかなスライダーを作ってみました。

もし動かない部分などがあればGitHubにコードを掲載しているので確認してみてください。

https://github.com/kazama1209/react-smooth-slider