🎬

React + TypeScriptで複数のYouTube動画をモーダルウィンドウで再生するギャラリーを作る

に公開

はじめに

本記事では、React + TypeScript を使って
複数の YouTube 動画のサムネイル一覧から、クリックでモーダルウィンドウ再生できるギャラリーを実装したときの手順とポイントをまとめています。

以前クライアントワーク(Web 制作)で、動画一覧からモーダル再生する UI を担当したのですが、
「これを React + TypeScript で実装し直すとしたらどう書くか?」を整理したくて作ったデモです。

対象読者

  • React で複数の動画をモーダルウィンドウで表示・再生したい
  • 複数の YouTube 動画を、カード一覧 + モーダル再生で見せたい
  • 背景のスクロールロックや inert など、アクセシビリティも少し意識した実装例を見てみたい

本記事のサンプルは Vite + React + TypeScript を前提にしており、Node.js 18 以降の環境を想定しています。

完成イメージと主な実装機能

最終的な UI としては、以下のような挙動になります。

  • サムネイル一覧からのモーダル再生
  • YouTube サムネイル URL の動的生成
  • モーダル表示中の背景スクロールロック(iOS 対応)
  • Escape キー / オーバーレイクリックでモーダルを閉じる

実装には ReactPlayer と、以下の自作カスタムフックを使用しています。

  • useScrollLock
  • useEscapeKey
  • useInert

全体構成

ディレクトリ構成はシンプルにこんな感じです。

├─ public/
│  └─ modal_arrow.svg
├─ src/
│  ├─ _hooks/
│  │  ├─ useEscapeKey.ts
│  │  ├─ useInert.ts
│  │  └─ useScrollLock.ts
│  ├─ components/
│  │  └─ Modal.tsx
│  ├─ data/
│  │  └─ videos.ts
│  ├─ types/
│  │  └─ video.ts
│  ├─ App.tsx
│  ├─ index.css
│  └─ main.tsx
├─ index.html
├─ package.json
└─ ...

