React + Typescriptで特定のセクションに表示される追従要素の実装

## この記事はなに？

この記事では、画面の特定の位置に固定表示される追従要素を、React + Typescriptで実装する方法を解説します。
追従要素は、フローティングアイテムや、スティッキーアイテムと呼ばれることもあります。
この記事では、「追従要素」と呼び、特定の要素が表示領域に入ったとき、追従要素を表示したり、消したり出来るようにします。

## 実装の概要

今回実装する追従要素は、以下の技術と概念を使用します。

技術 | 概要
--- | ---
React | UIを構築するためのJavaScriptライブラリ。
TypeScript | JavaScriptに静的型付けを追加し、開発を効率化します。
`useState` | コンポーネンの状態を管理するReact Hooks。追従要素の表示・非表示を制御するために使います。
`useEffect` | コンポーネントのライフサイクルイベント（マウント、アンマウント、更新）で副作用を実行するReact Hooks。スクロールイベントリスナーのセットアップとクリーンアップに使います。
`useRef` |  DOM要素への参照を保持するReact Hooks。追従要素を表示するトリガーとなるセクションのDOM要素を取得するために使います。
スクロールイベントリスナー | `window.scrollY` と要素の `offsetTop` を比較することで、追従要素の表示/非表示を制御します。

## コンポーネントの作成

### 追従要素のコンポーネント (`StickyElement.tsx`)

これは追従する要素のコンポーネントです。
今回はシンプルにテキストとボタンを表示するだけのものを作成します。

```typescript:StickyElement.tsx
import React from "react";

export const StickyElement: React.FC = () => {
  return (
    <div
      style={{
        position: "fixed",
        bottom: "20px",
        right: "20px",
        background: "#333",
        color: "white",
        padding: "16px",
        borderRadius: "8px",
        zIndex: 1000,
      }}
    >
      <h3>追従要素です！</h3>
      <p>親コンポーネントに設定した、`StartRef` から `EndRef` のセクションの間だけ表示されます。</p>
      <button onClick={() => alert("ボタンがクリックされました！")}>
        アクション
      </button>
    </div>
  );
};

```

### 親コンポーネント（`ParentComponent.tsx`）

このコンポーネントは、追従要素を表示し始めるトリガーとなるセクションと、追従要素を非表示にするトリガーとなるセクション、そして実際に追従要素を表示する領域を持ちます。

```typescript:ParentComponent.tsx
import React, { useRef, useState, useEffect } from "react";
import { StickyElement } from "./StickyElement";

export const ParentComponent: React.FC = ({}) => {
  // 追従要素を表示開始するトリガーとなるセクションへの参照
  const startRef = useRef<HTMLDivElement>(null);
  // 追従要素を非表示にするトリガーとなるセクションへの参照
  const endRef = useRef<HTMLDivElement>(null);
  // 追従要素の表示・非表示を管理する状態
  const [isStickyVisible, setIsStickyVisible] = useState(false);

  useEffect(() => {
    // スクロールイベントが発生した際のハンドラー関数
    const handleScroll = () => {
      // startRef と endRef がDOM要素に正しく紐付いているか確認
      if (startRef.current && endRef.current) {
        // startRef の上端から表示開始位置を設定
        const startPosition = startRef.current.offsetTop;
        // endRef の上端を表示終了位置に設定
        const endPosition = endRef.current.offsetTop;
        // 現在のスクロール位置 (縦方向)
        const scrollY = window.scrollY;
        // ウィンドウの表示領域の高さ
        const windowHeight = window.innerHeight;

        // 追従要素が表示されるべきかどうかを判定するロジック
        const isStickyVisible =
          scrollY + windowHeight > startPosition &&
          scrollY + windowHeight < endPosition;

        // 追従要素の表示状態を更新
        setIsStickyVisible(isStickyVisible);
      }
    };

    // スクロールイベントリスナーを追加
    window.addEventListener("scroll", handleScroll);
    // コンポーネントがマウントされた時点で一度ハンドラーを実行し、初期表示を設定
    handleScroll();

    // クリーンアップ関数: コンポーネントがアンマウントされる際にイベントリスナーを削除
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []); // 依存配列が空なので、マウント時とアンマウント時にのみ実行

  return (
    <div>
      {/* 追従要素が表示される前のダミーセクション */}
      <div style={{ height: "120vh", background: "#d0d0d0", padding: "20px" }}>
        <h2>上部のコンテンツ</h2>
        <p>スクロールして下のセクションへ</p>
      </div>

      {/* 追従要素を表示開始するトリガーとなるセクション */}
      <div
        ref={startRef}
        style={{ height: "100vh", background: "#f0f0f0", padding: "20px" }}
      >
        <h2>追従要素が表示されるセクションの開始点</h2>
        <p>このセクションに入ると追従要素が現れます。</p>
      </div>

      {/* 追従要素が表示されている間のコンテンツ */}
      <div style={{ height: "100vh", background: "#e8e8e8", padding: "20px" }}>
        <h2>追従要素が表示中のコンテンツ</h2>
        <p>この間、右下に要素が追従します。</p>
      </div>

      {/* 追従要素を非表示にするトリガーとなるセクション */}
      <div
        ref={endRef}
        style={{ height: "100vh", background: "#d0f0d0", padding: "20px" }}
      >
        <h2>追従要素が非表示になるセクションの開始点</h2>
        <p>このセクションに入ると追従要素が消えます。</p>
      </div>

      {/* 追従要素が非表示になった後のダミーセクション */}
      <div style={{ height: "80vh", background: "#d0d0d0", padding: "20px" }}>
        <h2>下部のコンテンツ</h2>
        <p>追従要素はもう表示されません。</p>
      </div>

      {/* 追従要素が表示される領域 */}
      {isStickyVisible && <StickyElement />}
    </div>
  );
};

```


### フェードイン・フェードアウトさせたい場合

上記のコードでは、追従要素が「ぱっぱっ」と消えます。

`{isStickyVisible && <StickyElement />}` を `<StickyElement isVisible={isButtonVisible}/>` に変更します。

DOMを消すわけではなく、cssで`opacity: isVisible ? 1 : 0,` など不透明度やアニメーションを実装することで、フェードイン・フェードアウトが実現できます。



## アプリケーションへの組み込み（`app.tsx`）

作成した、`ParentComponent` をよびだせば、実際に表示出来るようになったと思います。

```typescript:app.tsx
import React from "react";
import { ParentComponent } from "./ParentComponent";
import "./App.css"; // 必要に応じてCSSをインポート

function App() {
  return (
    <div className="App">
      <ParentComponent />
    </div>
  );
}

export default App;


```

### スタイリングの調整

今回は見本ということで、インラインスタイルを使用しましたが、実際のプロジェクトでは、CSSファイルやCSS-in-JSライブラリを使用してスタイリングを行うことを推奨します。

```css
body {
  margin: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.App {
  text-align: center;
}
```


## おわりに

CodeSandboxなどで試しに実行していただければ、動きのイメージが付きやすいと思います。
この記事が、誰かのお役に立てると幸いです :pray: