Live Demo: https://dlitsman.github.io/document-pip/
The Picture-in-picture API has been available in most browsers for quite some time. However, the main downside was that developers have very limited control over custom controls and the look and feel of it, as it only allows to use Video element as a PiP element. This forces developers to use canvas hacks for any custom UI.
Recent updates in Chrome 116 change that by adding Document Picture-in-Picture API support. This creates new possibilities for creating a much richer UX. At the time of writing this article, this feature is supported only in Chrome and Edge. However, hopefully, other browsers will also add support soon.
Let's start by looking at the browser API before adding React integration. For proper integration, we need to support three main operations: opening a window, closing and detecting a closed event, and detecting the feature itself.
API for opening picture-in-picture windows is very similar to the one you can use for regular window.open()
.
However, there are some important differences:
You can check more details and full API about raw implementation in this great article. Below, let's review the core operations we identified before.
To open new window
const pipWindow = await documentPictureInPicture.requestWindow({
width: 500, // optional
heithg: 500, // optional
});
// You have full control over this PiP now
pipWindow.document.body.innerHTML = 'Hello from PiP';
NOTE: It is important to note that you can only open this window in response to use interaction (User Activation); otherwise, you will get this error
DOMException: Failed to execute 'requestWindow' on 'DocumentPictureInPicture': Document PiP requires user activation
You can listen to the pagehide
event to detect when the PiP window is closing. For example, in case a user decides to close the PiP window.
pipWindow.addEventListener("pagehide", (event) => {
// do something when pip is closed by the browser/user
});
You can also decide to close the window at any moment programmatically:
pipWindow.close();
To detect if current runtime supports this API you can use this check:
if ('documentPictureInPicture' in window) {
// Feature supported
}
In the example above, we did some manual manipulations with pipWindow document DOM elements. This might be error-prone and hard to support. Alternatively, React can render the window's content and add interactivity. Especially if you already have React in the app itself.
Let's dive into the code and build React components that will help us render custom components inside a PiP window.
We need a single source of truth to keep track of the existing open window, detect support of the feature in a browser, callbacks to open a window, etc. To make it easier to consume, we can make a Context that will store this information and make it available for other components in our app.
type PiPContextType = {
isSupported: boolean;
pipWindow: Window | null;
requestPipWindow: (width: number, height: number) => Promise<void>;
closePipWindow: () => void;
};
const PiPContext = createContext<PiPContextType | undefined>(undefined);
Now, let's create a Provider that will implement this API.
type PiPProviderProps = {
children: React.ReactNode;
};
export function PiPProvider({ children }: PiPProviderProps) {
// Detect if the feature is available.
const isSupported = "documentPictureInPicture" in window;
// Expose pipWindow that is currently active
const [pipWindow, setPipWindow] = useState<Window | null>(null);
// Close pipWidnow programmatically
const closePipWindow = useCallback(() => {
if (pipWindow != null) {
pipWindow.close();
setPipWindow(null);
}
}, [pipWindow]);
// Open new pipWindow
const requestPipWindow = useCallback(
async (width: number, height: number) => {
// We don't want to allow multiple requests.
if (pipWindow != null) {
return;
}
const pip = await window.documentPictureInPicture.requestWindow({
width,
height,
});
// Detect when window is closed by user
pip.addEventListener("pagehide", () => {
setPipWindow(null);
});
// It is important to copy all parent widnow styles. Otherwise, there would be no CSS available at all
// https://developer.chrome.com/docs/web-platform/document-picture-in-picture/#copy-style-sheets-to-the-picture-in-picture-window
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules]
.map((rule) => rule.cssText)
.join("");
const style = document.createElement("style");
style.textContent = cssRules;
pip.document.head.appendChild(style);
} catch (e) {
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;
pip.document.head.appendChild(link);
}
});
setPipWindow(pip);
},
[pipWindow]
);
const value = useMemo(() => {
{
return {
isSupported,
pipWindow,
requestPipWindow,
closePipWindow,
};
}
}, [closePipWindow, isSupported, pipWindow, requestPipWindow]);
return <PiPContext.Provider value={value}>{children}</PiPContext.Provider>;
}
To make it easier to consume this Context we can also introduce helper hook
export function usePiPWindow(): PiPContextType {
const context = useContext(PiPContext);
if (context === undefined) {
throw new Error("usePiPWindow must be used within a PiPContext");
}
return context;
}
Now, once we have access to pipWindow, we can render it using React API. Since pipWindow
is not part of our DOM tree that React manages, we need to use createPortal API to render it to different DOM elements.
Let's create a PiPWindow
component that we can use to render inside the newly created Document Picture-in-Picture window.
import { createPortal } from "react-dom";
type PiPWindowProps = {
pipWindow: Window;
children: React.ReactNode;
};
export default function PiPWindow({ pipWindow, children }: PiPWindowProps) {
return createPortal(children, pipWindow.document.body);
}
You can now use it in any component. This is a minimal example:
function Example() {
const { isSupported, requestPipWindow, pipWindow, closePipWindow } =
usePiPWindow();
const startPiP = useCallback(() => {
requestPipWindow(500, 500);
}, [requestPipWindow]);
const [count, setCount] = useState(0);
return (
<div>
{/* Make sure to have some fallback in case if API is not supported */}
{isSupported ? (
<>
<button onClick={pipWindow ? closePipWindow : startPiP}>
{pipWindow ? "Close PiP" : "Open PiP"}
</button>
{pipWindow && (
<PiPWindow pipWindow={pipWindow}>
<div
style={{
flex: 1,
textAlign: "center",
}}
>
<h3>Hello in PiP!</h3>
<button
onClick={() => {
setCount((count) => count + 1);
}}
>
Clicks count is {count}
</button>
</div>
</PiPWindow>
)}
</>
) : (
<div className="error">
Document Picture-in-Picture is not supported in this browser
</div>
)}
</div>
);
}
This will render the Document Picture-in-Picture with our custom component. You can easily add any custom styles or logic there.
You can find complete source code and slightly more advanced examples in this repo: https://github.com/dlitsman/document-pip. It shows how to render a custom dynamic component that shares a state with a root window.
Live Demo: https://dlitsman.github.io/document-pip/
Document Picture-in-Picture is an excellent addition to the available APIs browsers provide. It opens new UX possibilities for different domains of apps, such as video conferencing, productivity, and more.
In this article, I've shown how this API can be integrated with modern React using standard Context and Portal API. I've omitted the error handling part for clarity, but this should be trivial to implement.