実装の流れ

  1. 動画データの型とダミーデータを用意する
  2. サムネイル付きの動画一覧を表示する
  3. クリックした動画を状態として持つ(selectedVideo
  4. モーダルコンポーネントを実装する
  5. スクロールロック / Escape キー / inert をカスタムフックで組み込む

ここからは、実装の流れに沿ってコードを見ていきます。

1. 動画データの型とダミーデータを用意する

まずは、動画データを扱うための型を用意します。

src/types/video.ts
export type Video = {
  id: number;
  title: string;
  src: string;
};

今回はシンプルに、

  • 一意な id
  • 一覧やモーダルで表示する title
  • YouTube の URL を表す src

の3つだけを持つ型にしています。

次に、この型を使ってダミーデータを定義します。

src/data/videos.ts
import type { Video } from "../types/video";

export const videos: Video[] = [
  {
    id: 1,
    title: "Sea View | Beauty of Nature",
    src: "https://www.youtube.com/watch?v=nTGXmmBTmzo",
  },
  {
    id: 2,
    title: "City Life | Drone Video",
    src: "https://www.youtube.com/watch?v=HHBsvKnCkwI",
  },
  {
    id: 3,
    title: "Video Of Sea",
    src: "https://www.youtube.com/watch?v=Q3-fxx-Bb78",
  },
  {
    id: 4,
    title: "Mountain Landscape With Snow",
    src: "https://www.youtube.com/watch?v=PavYAOpVpJI",
  },
  {
    id: 5,
    title: "Sea Waves",
    src: "https://www.youtube.com/watch?v=Cpcn-CLUY38",
  },
  {
    id: 6,
    title: "Natural sea",
    src: "https://www.youtube.com/watch?v=axktERb78eQ",
  },
];

実際のプロダクトでは API や CMS から取得することが多いと思いますが、
この記事ではフォーカスを「モーダル周りの実装」に絞りたいため、
コード内に固定の配列として定義しています。

YouTube の動画は、商用利用が絡む場合はライセンスの確認が必要になるケースもあります。
今回はあくまで学習用のデモとして、著作権フリーの自然風景などの動画を使っています。

2. サムネイル付きの動画一覧を表示する

Video[] の配列が用意できたら、まずは「ただの一覧」として表示してみます。

src/App.tsx(一部)
import { videos } from "./data/videos";

const App = () => {
  const getThumbnailUrl = (src: string) => {
    const url = new URL(src);
    const videoId = url.searchParams.get("v");

    if (!videoId) {
      return "";
    }
    return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
  };

  return (
    <>
      <main>
        <section className="page__section">
          <div className="inner">
            <h1 className="page__title">動画一覧</h1>
            <ul className="video__list">
              {videos.map((video) => (
                <li key={video.id} className="video__item">
                  <button type="button" className="video__button">
                    <div className="video__thumbWrapper">
                      <img
                        src={getThumbnailUrl(video.src)}
                        className="video__thumb"
                        alt=""
                        aria-hidden="true"
                      />
                    </div>
                    <p className="video__title">{video.title}</p>
                  </button>
                </li>
              ))}
            </ul>
          </div>
        </section>
      </main>
      {/* ここにモーダルを後から追加します */}
    </>
  );
};

ポイントはこのあたりです。

  • videos.map(...)Video[] をそのままリストに展開している
  • <li> の中身全体を <button> にして、
    見た目上クリックできそうな範囲 = 実際にフォーカスできる範囲 に揃えている
  • 今回、サムネイル画像は装飾扱いなので、alt="" + aria-hidden="true" とし、
    実際のラベルは下の <p className="video__title"> が担うようにしている

サムネイル画像の URL は、YouTube の動画 URL から埋め込み用の ID を取り出して生成しています。

const getThumbnailUrl = (src: string) => {
  const url = new URL(src);
  const videoId = url.searchParams.get("v");

  if (!videoId) {
    return "";
  }

  return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
};

YouTube では

  • https://www.youtube.com/watch?v=xxxxx という URL から
  • https://img.youtube.com/vi/xxxxx/hqdefault.jpg のようにサムネイルを取得できる
    という仕様があるので、それを利用しています。

この時点ではまだボタンをクリックしても何も起きませんが、
「動画一覧を正しく表示できているか」をまず確認するのが目的です。

3. クリックした動画を状態として持つ(selectedVideo

次に、「どの動画カードがクリックされたか」を React の state で管理します。

src/App.tsx
import { useState } from "react";
import { videos } from "./data/videos";
import type { Video } from "./types/video";

const App = () => {
  // いま選択されている動画(何も選ばれていないときは null)
  const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);

  const getThumbnailUrl = (src: string) => {
    const url = new URL(src);
    const videoId = url.searchParams.get("v");

    if (!videoId) {
      return "";
    }

    return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
  };

  // カードがクリックされたときに、その動画を選択状態にする
  const handleOpen = (video: Video) => {
    setSelectedVideo(video);
  };

  // モーダルを閉じるときには選択状態をリセットする
  const handleClose = () => {
    setSelectedVideo(null);
  };

  return (
    <>
      <main>
        {/* ... */}
      </main>

      {/* ここにモーダルコンポーネントを後から追加します */}
    </>
  );
};

selectedVideo は次のようなイメージです。

  • 初期値は null(何も選択されていない状態)
  • ユーザーがカードをクリックすると、その動画オブジェクトがセットされる
  • モーダルを閉じるタイミングで、null に戻す

この状態をもとにして、

  • selectedVideo !== null ならモーダルを開く
  • selectedVideo の中身(タイトルや src)をモーダル側に渡す

ということができるようになります。

実際にカードクリックと handleOpen を紐づけるのがこちらです。

src/App.tsx
<ul className="video__list">
  {videos.map((video) => (
    <li key={video.id} className="video__item">
      <button
        type="button"
        className="video__button"
        onClick={() => handleOpen(video)}
      >
        <div className="video__thumbWrapper">
          <img
            src={getThumbnailUrl(video.src)}
            className="video__thumb"
            alt=""
            aria-hidden="true"
          />
        </div>
        <p className="video__title">{video.title}</p>
      </button>
    </li>
  ))}
</ul>

ここまでで、

  1. カードをクリックすると handleOpen(video) が呼ばれる
  2. クリックされた動画が selectedVideo に保存される

という流れができました。

4. モーダルコンポーネントを作成する

次に、「選択された動画をモーダルで表示するためのコンポーネント」を作ります。

この章では、

  1. シンプルな Modal コンポーネントの枠を用意し(4-1)
  2. CSS で見た目を整え(4-2)
  3. ReactPlayer を使って実際に動画を再生できるようにします(4-3)

という流れで進めます。

4-1. シンプルな Modal コンポーネントを定義する

ここでは一旦、開いているかどうかisOpen)と 閉じる処理onClose)だけを扱うシンプルなモーダルとして実装し、
背景スクロールロックや Escape キーで閉じる処理などの細かい挙動は、後のセクションでカスタムフックとして追加していきます。

