When a playing video scrolls out of view, an IntersectionObserver on its placeholder can dock it into a floating mini-player within the page (reliable, no permissions), or — only in response to a user gesture — hand it to the browser's Picture-in-Picture window; in both cases leave a same-size placeholder behind so the article does not shift.
Problem / Scenario Context
A news site's explainer videos sit at the top of articles. Readers start a video, then scroll down to read along — and lose the video. Product asks for "picture-in-picture when the video leaves the screen", the pattern familiar from news and video sites. The first attempt calls video.requestPictureInPicture() from the observer callback; it throws a NotAllowedError in most browsers because the call was not triggered by a user gesture. The second attempt moves the video element into a fixed-position container, which works but makes the article jump up by the video's height the moment it happens.
There are two different features here — an in-page floating player and the browser's own PiP window — with different rules. The Media Playback Visibility Control topic covers play/pause on visibility; this page covers keeping playback visible.
Mechanics Explanation
In-page mini-player. The video element (or its wrapper) is restyled from its in-flow position to position: fixed in a corner when the placeholder leaves the viewport, and restored when it returns. No permissions are involved; it is just CSS. The observer watches a placeholder wrapper that stays in flow — not the video itself, which moves once docked and would immediately report as visible again.
Browser Picture-in-Picture. HTMLVideoElement.requestPictureInPicture() opens an always-on-top OS-level window, which survives tab switches. Browsers require transient user activation: the call must happen within a short time of a user gesture such as a click. An IntersectionObserver callback is not a user gesture, so the call is rejected. Chrome additionally supports automatic PiP for media sessions on tab switch in some configurations, and Safari has its own heuristics, but none of them are triggered by scrolling within the page.
Layout. Moving an in-flow element out of flow collapses its space. The placeholder must keep the video's aspect ratio (aspect-ratio on the wrapper) so the content below does not move.
Comparison Table: Mini-Player vs Picture-in-Picture API
| Aspect | In-page mini-player | requestPictureInPicture() |
|---|---|---|
| Triggered by scroll alone | yes | no — needs a user gesture |
| Survives tab switch | no | yes |
| Styling control | full | none (browser window) |
| Works on iOS Safari | yes | yes, from a gesture; video element only |
| Close / return controls | yours | browser's |
| Accessibility | you must provide | browser-provided controls |
| Best for | "keep watching while reading" | "watch while doing something else" |
Minimal Reproducible Example
const video = document.querySelector<HTMLVideoElement>('video.explainer')!;
new IntersectionObserver(([e]) => {
if (!e.isIntersecting && !video.paused) video.requestPictureInPicture(); // NotAllowedError
}).observe(video);
Start the video and scroll down: the console shows NotAllowedError: Must be handling a user gesture.
Production-Safe Solution
<div class="video-slot" style="aspect-ratio: 16 / 9">
<div class="video-frame">
<video class="explainer" controls playsinline src="/v/explainer.mp4"></video>
<button class="undock" hidden aria-label="Return video to article">×</button>
<button class="pip" hidden>Pop out</button>
</div>
</div>
.video-slot { position: relative; }
.video-frame { position: absolute; inset: 0; }
.video-frame.docked {
position: fixed; inset: auto 1rem 1rem auto; inline-size: min(320px, 45vw);
aspect-ratio: 16 / 9; z-index: 20; box-shadow: 0 8px 24px rgb(0 0 0 / 0.3);
}
@media (prefers-reduced-motion: no-preference) {
.video-frame { transition: inset 200ms ease-out, inline-size 200ms ease-out; }
}
export function dockOnScroll(slot: HTMLElement): () => void {
const frame = slot.querySelector<HTMLElement>('.video-frame')!;
const video = frame.querySelector('video')!;
const undock = frame.querySelector<HTMLButtonElement>('.undock')!;
const pip = frame.querySelector<HTMLButtonElement>('.pip')!;
let dismissed = false;
const setDocked = (d: boolean): void => {
frame.classList.toggle('docked', d);
undock.hidden = !d;
pip.hidden = !d || !document.pictureInPictureEnabled;
};
const io = new IntersectionObserver(([e]) => {
const offscreen = !e.isIntersecting && e.boundingClientRect.top < 0; // scrolled past, not above
setDocked(offscreen && !video.paused && !dismissed);
if (e.isIntersecting) dismissed = false; // back at the slot: allow docking again
}, { threshold: 0 });
undock.addEventListener('click', () => { dismissed = true; setDocked(false); video.pause(); });
// Real PiP only from a click, which provides the required user activation.
pip.addEventListener('click', () => { void video.requestPictureInPicture().then(() => setDocked(false)); });
video.addEventListener('pause', () => setDocked(false));
io.observe(slot);
return () => io.disconnect();
}
The observer watches the in-flow slot, which never moves, so docking does not feed back into the observation. The slot's aspect-ratio keeps the article from shifting. Docking happens only when the reader has scrolled past the video (top < 0) while it is playing, never for a paused video, and never again after the reader dismisses it — until they scroll back to it. The Pop out button gives an explicit gesture for browser PiP.
When Not to Dock
A floating player is intrusive, and some cases call for restraint:
- Paused videos should never dock; the reader chose to stop.
- Muted autoplaying loops (background or decorative video) should pause when out of view instead, as in pausing background video to save battery.
- Small screens have little room; on narrow viewports a docked player can cover the text the reader scrolled down to read. Consider docking only above a minimum viewport width, or docking as a slim bar with audio controls.
- Reduced motion — keep the dock but drop the animated transition.
- Screen-reader users need the dock's controls in a sensible reading order and a clear accessible name for the close button; the docked frame should not steal focus when it appears.
Verification Steps
- Play and scroll past the video; it should dock, and the article should not shift (check Layout Shift regions).
- Scroll back to the slot; the video returns in place.
- Pause while docked; the mini-player should close.
- Dismiss, scroll away and back to confirm dismissal is remembered until return.
- Click Pop out in Chrome, Safari and Firefox (desktop) and confirm browser PiP opens.
Common Mistakes to Avoid
- Calling
requestPictureInPicture()from the observer. It needs a user gesture. - Observing the video element itself. Once docked it is always visible, so the observer never undocks it.
- Leaving no placeholder. The article jumps by the video's height.
- Docking paused or decorative video. It adds clutter without value.
FAQ
Can I trigger Picture-in-Picture automatically on scroll?
No. Browsers require transient user activation for requestPictureInPicture, and an IntersectionObserver callback does not provide it. Use an in-page mini-player for scroll-triggered behaviour and offer browser PiP from a button.
Why observe a placeholder rather than the video?
Because the docked video is fixed on screen and always visible. Observing it would report visible as soon as it docks, undocking it again. The placeholder stays in the flow and reflects the article position.
Does moving the video element interrupt playback?
Restyling it with CSS does not. Moving the element to a different parent in the DOM can pause or reload it in some browsers, which is why the frame is restyled in place rather than reparented.
What about iframes like YouTube embeds?
The same docking technique works on the iframe's wrapper. Browser Picture-in-Picture for iframe players depends on the embedded player's own support, which you cannot trigger from the parent page.
Is the Document Picture-in-Picture API an option?
In Chromium-based browsers, documentPictureInPicture.requestWindow can place arbitrary HTML — custom controls, captions — in an always-on-top window, again only from a user gesture. It suits rich players but is not available everywhere.
How do I keep captions working in the mini-player?
Native track elements keep rendering when the video is restyled. Custom caption overlays must be inside the docked frame so they move with it.
Related
- Autoplaying Video Only When in Viewport — the playback trigger
- Measuring Video View Time with IntersectionObserver — counting docked time correctly
- Building a Sticky Header with IntersectionObserver — the same placeholder technique
↑ Back to Media Playback Visibility Control