Firefox 151 recently shipped the Document Picture-in-Picture API. This isn’t the same thing as the regular Picture-in-Picture API, which pushes videos into a resizable window that remains visible even after switching browser tabs or OS windows. The Document Picture-in-Picture API enables us to put anything into the window.
These windows work like web widgets. We can use them for floating stock tickers, live chat conversations, playlists, to-do lists, notes, spreadsheets — anything we’d want to keep on the screen at all times.
The general idea is that we create a Document Picture-in-Picture window (DPIP window) and put HTML, CSS, and JavaScript into it. It’s pretty simple when you think about it, but as we explore how the API works, we’re going to tackle a slightly more complex scenario you’ll probably run into.
We’re going to clone a stock ticker from the main document into a DPIP window. This gives us an opportunity to talk about relevant media queries and pseudo-classes for writing targeted CSS for the DPIP window. It’s also a reminder that taking an HTML component out of context can break its CSS, so you’ll need to keep that in mind.
To follow along, open the demo in debug mode. Picture-in-picture doesn’t work in nested browsing contexts such as CodePen <iframe>s. Safari doesn’t support the DPIP API yet, so make sure you’re using Chrome or Firefox.
The JavaScript of it all
First we need to check if the browser supports the Document Picture-in-Picture API. Unfortunately, there’s no way to query whether @media (display-mode: picture-in-picture) is supported using feature queries (@supports) because the at-rule() function is only supported by Chrome, and any plans to support preludes (that’s this part: (display-mode: picture-in-picture)) appear to have been dropped anyway.
To do this would’ve been awesome:
@supports at-rule(@media; display-mode: picture-in-picture) {
/* DPIP supported */
}
Note: Safari Technology Preview 251 release notes mention support for at-rule detection in @supports, though it’s unclear when that will roll out. Firefox 155 announced support for it shortly after this was first published.
Instead we have to check browser support using JavaScript, removing the button if DPIP isn’t supported, or making it create a DPIP window if it is:
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 () => {
/* ... */
});
}
Keep in mind that the Document Picture-in-Picture API is a desktop-only API, so the check above accounts for that too.
As for creating the DPIP window, there’s one thing we might want to handle first — an existing DPIP window. DPIP windows replace existing DPIP windows automatically, but we do need to decide what happens if the button is clicked a second time. The code below closes the DPIP window if it’s already open, effectively making the button a toggle:
document.querySelector("button").addEventListener("click", async () => {
/* If the DPIP window is open, close it */
if (window.documentPictureInPicture.window) {
window.documentPictureInPicture.window.close();
}
});
The problem is that focus always switches to the DPIP window, so toggling it off might require two button clicks. One solution is cloning the button into the DPIP window, but the DPIP window already has a “Close” icon-button, so there’s little point. Personally, I’d skip the toggle and let subsequent button clicks recreate the DPIP window. If the user moves or resizes the window, subsequent clicks will reset it to its original position and size (with the right options).
On that note, let’s talk about creating DPIP windows and their options. The width and height options do what you’d expect, but note that you can’t set one without the other — if you don’t set either, the browser chooses. The preferInitialWindowPlacement option, if set to true, prevents the browser from saving the position and size of the DPIP window. The disallowReturnToOpener option (not used here), if set to true, hides the “Back to tab” icon-button.
/* Create the DPIP window */
const DPIP = await window.documentPictureInPicture.requestWindow({
width: 600,
height: 400,
preferInitialWindowPlacement: true
});
The requestWindow() method of the DocumentPictureInPicture interface returns a promise (hence async and await), which means we can handle everything else while the window is being prepared.
We can clone HTML into the DPIP window like this:
/* 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));
To clone multiple elements, we need a different approach. Here’s what we do to clone all <style>s and <link rel=stylesheet>s (and <script>s if needed). Use querySelectorAll() to create an array of NodeList objects and createDocumentFragment() to create an arbitrary DOM tree, then loop through the array using forEach() and clone each node into the off-screen document fragment. Finally, append the entire fragment to the DPIP window’s <head>, causing just one reflow instead of multiple, which is more performant.
Cloning everything probably isn’t necessary, so adjust as needed.
/* 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);
Here’s the complete JavaScript snippet from the demo, which you’ll want to expand on (to add error handling, at least):
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);
});
}
Handling the CSS
If you’re taking HTML out of context and putting it in a DPIP window, make sure the CSS selectors aren’t too specific and are written to work in both contexts.
That said, you might want to write targeted CSS specifically for either window, and that’s where the display-mode media query comes in. Here’s what the demo uses to adjust the container:
#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;
}
}
Also note that the :picture-in-picture pseudo-class applies to the regular Picture-in-Picture API, not the Document Picture-in-Picture API.
Wrapping up
One API feature worth noting is the enter event, which fires when the DPIP window opens (not to be confused with enterpictureinpicture, which is for regular picture-in-picture):
documentPictureInPicture.addEventListener("enter", (event) => {
/* DPIP window opened */
});
The Document Picture-in-Picture API is not a large or complicated API, but with Firefox 151 now shipping support alongside Chrome, it’s a practical option for building persistent floating widgets — and worth adding to your toolkit.