Zenn
🖼️

React + TailwindCSSのWebアプリでDocument Picture-in-Picture

2025/02/11に公開
1

TL;DR

先に完成品。実装過程や詳細を知りたい方はこの先のセクションも読んでください🙏

import { useState } from "react";
import { createPortal } from "react-dom";

const usePiP = ({
  width = 300,
  height = 300,
}) => {
  const [pipWindow, setPiPWindow] = useState<Window | null>(null);
  const isSupported = 'documentPictureInPicture' in window;
  const openPiP = async () => {
    const pw = await window.documentPictureInPicture?.requestWindow({
      width: width,
      height: height,
      disallowReturnToOpener: false,
      preferInitialWindowPlacement: false,
    });
    if (!pw) return;
    setPiPWindow(pw);
    // ユーザーが PiP ウィンドウを閉じたときにstateを更新する
    pw.addEventListener('pagehide', () => {
      setPiPWindow(null);
    });

    // 親ページのスタイルをコピーする
    Array.from(document.styleSheets).forEach((styleSheet) => {
      try {
        const cssRules = Array.from(styleSheet.cssRules)
          .map((rule) => rule.cssText)
          .join('');
        const style = document.createElement('style');

        style.textContent = cssRules;
        pw?.document.head.appendChild(style);
      } catch (_) {
        const link = document.createElement('link');
        if (styleSheet.href == null) {
          return;
        }

        link.rel = 'stylesheet';
        link.type = styleSheet.type;
        link.media = styleSheet.media.toString();
        link.href = styleSheet.href;
        pw.document.head.appendChild(link);
      }
    });
  }
  const closePiP = () => {
    pipWindow?.close();
    setPiPWindow(null);
  }

  return { pipWindow, openPiP, closePiP, isSupported };
}

function App() {
  const { pipWindow, openPiP, closePiP, isSupported } = usePiP({ width: 300, height: 300 });
  return (
    <div className="flex-col p-10">
      <h1>
        Picture-in-Picture Example
      </h1>
      {isSupported ? (
        <div className="flex space-x-2">
          <button className="border p-2 rounded" onClick={openPiP}>
            Open
          </button>
          <button className="border p-2 rounded" onClick={closePiP}>
            Close
          </button>
        </div>
      ) : (<div>
        Picture-in-Picture is not supported in this browser.
      </div>)}
      {pipWindow && createPortal(<div>
        <p className="font-bold text-lg">
          Picture-in-Picture Window
        </p>
      </div>, pipWindow.document.body)}
    </div>
  )
}

Document Picture-in-Pictureとは

https://developer.mozilla.org/en-US/docs/Web/API/Document_Picture-in-Picture_API

Document Picture-in-PictureはWebアプリケーションで任意のHTML要素を常時最前面の別ウィンドウに表示できる新しいWeb APIです。
従来のPicture-in-Picture APIは <video> 要素のみをポップアウト表示できましたが、Document Picture-in-PictureではあらゆるHTML要素を表示できるように拡張されています。

これにより、ブラウザで他のサイトを見ながらさまざまなコンテンツを常に最前面で表示し続けることができるため、よりリッチなUXのWebアプリを開発することができます。

https://support.google.com/meet/answer/13665919?hl=ja

https://developer.chrome.com/blog/spotify-picture-in-picture?hl=ja

ReactでPiPウィンドウを実装する

この記事では、React(+Tailwind CSS)のWebアプリでDocument Picture-in-Pictureのウィンドウを実装する方法を解説します。

各ライブラリのバージョンは以下の通りになります。

{
  "dependencies": {
    "@tailwindcss/vite": "4.0.1",
    "react": "18.3.1",
    "react-dom": "18.3.1",
    "tailwindcss": "4.0.1"
  },
}

PiPウィンドウを表示/非表示できるようにする

まずは真っさらなPiPウィンドウをとりあえず表示できるようにします。
とりあえずWebページにPiPウィンドウの表示/非表示ボタンを作ります。

function App() {
  return (
    <div className="flex-col p-10">
      <h1>
        Picture-in-Picture Example
      </h1>
      <div className="flex space-x-2">
        <button className="border p-2 rounded">
          Open
        </button>
        <button className="border p-2 rounded">
          Close
        </button>
      </div>
    </div>
  )
}

表示

PiPウィンドウを表示するためには、window.documentPictureInPicture?.requestWindow() を呼び出します。各引数については以下のリンクを参照ください。

https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture/requestWindow

function App() {
+  const openPiP = async () => {
+    window.documentPictureInPicture?.requestWindow({
+      width: 300,
+      height: 300,
+      disallowReturnToOpener: false,
+      preferInitialWindowPlacement: false,
+    });
+  }

  return (
    <div className="flex-col p-10">
      <h1>
        Picture-in-Picture Example
      </h1>
      <div className="flex space-x-2">
+       <button className="border p-2 rounded" onClick={openPiP}>
-       <button className="border p-2 rounded">
          Open
        </button>
        <button className="border p-2 rounded">
          Close
        </button>
      </div>
    </div>
  )
}

