Picture-in-Picture Mode - Javascript
Picture-in-Picture (PiP) allows users to keep a video meeting visible in a floating window while continuing to use other parts of the application or device.
For example, a user can minimize the meeting into a PiP window while taking notes, checking another application, or browsing other content without leaving the meeting.
This guide explains two ways to implement Picture-in-Picture using VideoSDK:
- Simple PiP — Displays the local webcam in the PiP window using a direct media track. This is the recommended approach when you only need to display the local camera.
- Custom PiP with multiple video streams — Combines multiple participant video streams into a customizable grid and displays the resulting stream in the PiP window. This approach is useful when you want to control which participants are displayed and how they are arranged.
PiP Video
Browsers that support Picture-in-Picture allow a video from an HTMLVideoElement to be displayed in a floating window.
You can enter PiP either through the browser's video controls or programmatically using the requestPictureInPicture() API.
Browser support varies by platform.
- Chrome and Edge (desktop) support the standard
requestPictureInPicture()API. - Safari (desktop and iOS) is routed through WebKit's
webkitSetPresentationMode(). - Firefox does not provide a programmatic API for entering PiP.
The implementation below detects the available API and uses the appropriate approach for the current browser.
Detecting PiP support
Before displaying a PiP control, check whether the browser exposes a supported PiP API.
Use the following helper throughout the application so the same capability check is used for both Simple PiP and Custom PiP.
//iOS browsers are all WebKit and only expose webkitSetPresentationMode
const isIOS =
/iP(hone|ad|od)/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const isSafari =
/^((?!chrome|android|crios|fxios).)*safari/i.test(navigator.userAgent) ||
isIOS;
//Reused everywhere PiP support needs checking
const getPipSupport = () => ({
standard:
"pictureInPictureEnabled" in document &&
document.pictureInPictureEnabled === true,
webkit:
typeof HTMLVideoElement !== "undefined" &&
typeof HTMLVideoElement.prototype.webkitSetPresentationMode === "function",
});
const { standard, webkit } = getPipSupport();
if (!standard && !webkit) {
alert("PiP is not supported by your browser");
}
Simple PiP (Local Webcam)
Simple PiP displays the local participant's camera in the floating PiP window.
This approach is recommended when the PiP window only needs to show the local webcam. The PiP video uses the camera track directly, so no canvas rendering loop is required.
Step 1: Create the PiP video element
Create a dedicated <video> element that will be used as the source for the PiP window. Call initPipVideo() once after joining the meeting, and disposePipVideo() when the PiP element is no longer needed.
The video is kept very small and slightly visible because Safari may stop decoding a video that is completely hidden or positioned off-screen.
let pipSingleVideo = null;
let clonedTrack = null;
const initPipVideo = () => {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.style.position = "fixed";
video.style.pointerEvents = "none";
//Safari stops decoding fully off-screen or invisible videos,
//so keep a tiny, barely visible tile in the corner instead
video.style.right = "0";
video.style.bottom = "0";
video.style.width = "16px";
video.style.height = "9px";
video.style.opacity = "0.01";
document.body.appendChild(video);
pipSingleVideo = video;
};
const disposePipVideo = () => {
if (!pipSingleVideo) return;
//exitSinglePip (defined in Step 4) closes the window if it is still open
exitSinglePip();
if (clonedTrack) {
clonedTrack.stop();
clonedTrack = null;
}
pipSingleVideo.srcObject = null;
pipSingleVideo.remove();
pipSingleVideo = null;
};
Step 2: Provide the local webcam stream
When the local camera is enabled, attach the webcam track to the PiP video element. Use the local participant's stream-enabled and stream-disabled events to keep the PiP video in sync with the camera state.
The track is cloned so that the PiP video has its own media track. This allows the PiP video and the regular meeting video to use the camera independently.
//Call after joining the meeting
const bindLocalWebcamToPip = (meeting) => {
const localParticipant = meeting.localParticipant;
localParticipant.on("stream-enabled", (stream) => {
if (stream.kind !== "video" || !pipSingleVideo) return;
//Stop the previous clone before creating a new one
if (clonedTrack) {
clonedTrack.stop();
clonedTrack = null;
}
const cloned = stream.track.clone();
clonedTrack = cloned;
pipSingleVideo.srcObject = new MediaStream([cloned]);
pipSingleVideo.play().catch(() => {});
});
localParticipant.on("stream-disabled", (stream) => {
if (stream.kind !== "video" || !pipSingleVideo) return;
if (clonedTrack) {
clonedTrack.stop();
clonedTrack = null;
}
pipSingleVideo.srcObject = null;
});
};
Step 3: Enter or exit PiP
Use the appropriate API based on the browser.
Safari and iOS use the WebKit presentation mode API, while browsers supporting the standard PiP API use requestPictureInPicture().
The same function can be used to both enter and exit PiP.
const togglePipModeSingle = () => {
const video = pipSingleVideo;
if (!video || !video.srcObject) return;
const { standard, webkit } = getPipSupport();
if (isSafari && webkit) {
//Exit if already in PiP
if (video.webkitPresentationMode === "picture-in-picture") {
video.webkitSetPresentationMode("inline");
return;
}
//The prototype-level flag doesn't guarantee THIS element can enter
//PiP — Safari also exposes a per-element check
if (
typeof video.webkitSupportsPresentationMode !== "function" ||
!video.webkitSupportsPresentationMode("picture-in-picture")
) {
alert("PiP is not supported by your browser");
return;
}
video.webkitSetPresentationMode("picture-in-picture");
return;
}
if (standard) {
//Exit if already in PiP
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
return;
}
video.requestPictureInPicture();
return;
}
alert("PiP is not supported by your browser");
};
To make your PiP button reflect the current state (for example "Start PiP" / "Stop PiP"), update the button from the PiP lifecycle events on the hidden video, rather than assuming the toggle succeeded:
const setPipButtonState = (active) => {
togglePipBtn.textContent = active ? "Stop PiP" : "Start PiP";
};
pipSingleVideo.addEventListener("enterpictureinpicture", () =>
setPipButtonState(true)
);
pipSingleVideo.addEventListener("leavepictureinpicture", () =>
setPipButtonState(false)
);
pipSingleVideo.addEventListener("webkitpresentationmodechanged", () =>
setPipButtonState(
pipSingleVideo.webkitPresentationMode === "picture-in-picture"
)
);
Step 4: Close PiP when the meeting ends
The PiP video is attached directly to document.body, so it is not removed automatically when your meeting UI is torn down.
Close the PiP window when the user leaves the meeting by using the meeting-left event.
//Exit single-PiP if active — safe to call when it isn't
const exitSinglePip = () => {
const video = pipSingleVideo;
if (!video) return;
try {
if (video.webkitPresentationMode === "picture-in-picture") {
video.webkitSetPresentationMode("inline");
}
if (document.pictureInPictureElement === video) {
document.exitPictureInPicture().catch(() => {});
}
} catch (e) {}
};
meeting.on("meeting-left", () => {
//Closes the PiP window and releases the camera clone
disposePipVideo();
});
disposePipVideo() (Step 1) already calls exitSinglePip() and stops the cloned camera track — this releases the camera and closes the PiP window even if it is called from another teardown path.
Custom PiP with Multiple Video Streams
Use Custom PiP when you want to display multiple participants in the PiP window.
In this approach, participant videos are drawn onto a <canvas>. The canvas is then converted into a MediaStream and provided to a video element that is displayed in PiP.
This gives you control over the PiP layout, allowing you to create a participant grid or another custom layout.
Step 1: Check PiP support
Wire the PiP button to a togglePipModeMulti() method. Before creating the custom PiP window, verify that the current browser supports one of the available PiP APIs using the getPipSupport() helper described in the Detecting PiP support section.
let pipMultiWindow = null;
let pipMultiCleanup = null;
const togglePipBtn = document.getElementById("togglePipBtn");
togglePipBtn.addEventListener("click", () => {
togglePipModeMulti();
});
const togglePipModeMulti = () => {
const { standard, webkit } = getPipSupport();
if (!standard && !webkit) {
alert("PiP is not supported by your browser");
return;
}
};
Step 2: Create the canvas and PiP video
Create a canvas to render the participant layout. A separate video element acts as the source for the PiP window.
Keep the video minimally visible because Safari may reject PiP for a completely hidden video.
const togglePipModeMulti = () => {
//...support check from Step 1
//Create a Canvas which will render the PiP Stream
const source = document.createElement("canvas");
source.width = 640;
source.height = 360;
const ctx = source.getContext("2d");
//Create a Video tag which will popout for PiP
const pipVideo = document.createElement("video");
pipVideo.autoplay = true;
pipVideo.muted = true;
pipVideo.playsInline = true;
pipVideo.style.position = "fixed";
pipVideo.style.right = "0";
pipVideo.style.bottom = "0";
pipVideo.style.width = "16px";
pipVideo.style.height = "9px";
pipVideo.style.opacity = "0.01";
pipVideo.style.pointerEvents = "none";
document.body.appendChild(pipVideo);
pipMultiWindow = pipVideo;
};
Step 3: Render the participant grid
Draw active participant videos onto the canvas. A participant may appear in more than one <video> element, so the implementation deduplicates videos by their underlying media track.
On iOS, source videos may be paused when the application is backgrounded, so the rendering loop periodically attempts to resume them.
const getRowCount = (length) => (length > 2 ? 2 : length > 0 ? 1 : 0);
const getColCount = (length) => (length < 2 ? 1 : length < 5 ? 2 : 3);
const togglePipModeMulti = () => {
//...
//iOS pauses on-page videos when the app is backgrounded but still allows
//play() while hidden — so periodically un-pause them from the paint loop.
//(visibilitychange may never fire while PiP is active, hence polling.)
let nudgeTick = 0;
function nudgeSources() {
nudgeTick = (nudgeTick + 1) % 30; // ~1×/sec at paint cadence
if (nudgeTick !== 0) return;
document.querySelectorAll("video").forEach((v) => {
if (v === pipVideo) return;
if (v.srcObject) v.play().catch(() => {});
});
}
function drawCanvas() {
if (isIOS) nudgeSources();
//One <video> per track — a participant can render in several nodes
const seen = new Set();
const videos = [];
document.querySelectorAll("video").forEach((v) => {
if (v === pipVideo) return;
//Skip the Simple PiP helper — its CLONE evades the track dedupe
if (v === pipSingleVideo) return;
if (!v.videoWidth || !v.videoHeight) return;
const track = v.srcObject?.getVideoTracks?.()[0];
const key = track ? track.id : v;
if (seen.has(key)) return;
seen.add(key);
videos.push(v);
});
try {
ctx.fillStyle = "black";
ctx.fillRect(0, 0, source.width, source.height);
const rows = getRowCount(videos.length);
const columns = getColCount(videos.length);
for (let i = 0; i < rows; i++) {
for (let j = 0; j < columns; j++) {
const idx = j + i * columns;
if (idx < videos.length) {
ctx.drawImage(
videos[idx],
j * (source.width / columns),
i * (source.height / rows),
source.width / columns,
source.height / rows
);
}
}
}
} catch (error) {}
}
};
Only participants with their video enabled are displayed in the PiP window.
Step 4: Keep the participant grid updated
The canvas must be continuously updated so that changes in participant video are reflected in the PiP window.
A setInterval is used instead of requestAnimationFrame because requestAnimationFrame is paused when the page is moved to the background.
Safari applies additional timer throttling when the browser is backgrounded. To keep the custom PiP content updating, the implementation uses a silent Web Audio processing loop as an additional rendering clock.
const togglePipModeMulti = () => {
//...
//Paint loop — setInterval, not requestAnimationFrame (rAF suspends on tab switch)
let drawInterval = null;
const startDraw = () => {
if (drawInterval) return;
drawInterval = setInterval(drawCanvas, 33);
};
const stopDraw = () => {
if (!drawInterval) return;
clearInterval(drawInterval);
drawInterval = null;
};
//Backgrounded Safari throttles timers to ~1Hz (freezing the grid);
//the audio thread is exempt, so a silent ScriptProcessorNode acts as
//~23fps paint clock. Must be created inside the click gesture,
//otherwise Safari keeps the AudioContext suspended.
let audioTicker = null;
if (isSafari) {
try {
const AC = window.AudioContext || window.webkitAudioContext;
const actx = new AC();
const node = actx.createScriptProcessor(2048, 1, 1);
const silence = actx.createGain();
silence.gain.value = 0;
node.connect(silence);
silence.connect(actx.destination);
node.onaudioprocess = () => drawCanvas();
actx.resume().catch(() => {});
audioTicker = { actx, node };
} catch (e) {}
}
};
Step 5: Handle the PiP lifecycle and cleanup
Custom PiP creates several resources, including the canvas, PiP video, captured media stream, rendering timer, and Safari audio processing context.
All of these resources must be released when PiP closes to avoid unnecessary CPU usage, timers, or media tracks remaining active.
These lifecycle listeners are also the right place to update your PiP button — for example setPipButtonState(true) when PiP starts and setPipButtonState(false) inside cleanup — so the button label reflects whether PiP is actually open.
const togglePipModeMulti = () => {
//...
const cleanup = () => {
stopDraw();
if (audioTicker) {
try {
audioTicker.node.onaudioprocess = null;
audioTicker.node.disconnect();
audioTicker.actx.close();
} catch (e) {}
audioTicker = null;
}
if (pipVideo.srcObject) {
pipVideo.srcObject.getTracks().forEach((t) => t.stop());
}
pipVideo.remove();
pipMultiWindow = null;
pipMultiCleanup = null;
};
pipMultiCleanup = cleanup;
//Start painting when PiP begins; tear everything down when it ends
pipVideo.addEventListener("enterpictureinpicture", startDraw);
pipVideo.addEventListener("leavepictureinpicture", cleanup);
pipVideo.addEventListener("webkitpresentationmodechanged", () => {
if (pipVideo.webkitPresentationMode === "picture-in-picture") {
startDraw();
} else {
cleanup();
}
});
};
Step 6: Create the PiP stream and enter PiP
Once the participant grid is being rendered, capture the canvas as a MediaStream and use it as the source for the PiP video.
Safari requires a few additional steps before PiP can be entered:
- The canvas must contain rendered frames before
captureStream()is called. - The video should be started without waiting for
loadedmetadata. - Safari may only report PiP support after the video has started playing, so the implementation checks for support before entering PiP.
const togglePipModeMulti = () => {
//...
//Prime the canvas before captureStream or Safari drops the stream
drawCanvas();
drawCanvas();
drawCanvas();
const stream =
typeof source.captureStream === "function" ? source.captureStream(30) : null;
if (!stream) {
alert("PiP is not supported by your browser");
cleanup();
return;
}
pipVideo.srcObject = stream;
//Don't await — Safari never fires loadedmetadata for canvas streams
pipVideo.play().catch(() => {});
if (isSafari) {
//Safari advertises PiP support only after a frame has played — poll
const trySafariPip = (attempt = 0) => {
if (!pipMultiWindow) return;
const canWebkit =
typeof pipVideo.webkitSupportsPresentationMode === "function" &&
pipVideo.webkitSupportsPresentationMode("picture-in-picture");
if (canWebkit) {
pipVideo.webkitSetPresentationMode("picture-in-picture");
return;
}
if (attempt >= 20) {
alert("PiP is not supported by your browser");
cleanup();
return;
}
drawCanvas(); //keep the stream advancing, then re-check
setTimeout(() => trySafariPip(attempt + 1), 100);
};
trySafariPip();
} else {
const enterPip = () =>
pipVideo.requestPictureInPicture().catch(() => cleanup());
if (pipVideo.readyState >= 1) {
enterPip();
} else {
pipVideo.addEventListener("loadedmetadata", enterPip, { once: true });
}
}
};
requestPictureInPicture() must run while the click's user activation is still valid. The loadedmetadata fallback qualifies because canvas streams report metadata within milliseconds — but delaying entry further (for example behind additional await) can cause Chrome to reject the request with a user-gesture error.
Step 7: Exit PiP and clean up
Provide a way for users to exit PiP from your application's controls.
PiP should also be closed when the user leaves the meeting. Because the PiP video is created outside the meeting UI, it will not automatically disappear when the meeting UI is removed.
const exitMultiPip = () => {
const active = pipMultiWindow;
if (!active) return;
try {
if (active.webkitPresentationMode === "picture-in-picture") {
active.webkitSetPresentationMode("inline");
}
if (document.pictureInPictureElement === active) {
document.exitPictureInPicture().catch(() => {});
}
} catch (e) {}
//Leave events don't fire if entry was still in flight; cleanup() is idempotent
if (pipMultiCleanup) pipMultiCleanup();
};
const togglePipModeMulti = () => {
//If already active, exit
if (pipMultiWindow) {
exitMultiPip();
return;
}
//...Steps 1-6
};
//Close PiP when the user leaves the meeting — the PiP video lives on
//document.body, so nothing else would remove it
meeting.on("meeting-left", () => {
exitMultiPip();
});
Got a Question? Ask us on discord