src/components/Modal.tsx
import type { ReactNode, MouseEvent } from "react";

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
};

export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
  const handleOverlayClick = (e: MouseEvent<HTMLDivElement>) => {
    // オーバーレイ自体がクリックされたときだけ閉じる
    if (e.target === e.currentTarget) {
      onClose();
    }
  };

  return (
    <div
      className={`modal ${isOpen ? "is-open" : ""}`}
      onClick={handleOverlayClick}
      aria-modal="true"
      role="dialog"
    >
      <div className="modal__content">
        <button
          type="button"
          className="modal__close-button"
          onClick={onClose}
          aria-label="動画を閉じる"
        >
          <img
            src="./icon_modal_close.svg"
            className="modal__close-icon"
            alt=""
          />
        </button>
        {children}
      </div>
    </div>
  );
};

このコンポーネントのポイントは次のとおりです。

  • isOpen
    • モーダルが開いているかどうかを表すフラグです。
    • ここでは CSS クラス(modal / modal is-open)の切り替えに使っています。
      is-open クラス側で opacity / visibility などを切り替えることで、開閉アニメーションを付けられます。
  • onClose
    • モーダルを閉じるときに呼び出すコールバックです。
    • 「閉じるボタン」・「オーバーレイクリック」など、「閉じるきっかけ」ごとに毎回 onClose() を呼んでいるだけ、という形にしておくと扱いやすくなります。
  • children
    • モーダルの中身(今回だと ReactPlayer や動画タイトルなど)を外側から差し込むためのスロットです。
    • モーダル側は「枠」と「閉じる処理」だけを担当し、中身のレイアウトは親コンポーネントに委ねています。
  • handleOverlayClick
    • onClick は子要素にもバブリングしてしまうので、
      e.target === e.currentTarget という条件で「オーバーレイそのものがクリックされたとき」だけ onClose() を呼ぶようにしています。
  • aria-modal="true"role="dialog"
    • スクリーンリーダーなどに「これはモーダルダイアログです」と伝えるための ARIA 属性です。
    • 後述する inert と組み合わせることで、モーダル外のコンテンツにフォーカスが移動しないようにしています。

4-2 モーダルのスタイルを整える(CSS)

src/index.css(一部)
:root {
  --modal-gap-x: 160px; /* 左右の最低余白 */
  --modal-gap-y: 120px; /* 上下の最低余白 */
}

/* モーダルのスタイル */
.modal {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  background-color: #1b1716e6;
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
  opacity: 0;
  visibility: hidden;
  transition: opacity .3s ease-out, visibility .3s ease-out;
}

.modal.is-open {
  opacity: 1;
  visibility: visible;
}

.modal__content {
  background: #111111;
  width: min(
    calc(100vw - 2 * var(--modal-gap-x)),
    calc((100vh - 2 * var(--modal-gap-y)) * 16 / 9)
  );
  aspect-ratio: 16 / 9;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  position: relative;
}

