usePictureInPicture
A comprehensive hook for managing Picture-in-Picture mode for video elements, allowing creating floating video players that persist while multitasking.
Installation
npx shadcn@latest add https://r.fiberui.com/r/hooks/use-picture-in-picture.jsonA React hook that allows you to easily toggle Picture-in-Picture mode for HTML video elements, enabling a floating video window that stays on top of other content.
Browser Support
Supported by most modern desktop browsers (Chrome, Edge, Safari, Firefox). Mobile support varies (especially on iOS where it's native behavior).
Features
- Toggle Mode - Easily enter or exit PiP mode
- State Tracking - Tracks whether PiP is currently active
- Event Listeners - Updates state even when PiP is toggled via browser UI
- Type Safe - Fully typed for TypeScript
- SSR Safe - Works safely with server-side rendering
Basic Usage
A simple video player with a custom button to toggle Picture-in-Picture mode.
"use client";
import { usePictureInPicture } from "@repo/hooks/utility/use-picture-in-picture";
import { Button } from "@repo/ui/components/button";
import { PictureInPicture, X } from "lucide-react";
import { useRef } from "react";
export function Example1() {
const videoRef = useRef<HTMLVideoElement>(null);
const { isActive, isSupported, toggle } = usePictureInPicture();
return (
<div className="flex flex-col gap-4">
<div className="relative overflow-hidden rounded-xl bg-black shadow-lg">
<video
ref={videoRef}
className="aspect-video w-full"
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
controls
loop
/>
{isActive && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 text-white">
<div className="text-center">
<PictureInPicture className="mx-auto mb-2 h-12 w-12 opacity-50" />
<p className="font-medium">
Playing in Picture-in-Picture
</p>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-2">
{!isSupported && (
<div className="rounded bg-yellow-100 p-2 text-sm text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400">
Picture-in-Picture is not supported in this browser.
</div>
)}
<Button
onClick={() => videoRef.current && toggle(videoRef.current)}
isDisabled={!isSupported}
className="gap-2 self-start"
>
{isActive ? (
<>
<X className="h-4 w-4" />
Exit Picture-in-Picture
</>
) : (
<>
<PictureInPicture className="h-4 w-4" />
Enter Picture-in-Picture
</>
)}
</Button>
</div>
</div>
);
}
API Reference
Hook Signature
function usePictureInPicture(): UsePictureInPictureReturn;Return Value
| Property | Type | Description |
|---|---|---|
isActive | boolean | true if Picture-in-Picture mode is active |
isSupported | boolean | true if PiP API is supported by the browser |
toggle | (element: HTMLVideoElement) => Promise | Toggles PiP mode for the given video element |
enter | (element: HTMLVideoElement) => Promise | Enters PiP mode for the given video element |
exit | () => Promise | Exits PiP mode |
window | PictureInPictureWindow | null | The PiP window object (if active) containing dimensions |
Hook Source Code
import { useState, useCallback, useEffect } from "react";
/**
* Return type for the usePictureInPicture hook
*/
export interface UsePictureInPictureReturn {
/** Whether Picture-in-Picture is currently active */
isActive: boolean;
/** Whether Picture-in-Picture is supported by the browser */
isSupported: boolean;
/** Whether the PiP window is currently active */
window: PictureInPictureWindow | null;
/** Enter Picture-in-Picture mode for a video element */
enter: (element: HTMLVideoElement) => Promise<void>;
/** Exit Picture-in-Picture mode */
exit: () => Promise<void>;
/** Toggle Picture-in-Picture mode */
toggle: (element: HTMLVideoElement) => Promise<void>;
}
/**
* A React hook for managing Picture-in-Picture mode for video elements.
*
* @returns UsePictureInPictureReturn object with state and control methods
*/
export function usePictureInPicture(): UsePictureInPictureReturn {
const [isActive, setIsActive] = useState(false);
const [pipWindow, setPipWindow] = useState<PictureInPictureWindow | null>(
null,
);
// Check support
const isSupported =
typeof document !== "undefined" &&
"pictureInPictureEnabled" in document;
// Handle enter PiP
const enter = useCallback(
async (element: HTMLVideoElement) => {
if (!isSupported || !element) return;
try {
if (element.requestPictureInPicture) {
const window = await element.requestPictureInPicture();
setPipWindow(window);
setIsActive(true);
}
} catch (err) {
console.error("Failed to enter Picture-in-Picture:", err);
}
},
[isSupported],
);
// Handle exit PiP
const exit = useCallback(async () => {
if (!isSupported || !document.pictureInPictureElement) return;
try {
await document.exitPictureInPicture();
setPipWindow(null);
setIsActive(false);
} catch (err) {
console.error("Failed to exit Picture-in-Picture:", err);
}
}, [isSupported]);
// Toggle PiP
const toggle = useCallback(
async (element: HTMLVideoElement) => {
if (isActive) {
await exit();
} else {
await enter(element);
}
},
[isActive, enter, exit],
);
// Listen for PiP events
useEffect(() => {
if (!isSupported) return;
const onEnter = (e: Event) => {
const target = e.target as HTMLVideoElement;
// We can't easily get the pipWindow here without the promise return,
// but we know it's active.
setIsActive(true);
};
const onExit = () => {
setIsActive(false);
setPipWindow(null);
};
// Note: These events fire on the video element, not document.
// But since we don't hold the ref to the element in the hook state (to avoid re-renders or complexity),
// we rely on the manual enter/exit calls for primary state logic.
// However, external toggles (like browser UI) need to be caught.
// To do this properly globally is hard without the ref properly passed.
// A common pattern is to just listen to document for 'enterpictureinpicture' but that event is on the element.
// Better approach: User passes ref? Or we return a ref?
// Or we just rely on the user to use the toggle controls provided.
// For robustness, let's keep it simple: controls drive the state.
// But if the user closes the PiP window via the "X" button, we need to know.
// We can listen to 'leavepictureinpicture' on the document (capturing phase) to detect exit?
// Actually, 'leavepictureinpicture' bubbles? MDN says "The event bubbles".
const handleLeave = () => {
if (!document.pictureInPictureElement) {
setIsActive(false);
setPipWindow(null);
}
};
const handleEnter = () => {
if (document.pictureInPictureElement) {
setIsActive(true);
}
};
document.addEventListener("leavepictureinpicture", handleLeave, true);
document.addEventListener("enterpictureinpicture", handleEnter, true);
return () => {
document.removeEventListener(
"leavepictureinpicture",
handleLeave,
true,
);
document.removeEventListener(
"enterpictureinpicture",
handleEnter,
true,
);
};
}, [isSupported]);
return {
isActive,
isSupported,
window: pipWindow,
enter,
exit,
toggle,
};
}
export default usePictureInPicture;
useIsMounted
A utility hook to solve hydration mismatch issues. Returns true only after the component has mounted on the client, ensuring safe access to browser APIs.
useShare
A cross-platform utility hook for invoking the native share sheet on mobile and desktop, enabling effortless content sharing with optional fallbacks.