Creating Web Widgets Using the Document Picture-in-Picture API
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. No, the Document Picture-in-Picture API enables us to put anything into the window.
I guess we can think of these windows as web widgets. We can use them for floating stock tickers, live chat conversations, playlists, to-do lists, notes, spreadsheets — anything that 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 then we put HTML, CSS, and JavaScript into it. It’s pretty simple when you think about it, but as we explore how the Document Picture-in-Picture API works, we’re going to tackle a slightly more complex scenario that you’ll probably run into.
We’re going to clone a stock ticker from the main document into a DPIP window. This not only gives us an opportunity to talk about some relevant media queries and pseudo-classes, which we’ll use to write targeted CSS for the DPIP window, but it’s also a stark reminder that taking a HTML component out of context can break the CSS, so you’ll need to keep that in mind.
This is said stock ticker:
But for it to work, you’ll need to open the demo in debug mode. This is because picture-in-picture doesn’t work in nested browsing contexts such as CodePen s.
In addition, Safari doesn’t support the DPIP API yet, so make sure that you’re using Chrome or Firefox.
Ready to begin?
The JavaScript of it all
First we need to check if the browser supports the Document Picture-in-Picture API. I imagine that it’d be a nice-to-have feature, so why wait for Safari support? Unfortunately though, there’s no way to query whether or not @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 do mention support for at-rule detection in @supports but it’s unclear when that will rollout. And Firefox 155 announced support for it just a day after this 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 supported):
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 while illustrating exactly why a full-featured at-rule() function would be so useful.
As for creating the DPIP window, there’s one thing that we might want to do first — handle an existing DPIP window. DPIP windows replace existing DPIP windows, so we don’t need to worry about that part of it, 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 button:
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 the DPIP window off might require two button clicks. One solution to that is cloning the button into the DPIP window, but the DPIP window already has a “Close” icon-button, so there’s no point in that. Personally, I wouldn’t do anything, letting subsequent button clicks recreate the DPIP window. In fact, if the user moves or resizes the DPIP window, subsequent button 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 said options. It’s pretty obvious what the width and height options do, but note that we can’t set one without the other, and if we 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 (which does the same thing as the “Close” icon button, but also takes the user back to the originating tab).
/* 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 why we’re using async and await), which means that we can take care of 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 */
DPIP.document.body.append(stock.cloneNode(true));
But to clone multiple elements, we’d need to take a different approach. This is what we’re going to do as we clone all s and s (and s if you need any, or whatever resources the DPIP window requires).
It’s quite simple, though — use querySelectorAll() to create an array of NodeList objects and createDocumentFragment() to create an arbitrary DOM tree, before looping through the array using forEach() and cloning each node into said off-screen document fragment. Finally, append the entire document fragment to the of the DPIP window, causing just one reflow instead of multiple, which is more performant.
And remember, cloning everything probably isn’t necessary, so adjust as needed.
/* Select all
Here’s the complete JavaScript snippet from the demo, which you’ll probably 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 */
DPIP.document.body.append(stock.cloneNode(true));
/* Select all
Handling the CSS
Remember, if taking HTML out of context (along with its CSS) and putting it in a DPIP window, make sure that the CSS selectors aren’t too specific and are written for both contexts.
That being said, you might want to write some targeted CSS specifically for either window, and that’s where the display-mode media query comes into it. It’s fairly self-explanatory — here’s what I’m using in the demo 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 is for the regular Picture-in-Picture API, not the Document Picture-in-Picture API.
Wrapping up
I couldn’t think of a good use for the vaguely named enter event, which fires when the DPIP window opens (not to be confused with the enterpictureinpicture event for regular picture-in-picture):
documentPictureInPicture.addEventListener("enter", (event) => {
/* DPIP window opened */
});
Otherwise, I think that’s a wrap for the Document Picture-in-Picture API. It’s not a terribly large or complicated API, but it sounds like it could be really useful?

