Hey everyone ๐
I recently added Picture-in-Picture (PiP) support to my React app โ and honestly, it was way easier than I expected.
So, this post is both a memo for my future self and a beginner-friendly guide for anyone who wants to add PiP to their web app.
Letโs dive in ๐
๐ฌ What is Document Picture-in-Picture?
Normally, Picture-in-Picture is used for videos โ
like when you pop out a YouTube video and keep it on the corner of your screen.
But now, Chrome 116+ (and other Chromium browsers like Edge) added
๐ Document Picture-in-Picture API,
which allows you to open any HTML/React component inside a small floating window that stays on top.
That means you can show:
- A timer that stays visible while switching tabs ๐
- A chat box or mini UI
- A floating widget (music, notes, weather, etc.)
โ๏ธ Features and Limitations
Item | Description |
---|---|
โ Supported browsers | Chrome / Edge 116+ |
๐ซ Not supported | Safari, Firefox |
๐ช Only one PiP window | You canโt open multiple windows |
๐ฎ Interaction | You can use click / keyboard inside the PiP window |
๐งฑ Size | Small window โ keep your UI minimal |
๐ป React + Vite Example
Hereโs how I implemented it in my project.
/src/hooks/useDocumentPiP.tsx
import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { createRoot, Root } from 'react-dom/client';
declare global {
interface Window {
documentPictureInPicture?: {
requestWindow: (options?: { width?: number; height?: number }) => Promise<Window>;
};
}
}
// Copy CSS styles from main document to PiP window
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) {
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);
}
}
});
};
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);
}, []);
const openPiP = useCallback(
async (content: ReactNode, options?: { width?: number; height?: number }) => {
if (!isSupported || !window.documentPictureInPicture) {
throw new Error('Document Picture-in-Picture is not supported.');
}
cleanup();
const pipWindow = await window.documentPictureInPicture.requestWindow(options);
pipWindowRef.current = pipWindow;
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';
copyStyles(document, pipWindow.document);
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
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('Failed to open PiP', error);
} finally {
setIsPending(false);
}
}, [closePiP, isOpen, isPending, openPiP]);
if (!isSupported) return null; // Hide button if PiP is unsupported
return (
<button
type="button"
className="btn btn-outline btn-sm"
onClick={handleClick}
disabled={isPending}
title="Open Picture-in-Picture"
>
<MdPictureInPictureAlt size={16} className="mr-1" />
{isOpen ? 'Close PiP' : 'Open PiP'}
</button>
);
};
๐งฑ Common Pitfalls
Issue | Cause | Fix |
---|---|---|
CSS not applied | Cross-origin stylesheet error | Re-insert using <link> fallback |
Blank screen |
<body> rendered before ready |
Use await properly or React 18โs createRoot
|
Canโt open two windows | API limitation | Use isOpen state to toggle |
๐งช Testing Checklist
- Open
chrome://flags/#document-picture-in-picture-api
โ Make sure itโs Default or Enabled (not Disabled) - Run
npm run dev
- Click the PiP button โ Small window should appear
- Switch tabs โ the PiP window should stay visible
- Press
Esc
or close it manually โ should return to normal
โ Final Thoughts (and a Small Plug)
Implementing PiP in React was way easier than I expected.
Itโs still limited to Chrome/Edge, but for timers, widgets, or chat tools, itโs surprisingly practical.
Iโve already added this feature to my own app โ
๐ Pomodoro Flow ๐
A minimal Pomodoro timer you can keep floating while working.
If you want to stay focused, check it out!
๐ References
๐งญ Outro
If this post helped you, drop a โค๏ธ or a comment below โ it really motivates me ๐
Top comments (0)