Picture In Picture
The Picture-in-Picture (PiP) component provides an interface to both classic video Picture-in-Picture and Document Picture-in-Picture APIs. It enables your Progressive Web App to display content in a floating window that stays on top of other windows, allowing users to multitask while keeping your content visible.
This component is particularly useful for:
Video players and streaming applications
Video conferencing and calls
Live sports or event streaming
Tutorial and educational content
Dashboard monitoring and live data
Multi-window workflows
Browser Support
Classic Picture-in-Picture (Video):
Supported in Chrome, Edge, Safari, and Firefox
Works with
<video>elements
Document Picture-in-Picture:
Supported in Chrome 116+ and Edge 116+
Can display any DOM content (not just video)
Currently experimental in other browsers
Usage
Basic Video Picture-in-Picture
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<video
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
controls
src="/videos/sample.mp4"
>
</video>
</div>
<button {{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}>
Toggle Picture-in-Picture
</button>
</div>
<script>
document.addEventListener('pwa--picture-in-picture:enter', () => {
console.log('Entered PiP mode');
});
document.addEventListener('pwa--picture-in-picture:exit', () => {
console.log('Exited PiP mode');
});
</script>Video Player with PiP Controls
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div class="video-player" {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<video
id="main-video"
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
src="/videos/movie.mp4"
></video>
<div class="player-controls">
<button id="play-pause">▶️ Play</button>
<button {{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}>
📺 PiP Mode
</button>
</div>
</div>
</div>
<script>
const video = document.getElementById('main-video');
const playPauseBtn = document.getElementById('play-pause');
playPauseBtn.addEventListener('click', () => {
if (video.paused) {
video.play();
playPauseBtn.textContent = '⏸️ Pause';
} else {
video.pause();
playPauseBtn.textContent = '▶️ Play';
}
});
document.addEventListener('pwa--picture-in-picture:enter', () => {
// Auto-play when entering PiP
video.play();
// Update UI
document.querySelector('.video-player').classList.add('in-pip');
});
document.addEventListener('pwa--picture-in-picture:exit', () => {
// Update UI when exiting PiP
document.querySelector('.video-player').classList.remove('in-pip');
});
document.addEventListener('pwa--picture-in-picture:error', (event) => {
console.error('PiP error:', event.detail);
alert('Picture-in-Picture is not available');
});
</script>Document PiP (Any Content)
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<div
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
class="dashboard-widget"
>
<h3>Live Dashboard</h3>
<div class="stats">
<div class="stat">
<span class="label">Users Online:</span>
<span class="value" id="users-count">1,234</span>
</div>
<div class="stat">
<span class="label">Revenue:</span>
<span class="value" id="revenue">$12,345</span>
</div>
<div class="stat">
<span class="label">Orders:</span>
<span class="value" id="orders">89</span>
</div>
</div>
</div>
</div>
<button {{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}>
Float Dashboard
</button>
</div>
<script>
// Simulate real-time updates
setInterval(() => {
document.getElementById('users-count').textContent =
Math.floor(Math.random() * 2000);
document.getElementById('revenue').textContent =
'$' + Math.floor(Math.random() * 50000);
document.getElementById('orders').textContent =
Math.floor(Math.random() * 200);
}, 2000);
document.addEventListener('pwa--picture-in-picture:unsupported', () => {
alert('Document Picture-in-Picture is not supported in this browser');
});
</script>Video Conference with PiP
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div class="video-conference" {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<div
class="local-video-container"
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
>
<video id="local-video" autoplay muted></video>
<div class="video-controls">
<button id="mute-toggle">🎤 Mute</button>
<button id="camera-toggle">📹 Camera</button>
</div>
</div>
<div class="remote-videos">
<video class="remote-video" autoplay></video>
<video class="remote-video" autoplay></video>
</div>
</div>
<button {{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}>
Float My Video
</button>
</div>
<script>
// Initialize local video
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
document.getElementById('local-video').srcObject = stream;
});
document.addEventListener('pwa--picture-in-picture:enter', () => {
console.log('Your video is now floating');
// Continue seeing your video while browsing other tabs
});
document.addEventListener('pwa--picture-in-picture:exit', () => {
console.log('Your video is back in the main window');
});
</script>Live Stream Monitor
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div class="stream-monitor" {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<div
class="live-stream"
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
>
<video id="live-stream" autoplay>
<source src="https://example.com/live.m3u8" type="application/x-mpegURL">
</video>
<div class="stream-info">
<span class="live-indicator">🔴 LIVE</span>
<span id="viewer-count">1.2K viewers</span>
</div>
</div>
</div>
<div class="chat-section">
<h3>Live Chat</h3>
<div id="chat-messages"></div>
<input type="text" placeholder="Type a message...">
</div>
<button {{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}>
Pop Out Stream
</button>
</div>
<script>
document.addEventListener('pwa--picture-in-picture:enter', () => {
// Keep watching the stream while interacting with chat
console.log('Stream popped out - continue chatting!');
});
</script>Music Player with Lyrics
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div class="music-player" {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<div
class="player-widget"
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
>
<div class="album-art">
<img src="/albums/cover.jpg" alt="Album Cover">
</div>
<div class="track-info">
<h4 id="track-title">Song Title</h4>
<p id="artist-name">Artist Name</p>
</div>
<div class="player-controls">
<button>⏮️</button>
<button id="play-pause">▶️</button>
<button>⏭️</button>
</div>
<div class="progress-bar">
<div class="progress" style="width: 45%"></div>
</div>
</div>
</div>
<div class="lyrics-section">
<h3>Lyrics</h3>
<div id="lyrics"></div>
</div>
<button {{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}>
Float Player
</button>
</div>
<script>
document.addEventListener('pwa--picture-in-picture:enter', () => {
// Keep controls accessible while reading lyrics
console.log('Player floating - read lyrics below');
});
</script>Parameters
None
Actions
toggle
toggleToggles Picture-in-Picture mode. If not currently in PiP, it enters PiP mode. If already in PiP, it exits.
{{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}The action will:
Check if the
floatingtarget element is specifiedFor
<video>elements: Use classic Video PiP APIFor other elements: Use Document PiP API (where supported)
Automatically restore the element to its original position when exiting
Targets
floating
floatingRequired. The element to display in the Picture-in-Picture window.
For video elements: Uses classic Video PiP
For other elements: Uses Document PiP (Chromium browsers only)
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}container
containerRequired. The parent container where the floating element should be restored when exiting PiP.
{{ stimulus_target('@pwa/picture-in-picture', 'container') }}The component uses this target to remember where to put the element back after PiP closes.
Events
pwa--picture-in-picture:enter
pwa--picture-in-picture:enterDispatched when the element successfully enters Picture-in-Picture mode.
No payload
Example:
document.addEventListener('pwa--picture-in-picture:enter', () => {
console.log('Entered PiP mode');
// Update UI
document.querySelector('.pip-button').textContent = 'Exit PiP';
// Start tracking
analytics.track('pip_entered');
});pwa--picture-in-picture:exit
pwa--picture-in-picture:exitDispatched when the Picture-in-Picture session ends.
No payload
This event fires when:
User closes the PiP window
toggle()action is called while in PiP modeBrowser automatically closes PiP
Example:
document.addEventListener('pwa--picture-in-picture:exit', () => {
console.log('Exited PiP mode');
// Update UI
document.querySelector('.pip-button').textContent = 'Enter PiP';
// The element is automatically restored to its container
});pwa--picture-in-picture:unsupported
pwa--picture-in-picture:unsupportedDispatched when Picture-in-Picture is not available.
No payload
Reasons for this event:
Browser doesn't support PiP
Feature is disabled by user/browser policy
Platform restrictions (e.g., trying Document PiP on unsupported browser)
Example:
document.addEventListener('pwa--picture-in-picture:unsupported', () => {
console.warn('PiP not supported');
// Hide PiP button
document.querySelector('.pip-button').style.display = 'none';
// Show message
alert('Picture-in-Picture is not supported on this device');
});pwa--picture-in-picture:error
pwa--picture-in-picture:errorDispatched when entering or exiting PiP fails.
Payload: Error details
Example:
document.addEventListener('pwa--picture-in-picture:error', (event) => {
console.error('PiP error:', event.detail);
// Show user-friendly message
const errorMsg = event.detail.message || 'Failed to activate Picture-in-Picture';
alert(errorMsg);
});Best Practices
Provide clear controls: Make it obvious how to enter and exit PiP mode
Preserve state: Ensure playback state is maintained when entering/exiting PiP
Responsive design: PiP windows are typically small, design accordingly
Handle errors gracefully: Always listen for unsupported and error events
User interaction required: PiP can only be triggered by user actions
Test on devices: Behavior varies across browsers and platforms
Provide fallbacks: Not all browsers support Document PiP
Classic vs Document PiP
Content
<video> only
Any DOM elements
Browser Support
Wide (all modern browsers)
Chrome/Edge 116+
Use Cases
Video streaming
Dashboards, widgets, any content
Styling
Limited
Full CSS control
Interactivity
Video controls only
Full DOM interaction
Browser-Specific Behavior
Chrome/Edge
Full support for both classic and Document PiP
Document PiP available from version 116+
Provides native PiP controls
Safari
Classic PiP supported for video
No Document PiP support
iOS Safari has platform-specific PiP UI
Firefox
Classic PiP supported
No Document PiP support yet
Provides built-in PiP button for videos
Styling PiP Content
When using Document PiP, you can style the floating content:
/* Target PiP window content */
@media (display-mode: picture-in-picture) {
.dashboard-widget {
padding: 10px;
font-size: 14px;
}
/* Hide unnecessary elements in PiP */
.sidebar,
.footer {
display: none;
}
}Complete Example: Multi-Feature Video Player
<div {{ stimulus_controller('@pwa/picture-in-picture') }}>
<div class="advanced-player" {{ stimulus_target('@pwa/picture-in-picture', 'container') }}>
<video
id="player-video"
{{ stimulus_target('@pwa/picture-in-picture', 'floating') }}
src="/videos/demo.mp4"
></video>
<div class="player-ui">
<div class="progress-container">
<input type="range" id="progress" min="0" max="100" value="0">
<span id="time">0:00 / 0:00</span>
</div>
<div class="controls">
<button id="play-pause">▶️</button>
<button id="mute">🔊</button>
<input type="range" id="volume" min="0" max="100" value="100">
<button id="fullscreen">⛶</button>
<button
{{ stimulus_action('@pwa/picture-in-picture', 'toggle', 'click') }}
id="pip-btn"
>
📺 PiP
</button>
</div>
</div>
</div>
<div class="video-description">
<h2>Video Title</h2>
<p>Video description and details...</p>
</div>
</div>
<script>
const video = document.getElementById('player-video');
const playPauseBtn = document.getElementById('play-pause');
const muteBtn = document.getElementById('mute');
const volumeSlider = document.getElementById('volume');
const progressSlider = document.getElementById('progress');
const timeDisplay = document.getElementById('time');
const pipBtn = document.getElementById('pip-btn');
// Play/Pause
playPauseBtn.addEventListener('click', () => {
if (video.paused) {
video.play();
playPauseBtn.textContent = '⏸️';
} else {
video.pause();
playPauseBtn.textContent = '▶️';
}
});
// Mute/Unmute
muteBtn.addEventListener('click', () => {
video.muted = !video.muted;
muteBtn.textContent = video.muted ? '🔇' : '🔊';
});
// Volume
volumeSlider.addEventListener('input', (e) => {
video.volume = e.target.value / 100;
});
// Progress
video.addEventListener('timeupdate', () => {
const percent = (video.currentTime / video.duration) * 100;
progressSlider.value = percent;
const current = formatTime(video.currentTime);
const total = formatTime(video.duration);
timeDisplay.textContent = `${current} / ${total}`;
});
progressSlider.addEventListener('input', (e) => {
const time = (e.target.value / 100) * video.duration;
video.currentTime = time;
});
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// PiP Events
document.addEventListener('pwa--picture-in-picture:enter', () => {
pipBtn.textContent = '⬛ Exit PiP';
pipBtn.classList.add('active');
// Auto-play when entering PiP
video.play();
console.log('Video is now floating - continue browsing!');
});
document.addEventListener('pwa--picture-in-picture:exit', () => {
pipBtn.textContent = '📺 PiP';
pipBtn.classList.remove('active');
console.log('Video returned to main window');
});
document.addEventListener('pwa--picture-in-picture:unsupported', () => {
pipBtn.style.display = 'none';
console.warn('Picture-in-Picture not supported');
});
document.addEventListener('pwa--picture-in-picture:error', (event) => {
console.error('PiP error:', event.detail);
alert('Failed to activate Picture-in-Picture mode');
});
// Fullscreen
document.getElementById('fullscreen').addEventListener('click', () => {
if (video.requestFullscreen) {
video.requestFullscreen();
}
});
</script>
<style>
.advanced-player {
position: relative;
max-width: 800px;
background: #000;
border-radius: 8px;
overflow: hidden;
}
video {
width: 100%;
display: block;
}
.player-ui {
background: linear-gradient(transparent, rgba(0,0,0,0.8));
padding: 10px;
}
.controls {
display: flex;
gap: 10px;
align-items: center;
}
.controls button {
background: transparent;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 5px 10px;
}
.controls button.active {
background: rgba(255,255,255,0.2);
border-radius: 4px;
}
.progress-container {
margin-bottom: 10px;
}
#progress {
width: 100%;
}
#time {
color: white;
font-size: 12px;
}
.video-description {
padding: 20px;
}
/* Style for when video is in PiP */
.advanced-player.in-pip {
opacity: 0.5;
}
</style>Last updated
Was this helpful?