上記の実装を行うとProperty 'documentPictureInPicture' does not exist on type 'Window & typeof globalThis'エラーで怒られるので、windowにdocumentPictureInPictureを生やします。今回はViteを使っているので、vite-env.d.ts に型を定義します。

Property 'documentPictureInPicture' does not exist on type 'Window & typeof globalThis'エラー

vite-env.d.ts
/// <reference types="vite/client" />
declare interface Window {
  documentPictureInPicture?: DocumentPictureInPicture;
}

type DocumentPictureInPicture = {
  requestWindow: (options?: {
    disallowReturnToOpener?: boolean;
    preferInitialWindowPlacement?: boolean;
    width?: number;
    height?: number;
  }) => Promise<Window>;
};

これでOpenボタンをクリックするとPiPウィンドウが表示されます 🎉

非表示

PiPウィンドウを閉じるためには、ウィンドウ右上の閉じるボタンを押下するか、window.documentPictureInPicture?.requestWindow() で返されるWindowオブジェクトのclose()メソッドを呼び出します。
今回、ページ上に配置したボタンからPiPウィンドウを閉じたいので、useStateで表示したPiPウィンドウを保持し、プログラム的に閉じれるようにします。

function App() {
+ const [pipWindow, setPiPWindow] = useState<Window | null>(null);
  const openPiP = async () => {
+   const pw = await window.documentPictureInPicture?.requestWindow({
-   window.documentPictureInPicture?.requestWindow({
      ...
    });
+   if (!pw) return;
+   setPiPWindow(pw);
+   // ユーザーが PiP ウィンドウを閉じたときにstateを更新する
+   pw.addEventListener('pagehide', () => {
+     setPiPWindow(null);
    });
  }
+ const closePiP = () => {
+   pipWindow?.close();
+   setPiPWindow(null);
+ }

  return (
    ...
        <button className="border p-2 rounded" onClick={openPiP}>
          Open
        </button>
+       <button className="border p-2 rounded" onClick={closePiP}>
-       <button className="border p-2 rounded">
          Close
        </button>
    ...   
  )
}

これでCloseボタンをクリックするとPiPウィンドウが閉じます 🎉

PiPウィンドウ内で任意の要素を表示する

PiPウィンドウ上にReactコンポーネントを表示させるためにcreatePortalを利用します。

https://react.dev/reference/react-dom/createPortal

function App() {
  const [pipWindow, setPiPWindow] = useState<Window | null>(null);
  // ...
  return (
    <div className="flex-col p-10">
      <h1>
        Picture-in-Picture Example
      </h1>
      <div className="flex space-x-2">
        <button className="border p-2 rounded" onClick={openPiP}>
          Open
        </button>
        <button className="border p-2 rounded" onClick={closePiP}>
          Close
        </button>
      </div>
+     {pipWindow && createPortal(
+       <div>
+         <p>
+           Picture-in-Picture Window
+         </p>
+       </div>,
+       pipWindow.document.body,
+      )}
  )
}

今回の例ではやっていませんが、createPortalを利用することで親コンポーネントのコンテキストやstateをPiPウィンドウ内でも利用することができます。


こんなかんじ

PiP内でTailwind CSSを利用できるようにする

PiPウィンドウ上に任意のReactコンポーネントを表示できるようになりましたが、このままではTailwind CSSなどの親ページ側のスタイルが利用できません。

そのため、以下のページを参考にPiPウィンドウを表示した時に親ページのスタイルを全てコピーします。

https://developer.chrome.com/docs/web-platform/document-picture-in-picture#copy-style-sheets-to-the-picture-in-picture-window

function App() {
  const [pipWindow, setPiPWindow] = useState<Window | null>(null);
  const openPiP = async () => {
    const pw = await window.documentPictureInPicture?.requestWindow({
      width: 300,
      height: 300,
      disallowReturnToOpener: false,
      preferInitialWindowPlacement: false,
    });
    if (!pw) return;
    setPiPWindow(pw);
    // ユーザーが PiP ウィンドウを閉じたときにstateを更新する
    pw.addEventListener('pagehide', () => {
      console.log('PiP window closed');
      setPiPWindow(null);
    });

+   // 親ページのスタイルをコピーする
+   Array.from(document.styleSheets).forEach((styleSheet) => {
+     try {
+       const cssRules = Array.from(styleSheet.cssRules)
+         .map((rule) => rule.cssText)
+         .join('');
+       const style = document.createElement('style');
+
+       style.textContent = cssRules;
+       pw.document.head.appendChild(style);
+     } catch (_) {
+       const link = document.createElement('link');
+       if (styleSheet.href == null) {
+         return;
+       }
+
+       link.rel = 'stylesheet';
+       link.type = styleSheet.type;
+       link.media = styleSheet.media.toString();
+       link.href = styleSheet.href;
+       pw.document.head.appendChild(link);
+      }
+   });
  }
  // ...
  return (
    <div className="flex-col p-10">
      {/* ... */}
      {pipWindow && createPortal(<div>
+       {/* クラスを設定してみる */}
+       <p className="font-bold text-lg">
-       <p>
          Picture-in-Picture Window
        </p>
      </div>, pipWindow.document.body)}
    </div>
  )
}

Document Picture-in-Picture APIをサポートしているかどうかチェックする

2025年2月現在、Document Picture-in-Picture APIはExperimentalなAPIであり、サポートしているブラウザは多くありません。

Document Picture-in-Picture API機能が利用できるかどうかはwindowオブジェクトにdocumentPictureInPicture が存在するかをチェックできます。

function App() {
  // ...
  return (
    <div className="flex-col p-10">
      <h1>
        Picture-in-Picture Example
      </h1>
+     {"documentPictureInPicture" in window ? (
        <div className="flex space-x-2">
          <button className="border p-2 rounded" onClick={openPiP}>
            Open
          </button>
          <button className="border p-2 rounded" onClick={closePiP}>
            Close
          </button>
        </div>
+      ) : (<div>
+       Picture-in-Picture is not supported in this browser.
+     </div>)}
      {pipWindow && createPortal(...)}
    </div>
  )
}

サポートしていないブラウザで表示したスクリーンショット

カスタムフックに切り出す

一連の実装をカスタムフックに切り出します。

import { useState } from "react";
import { createPortal } from "react-dom";

const usePiP = ({
  width = 300,
  height = 300,
}) => {
  const [pipWindow, setPiPWindow] = useState<Window | null>(null);
  const isSupported = 'documentPictureInPicture' in window;
  const openPiP = async () => {
    const pw = await window.documentPictureInPicture?.requestWindow({
      width: width,
      height: height,
      disallowReturnToOpener: false,
      preferInitialWindowPlacement: false,
    });
    if (!pw) return;
    setPiPWindow(pw);
    // ユーザーが PiP ウィンドウを閉じたときにstateを更新する
    pw.addEventListener('pagehide', () => {
      setPiPWindow(null);
    });

    // 親ページのスタイルをコピーする
    Array.from(document.styleSheets).forEach((styleSheet) => {
      try {
        const cssRules = Array.from(styleSheet.cssRules)
          .map((rule) => rule.cssText)
          .join('');
        const style = document.createElement('style');

        style.textContent = cssRules;
        pw?.document.head.appendChild(style);
      } catch (_) {
        const link = document.createElement('link');
        if (styleSheet.href == null) {
          return;
        }

        link.rel = 'stylesheet';
        link.type = styleSheet.type;
        link.media = styleSheet.media.toString();
        link.href = styleSheet.href;
        pw.document.head.appendChild(link);
      }
    });
  }
  const closePiP = () => {
    pipWindow?.close();
    setPiPWindow(null);
  }

  return { pipWindow, openPiP, closePiP, isSupported };
}

こうすることでコードの見通しが良くなり、再利用しやすくなりました 🎉

function App() {
  const { pipWindow, openPiP, closePiP, isSupported } = usePiP({ width: 300, height: 300 });
  return (
    <div className="flex-col p-10">
      <h1>
        Picture-in-Picture Example
      </h1>
      {isSupported ? (
        <div className="flex space-x-2">
          <button className="border p-2 rounded" onClick={openPiP}>
            Open
          </button>
          <button className="border p-2 rounded" onClick={closePiP}>
            Close
          </button>
        </div>
      ) : (<div>
        Picture-in-Picture is not supported in this browser.
      </div>)}
      {pipWindow && createPortal(<div>
        <p className="font-bold text-lg">
          Picture-in-Picture Window
        </p>
      </div>, pipWindow.document.body)}
    </div>
  )
}

(余談) Document Picture-in-Pictureしたかった理由

私は個人でオンラインでプランニングポーカーができるWebサービス(React+Tailwind)を開発しています。

https://main.denqeqveakjkg.amplifyapp.com/

スクラム開発でプランニングを行う際、FigmaやJiraを見ながらポイントを設定できたらいいなーと思っていたところにDocument Picture-in-Picture APIを見つけて使ってみました。

おわりに

Document Picture-in-Picture、タブで色々なサイトを沢山開いて行ったり来たりする現代人において、とても便利なAPIだと思います。(特に、Google MeetのPiP機能は素晴らしいです)
Webアプリのユースケースによっては、PiPできるとかなりユーザー体験がよくなるものもあると思うので、ぜひ色々なサービスで取り入れて欲しい&早くExperimentalからGAされて欲しいです。

1

Discussion

ログインするとコメントできます