.modal__close-button {
  position: absolute;
  top: -53px;
  right: -53px;
  border: none;
  width: 50px;
  height: 50px;
  background: #ffffff;
  border-radius: 50%;
  cursor: pointer;
}

ここでは、

  • 画面全体を覆うオーバーレイ(.modal
  • 16:9 の動画枠(.modal__content
    の 2 つを中心にスタイルを定義しています。
    width: min(...)aspect-ratio: 16 / 9 によって、「画面の縦横どちらかが狭い方」に合わせて動画枠をリサイズするようにしています。

4-3 ReactPlayer を導入して動画を再生する

モーダルの「枠」ができたので、中身に YouTube 動画プレーヤーを差し込んでいきます。
今回は、自前で <iframe> を書くのではなく、react-player というライブラリを使います。

まずはパッケージをインストールします。

npm install react-player
# または
# yarn add react-player

App.tsxに、次のようにデフォルトインポートして使います。

src/App.tsx
import ReactPlayer from "react-player";

最後に、App.tsx から作成した Modalコンポーネントを呼び出し、
選択された動画だけをモーダルで再生する ところまでつなげます。

App.tsx
const App = () => {
  // 前章までと同じ: selectedVideo, handleOpen, handleClose、getThumbnailUrl など

  return (
    <>
      <main>
        {/* 動画一覧の部分は 3. で書いたものと同じです */}
      </main>

      <Modal isOpen={selectedVideo !== null} onClose={handleClose}>
        {selectedVideo && (
          <ReactPlayer
            src={selectedVideo.src}
            width="100%"
            height="100%"
            controls
          />
        )}
      </Modal>
    </>
  );
};

ここでのポイントは次の 3 つです。

  • isOpen={selectedVideo !== null}
    何か動画が選ばれていれば true になり、Modalis-open クラスが付きます。
    selectedVideonull に戻すと false になり、モーダルを閉じるスタイルに変わります。

  • onClose={handleClose}
    「閉じるボタン」や「オーバーレイクリック」など、モーダル側から「閉じてほしい」ときは
    すべて handleClose() が呼ばれます。中身は setSelectedVideo(null) だけなので、
    「閉じるきっかけ」はモーダル / 「状態の更新」は App という役割分担になっています。

  • {selectedVideo && (<ReactPlayer ... />)}
    selectedVideonull でないときだけ <ReactPlayer> を描画します。
    これにより、「何も選ばれていないのにプレイヤーだけ存在する」という状態を避けられます。
    src={selectedVideo.src} に YouTube の URL を渡し、width="100%" / height="100%" にしておくことで、モーダル内いっぱいにプレイヤーを表示しています。

ユーザビリティ/アクセシビリティ対応のカスタムフックを追加する

ここまでで「クリックした動画をモーダルで再生する」基本の流れは完成しました。

実際のプロダクトでは、さらに次のような振る舞いが欲しくなることが多いと思います。

  • モーダル表示中に背景をスクロールさせない
  • Escape キーでモーダルを閉じられるようにする
  • モーダル表示中、背景を非活性化(inert)してフォーカスが移動しないようにする

このデモでは、それぞれを専用のカスタムフックに切り出しています。

  • useScrollLock … モーダル表示中の背景スクロールロック(iOS 対応)
  • useEscapeKey … Escape キー押下でモーダルを閉じる
  • useInert … モーダル表示中に、背景側の要素に inert を付与する

ここでは 「何をしているか」と「どう使うか」 に絞って紹介し、
実際の実装コードは GitHub のリポジトリにまとめています。

5-1. useScrollLock:モーダル表示中に背景をスクロールさせない

useScrollLock は、「今モーダルが開いているか?」というフラグを受け取って、
開いているあいだだけ body / html のスクロールを止めるフックです。

src/components/Modal.tsx(抜粋)
import useScrollLock from "../_hooks/useScrollLock";

export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
  // モーダル表示中だけ背景のスクロールをロック
  useScrollLock(isOpen);

  // ...
}

実装側では、document.body.style.overflow = 'hidden' や、iOS対策として position: fixed など、ブラウザ環境に応じた手法を切り替えつつ、useEffect のクリーンアップ機能でスタイルを元に戻すようにしています。

5-2. useEscapeKey:Escape キーでモーダルを閉じる

useEscapeKey は、「Escape キーが押されたらコールバックを呼ぶ」ためのフックです。
enabledtrue のときだけイベントリスナーを登録します。

src/components/Modal.tsx(抜粋)
import useEscapeKey from "../_hooks/useEscapeKey";

export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
  useScrollLock(isOpen);

  useEscapeKey({
    enabled: isOpen,
    onEscape: onClose,
  });

  // ...
}

