Floating UI in a Resizable Window
Firefox 151 added support for the Document Picture-in-Picture API. Unlike the standard Picture-in-Picture API, which only pushes video into a persistent overlay window, the Document version lets you place arbitrary HTML, CSS, and JavaScript into a floating window that stays above other tabs and applications.
Think of these windows as persistent web widgets: stock tickers, chat threads, playlists, to-do lists, or a spreadsheet you want to reference while working elsewhere. The API is small and straightforward, but jumping straight to a practical scenario reveals the constraints worth knowing about.
Moving a Component into Its Own Window
To test the API's real limits, we'll clone a stock ticker from the main document into a Document Picture-in-Picture (DPIP) window. That exercise forces us to handle copied styles and media queries correctly — and it makes the point that pulling a component out of its original DOM context can break its CSS if you aren't careful.
Because Picture-in-Picture doesn't work within nested browsing contexts like CodePen <iframe>s, try the debug-mode demo to see this in action. Safari doesn't support the API, so use Chrome or Firefox.
Feature Detection and Window Lifecycle
Ideally you'd gate DPIP support with a feature query like:
@supports at-rule(@media; display-mode: picture-in-picture) {
/* DPIP supported */
}
That won't work, though. The at-rule() function needed for this lives only in Chrome, and support for querying @media (display-mode: picture-in-picture) in @supports hasn't materialized in other browsers. Safari Technology Preview 251 mentions at-rule detection in @supports, but its rollout is unknown; Firefox 155 announced support shortly after this writing.
So JavaScript is the pragmatic path. The API is desktop-only, so the feature check should account for that and remove or enable the trigger button accordingly:
if (!("documentPictureInPicture" in window)) {
/* DPIP not supported (remove button) */
document.querySelector("button").remove();
} else {
/* DPIP supported (listen for button click) */
document.querySelector("button").addEventListener("click", async () => {
/* ... */
});
}
When creating a DPIP window, remember that a new one replaces any existing DPIP window. You need to decide what a second click on the trigger does. Closing the existing window makes the trigger a toggle, but focus always jumps to the DPIP window, so turning it off could require two clicks. A reasonable alternative: let repeated clicks just re-create the window. If the user has moved or resized the open window, a new requestWindow() call resets it to the original dimensions and position.
The requestWindow() method on the DocumentPictureInPicture interface is async and returns a promise. Its options: width and height (both required if you set either — otherwise the browser decides), preferInitialWindowPlacement (set to true to keep the browser from persisting window state), and disallowReturnToOpener (set to true to hide the "Back to tab" icon-button).
/* Create the DPIP window */
const DPIP = await window.documentPictureInPicture.requestWindow({
width: 600,
height: 400,
preferInitialWindowPlacement: true
});
Cloning DOM and Assets
Cloning a single node into the DPIP window is straightforward:
/* Select the component */
const stock = document.querySelector("#stock");
/* Clone the component and append it to the DPIP <body> */
DPIP.document.body.append(stock.cloneNode(true));
But cloning multiple <style> elements and <link rel="stylesheet"> tags (and any <script> tags the window needs) requires a different pattern. Grab all relevant nodes with querySelectorAll(), append clones to an off-screen createDocumentFragment(), and then attach the entire fragment to the DPIP document's <head>. That causes a single reflow instead of one per node, which is the more performant approach.
/* Select all <style>s and <link rel=stylesheet>s */
const styles = document.querySelectorAll("style, [rel=stylesheet]");
/* Create a document fragment */
const documentFragment = document.createDocumentFragment();
/* Clone the styles and append them to the DPIP <head> */
styles.forEach((element) =>
documentFragment.append(element.cloneNode(true))
);
/* Append the document fragment to the DPIP <head> */
DPIP.document.head.append(documentFragment);
You don't necessarily need to clone the whole document — clone only what the DPIP window actually needs. Here's the full demo script, which you'd want to extend with error handling in production:
if (!("documentPictureInPicture" in window)) {
/* DPIP not supported (remove button) */
document.querySelector("button").remove();
} else {
/* DPIP supported (listen for button click) */
document.querySelector("button").addEventListener("click", async () => {
/* Create the DPIP window */
const DPIP = await window.documentPictureInPicture.requestWindow({
width: 600,
height: 400,
preferInitialWindowPlacement: true
});
/* Select the component */
const stock = document.querySelector("#stock");
/* Clone the component and append it to the DPIP <body> */
DPIP.document.body.append(stock.cloneNode(true));
/* Select all <style>s and <link rel=stylesheet>s */
const styles = document.querySelectorAll("style, [rel=stylesheet]");
/* Create a document fragment */
const documentFragment = document.createDocumentFragment();
/* Clone the styles and append them to the DPIP <head> */
styles.forEach((element) =>
documentFragment.append(element.cloneNode(true))
);
/* Append the document fragment to the DPIP <head> */
DPIP.document.head.append(documentFragment);
});
}
Styling the Floating Context
Copied HTML can render incorrectly in the DPIP window if the originating CSS selectors are too context-specific. Write selectors that work in both the main document and the floating window.
When you need targeted rules for just one context, the display-mode media query does the job. The demo adjusts its container like this:
#stock {
width: fit-content;
border-radius: 0.7rem;
@media (display-mode: picture-in-picture) {
width: 100%;
height: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
Watch out for the :picture-in-picture pseudo-class — it belongs to the regular Picture-in-Picture API, not the Document version.
Events and Final Notes
The vaguely named enter event fires when the DPIP window opens (not to be confused with the enterpictureinpicture event from the regular API). Don't conflate the two:
documentPictureInPicture.addEventListener("enter", (event) => {
/* DPIP window opened */
});
The Document Picture-in-Picture API is small and easy to learn, but the demo shows its practical edge cases: CSS context-sensitivity, feature detection limitations, and window lifecycle management. That combination of simplicity and real-world quirks makes it a useful tool for building always-on-screen widgets.



