Use picture-in-picture
Pop the video into a floating window, with availability detection and state that tracks every entry path.
Pop the video into a floating always-on-top window so it keeps playing while the user does something else.
Recommended approach
Add a PiPButton. It requests and exits picture-in-picture, reflects the current state, and renders nothing when picture-in-picture is unsupported.
import { Container, createPlayer, PiPButton } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
const { Player } = createPlayer({ features: videoFeatures });
export default function BasicUsage() {
return (
<Player>
<Container className="media-container">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoPlay muted playsInline loop />
<PiPButton
className="media-pip-button"
render={(props, state) => <button {...props}>{state.pip ? 'Exit PiP' : 'Enter PiP'}</button>}
/>
</Container>
</Player>
);
}
.media-container {
position: relative;
}
.media-container video {
width: 100%;
}
.media-pip-button {
position: absolute;
right: 10px;
bottom: 10px;
padding-block: 8px;
padding-inline: 20px;
color: black;
cursor: pointer;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
<video-player class="video-player">
<media-container>
<video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoplay muted playsinline loop></video>
<media-pip-button class="media-pip-button">
<span class="pip">Exit PiP</span>
<span class="not-pip">Enter PiP</span>
</media-pip-button>
</media-container>
</video-player>
.video-player media-container {
position: relative;
display: block;
}
.video-player media-container video {
width: 100%;
}
.media-pip-button {
position: absolute;
right: 10px;
bottom: 10px;
padding-block: 8px;
padding-inline: 20px;
color: black;
cursor: pointer;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
.media-pip-button .pip {
display: none;
}
.media-pip-button .not-pip {
display: none;
}
.media-pip-button[data-pip] .pip {
display: inline;
}
.media-pip-button:not([data-pip]) .not-pip {
display: inline;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/container';
import '@videojs/html/ui/pip-button';
How it works
The picture-in-picture feature puts three things in player state.
pip is true while the video is in a floating window. It tracks every way in and out — your button, the native controls, or a control the browser adds on its own.
pipAvailability answers “can I offer this?” It starts as 'unavailable'. Once the feature attaches to a media element, it settles to 'available' or 'unsupported'.
requestPictureInPicture(), exitPictureInPicture(), and togglePictureInPicture() change the presentation. If the browser blocks a request, the returned promise rejects. In a browser with no picture-in-picture API, the actions resolve without changing anything.
Picture-in-picture and fullscreen are exclusive: requesting one exits the other first.
Availability and constraints
- Show picture-in-picture controls only when
pipAvailabilityis'available'. PiPButton handles this for you. - Requests must come from a user gesture; browsers reject requests outside one.
- Firefox 153 (July 2026) added the programmatic picture-in-picture API. Earlier versions, including Firefox ESR 140, report
'unsupported'even though they offer their own built-in picture-in-picture toggle to users. - On iOS, picture-in-picture is supported on iPhone and iPad through the WebKit presentation mode; the player handles this path for you.
- The browser owns the floating window: its size, position, and built-in controls aren’t yours to style.
- The user can close the window or return the video at any time. React to
pipstate instead of assuming your button is the only entry and exit.
Common variations
Request picture-in-picture from app logic
Enter picture-in-picture when the user scrolls the player out of view or navigates within your app. This still requires a recent user gesture in most browsers, so tie it to an interaction:
import { Container } from '@videojs/react';
import { usePlayer, Video, VideoPlayer } from '@videojs/react/video';
function WatchElsewhereButton() {
const store = usePlayer();
return (
<button
type="button"
onClick={() => {
store.requestPictureInPicture().catch(() => {
// Rejected: no video data yet, or blocked by the browser.
});
}}
>
Keep watching while you browse
</button>
);
}
export default function App() {
return (
<VideoPlayer>
<Container>
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" muted playsInline />
<WatchElsewhereButton />
</Container>
</VideoPlayer>
);
}<video-player>
<media-container>
<video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" muted playsinline></video>
</media-container>
</video-player>
<button type="button" id="watch-elsewhere">Keep watching while you browse</button>
<script type="module">
import '@videojs/html/video/player';
document.querySelector('#watch-elsewhere').addEventListener('click', () => {
const video = document.querySelector('video');
if (typeof video.requestPictureInPicture !== 'function') return; // Unsupported.
video.requestPictureInPicture().catch(() => {
// Rejected: no video data yet, or blocked by the browser.
});
});
</script>Troubleshooting
The picture-in-picture button doesn’t render
pipAvailability is 'unsupported' — the browser has no programmatic picture-in-picture API (Firefox before 153), or the media isn’t a video.
The request rejects
The video has no data yet (wait for it to be ready), the call wasn’t tied to a user gesture, or the browser blocked it. Handle the rejection rather than assuming entry succeeded.
State is wrong after the user closes the floating window
Read pip from player state instead of tracking entry and exit yourself; it stays in sync with every path in and out of picture-in-picture.