これで「モーダルが開いている間だけ Escape キーで閉じる」が簡単に書けます。
閉じるときの処理は onClose に集約しているので、クリックで閉じる場合とも共通にできます。

5-3. useInert:モーダル表示中に背景を非活性化する

useInert は、「この要素を一時的に inert にしたい」というときに使うフックです。
inert が付いた要素は、クリックやフォーカスの対象から外れ、スクリーンリーダーの読み上げ対象にもなりません。

このデモでは、<main> 要素を対象にしています。

src/App.tsx(抜粋)
import { useRef, useState } from "react";
import useInert from "./_hooks/useInert";

const App = () => {
  const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
  const mainRef = useRef<HTMLElement | null>(null);

  // モーダル表示中だけ、背景の main を inert にする
  useInert({ ref: mainRef, active: selectedVideo !== null });

  return (
    <>
      <main ref={mainRef}>
        {/* 動画一覧 */}
      </main>

      {/* Modal は前章と同じです */}
    </>
  );
};

内部では、activetrue のあいだだけ ref.current.inert = true にし、
クリーンアップ時に false に戻しているだけのシンプルな実装です。

5-4. フォーカストラップについて

本番のモーダル実装では、Tab / Shift + Tab でフォーカスをモーダル内に閉じ込める
「フォーカストラップ」を入れるケースも多いと思います。

今回のデモでも useFocusTrap というフックを作り試しましたが、

  • フォーカス対象が「閉じるボタン」と YouTube プレーヤー(iframe)のみ
  • プレーヤーは ReactPlayer 経由で描画され、iframe 内部の UI も独自にフォーカスを持つ

といった理由から、理想的なループ挙動を安定して実現するのが難しく、
フォーカストラップ用のダミー要素を増やすと、ユーザーから見えない要素にフォーカスが移動する可能性があり、キーボード操作の一貫性という意味でもアクセシビリティ上望ましくないため、

今回は 「背景を inert で無効化するところまで」をベースライン として扱い、
フォーカストラップはフォーム付きダイアログなど、より複雑なケースで検討する方針にしています。

まとめ

この記事では、React + TypeScript で

  • YouTube 動画のサムネイル一覧を表示し、
  • クリックされた動画だけをモーダルで再生し、
  • スクロールロック / Escape キー / inert で使い勝手を整える

というところまでを一通り実装してきました。

ポイントをあらためて整理すると、

  • selectedVideo を軸に「どの動画が選択されているか」を一元管理する
  • モーダルは「開閉のフラグ」と「閉じるコールバック」を受け取る素朴なコンポーネントにしておく
  • 使い勝手やアクセシビリティに関わる処理(スクロールロック、Escape キー、inert)はカスタムフックとして切り出し、どこからでも再利用できるようにする

という設計にしておくと、実装もテストもしやすくなります。

今回のサンプルは動画ギャラリーですが、
中身の <ReactPlayer> を差し替えれば「画像ビューア」「フォーム付きモーダル」などにも応用できます。
ぜひ手元のプロジェクトでも、少しずつカスタマイズしながら試してみてください。

ここまで読んでいただきありがとうございました。
この記事が、モーダル実装やアクセシビリティ対応を考えるときの一例として、少しでもお役に立てばうれしいです。

Discussion