1. はじめに
個人開発したポモドーロタイマー(WEBアプリ)があるのですが、使ってる内に
「タイマーを常に見たい…」
「別タブ開いても小窓で動いてほしい…」
みたいな願望湧いてきたんで、実装してみることにしました。
この記事では、自分がまた忘れたときのために実装方法を書き残しておきまーす。
2. Picture-in-Picture(PiP)とは?
よく YouTube で動画が小窓で残るアレです。
ただ今回のはそれより進化してて、任意のHTML(Reactコンポーネント)を表示できるというもの。
Chrome 116 以降で追加された
「Document Picture-in-Picture API」
を使うと、好きなUIをそのまま前面ウィンドウに出せるみたいです。
✅ 対応ブラウザ
| ブラウザ | 対応状況 |
|---|---|
| Chrome / Edge | ✅ OK(v116以上) |
| Safari / Firefox | ❌ 未対応(そのうち来るかも) |
間違ってたらごめん
✅ ざっくり流れ
ざっくりいうと、こうです👇
-
window.documentPictureInPictureが使えるか確認 -
requestWindow()でミニウィンドウを作る - Reactコンポーネントをそこにマウント
- 閉じられたら
unmount()して掃除
3. 使用技術
- フレームワーク: React
- ビルドツール: Vite
- 言語: TypeScript
4. 実装コード
/src/hooks/useDocumentPiP.tsx
React で扱いやすいように、まずフックを作ります。
PiPのウィンドウを開いたり閉じたり、Reactの世界から操作できるように。
import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { createRoot, Root } from 'react-dom/client';
// 型宣言(Chrome系のみ対応)
declare global {
interface Window {
documentPictureInPicture?: {
requestWindow: (options?: { width?: number; height?: number }) => Promise<Window>;
};
}
}
// 元のページのCSSをPiPにも反映する関数
const copyStyles = (source: Document, target: Document) => {
Array.from(source.styleSheets).forEach((styleSheet) => {
try {
// 通常のスタイルを全部コピー
const rules = styleSheet.cssRules;
const style = target.createElement('style');
style.textContent = Array.from(rules)
.map((rule) => rule.cssText)
.join('\n');
target.head.appendChild(style);
} catch (error) {
// ⚠️ クロスオリジンCSSは例外が出るので、linkタグで再挿入
const ownerNode = styleSheet.ownerNode as HTMLLinkElement | null;
if (ownerNode?.tagName === 'LINK' && ownerNode.href) {
const link = target.createElement('link');
link.rel = 'stylesheet';
link.href = ownerNode.href;
target.head.appendChild(link);
}
}
});
};
// React Hooks 版 PiP制御
export const useDocumentPiP = () => {
const pipWindowRef = useRef<Window | null>(null);
const pipRootRef = useRef<Root | null>(null);
const [isOpen, setIsOpen] = useState(false);
const isSupported = useMemo(
() => typeof window !== 'undefined' && 'documentPictureInPicture' in window,
[]
);
// クリーンアップ処理
const cleanup = useCallback(() => {
if (pipRootRef.current) {
pipRootRef.current.unmount();
pipRootRef.current = null;
}
pipWindowRef.current = null;
setIsOpen(false);
}, []);
// PiPウィンドウを開く
const openPiP = useCallback(
async (content: ReactNode, options?: { width?: number; height?: number }) => {
if (!isSupported || !window.documentPictureInPicture) {
throw new Error('Document Picture-in-Picture は未対応です');
}
cleanup();
// 小ウィンドウ作成
const pipWindow = await window.documentPictureInPicture.requestWindow(options);
pipWindowRef.current = pipWindow;
// bodyを初期化
pipWindow.document.body.innerHTML = '';
pipWindow.document.body.style.margin = '0';
pipWindow.document.body.style.display = 'flex';
pipWindow.document.body.style.alignItems = 'center';
pipWindow.document.body.style.justifyContent = 'center';
// 元ページのCSSをコピー
copyStyles(document, pipWindow.document);
// Reactのコンテナを作ってレンダリング
const container = pipWindow.document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
pipWindow.document.body.appendChild(container);
const root = createRoot(container);
root.render(content);
pipRootRef.current = root;
setIsOpen(true);
// 小ウィンドウが閉じられたら掃除
const handleClose = () => cleanup();
pipWindow.addEventListener('pagehide', handleClose, { once: true });
pipWindow.addEventListener('beforeunload', handleClose, { once: true });
},
[cleanup, isSupported]
);
// 手動で閉じる関数
const closePiP = useCallback(() => {
pipWindowRef.current?.close();
cleanup();
}, [cleanup]);
return { isSupported, isOpen, openPiP, closePiP };
};
/src/components/PictureInPictureButton.tsx
UI 側です。
PiPがサポートされてないブラウザでは、ボタンをまるごと非表示にしてます。
import { useCallback, useState } from 'react';
import { MdPictureInPictureAlt } from 'react-icons/md';
import { Timer } from './Timer';
import { useDocumentPiP } from '../hooks/useDocumentPiP';
export const PictureInPictureButton = () => {
const { isSupported, isOpen, openPiP, closePiP } = useDocumentPiP();
const [isPending, setIsPending] = useState(false);
const handleClick = useCallback(async () => {
if (isPending) return;
if (isOpen) {
closePiP(); // すでに開いてたら閉じる
return;
}
try {
setIsPending(true);
await openPiP(
<div className="bg-base-100 text-base-content w-full h-full flex flex-col items-center justify-center gap-4 p-4">
{/* ここに小窓で表示したい内容 */}
<Timer />
<span className="text-sm font-medium">Picture in Picture</span>
</div>,
{ width: 360, height: 220 } // ウィンドウサイズ指定
);
} catch (error) {
console.error('PiP 起動に失敗しました', error);
} finally {
setIsPending(false);
}
}, [closePiP, isOpen, isPending, openPiP]);
// 対応してるブラウザでだけ表示
return (
isSupported && (
<button
type="button"
className="btn btn-outline btn-sm"
onClick={handleClick}
disabled={isPending}
title="Picture-in-Picture ウィンドウを開く"
>
<MdPictureInPictureAlt size={16} className="mr-1" />
{isOpen ? 'PiP解除' : 'PiP表示'}
</button>
)
);
};
5. よくあるハマりどころ
| 症状 | 原因 | 対策 |
|---|---|---|
| CSS が反映されない | クロスオリジンCSSの例外 |
<link>タグで再挿入して回避 |
| 真っ白な画面になる |
<body> が生成される前に描画している |
await の順番を確認 or React 18 の createRoot で解決 |
| 2つ以上開けない | API仕様で1つまで |
isOpen 状態で制御 |
☕ まとめ(+ちょっと宣伝)
今回、React で PiP を触ってみて思ったのは、
「思ってたより全然簡単!」ってこと。
Chrome 116 以降ならかなり安定して動作するので、
タイマー・ミニUI・チャット などにめっちゃ応用できます。
自分の開発しているアプリ Pomodoro Flow にもこの機能を組み込んでます🍅
「ポモドーロタイマーを常に前面に出したい」って人は、ぜひ触ってみてください!
👇 参考リンク
おまけ
最近、寒暖差激しくて、ガッツリ風邪ひきました、、、
めっちゃ萎える。
もしこの記事が役に立ったら、
「いいね」 押してくれたら励みになります 🙌