Fiber UI LogoFiberUI

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.json

A 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.

Picture-in-Picture is not supported in this browser.
"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

PropertyTypeDescription
isActivebooleantrue if Picture-in-Picture mode is active
isSupportedbooleantrue if PiP API is supported by the browser
toggle(element: HTMLVideoElement) => PromiseToggles PiP mode for the given video element
enter(element: HTMLVideoElement) => PromiseEnters PiP mode for the given video element
exit() => PromiseExits PiP mode
windowPictureInPictureWindow | nullThe 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;