DEV Community

Cover image for How I implemented Picture-in-Picture in React (with full code + tips)
keni
keni

Posted on

How I implemented Picture-in-Picture in React (with full code + tips)

Hey everyone ๐Ÿ‘‹

I recently added Picture-in-Picture (PiP) support to my React app โ€” and honestly, it was way easier than I expected.

So, this post is both a memo for my future self and a beginner-friendly guide for anyone who wants to add PiP to their web app.

Letโ€™s dive in ๐Ÿš€


๐ŸŽฌ What is Document Picture-in-Picture?

Normally, Picture-in-Picture is used for videos โ€”

like when you pop out a YouTube video and keep it on the corner of your screen.

But now, Chrome 116+ (and other Chromium browsers like Edge) added

๐Ÿ‘‰ Document Picture-in-Picture API,

which allows you to open any HTML/React component inside a small floating window that stays on top.

That means you can show:

  • A timer that stays visible while switching tabs ๐Ÿ•’
  • A chat box or mini UI
  • A floating widget (music, notes, weather, etc.)

โš™๏ธ Features and Limitations

Item Description
โœ… Supported browsers Chrome / Edge 116+
๐Ÿšซ Not supported Safari, Firefox
๐ŸชŸ Only one PiP window You canโ€™t open multiple windows
๐ŸŽฎ Interaction You can use click / keyboard inside the PiP window
๐Ÿงฑ Size Small window โ€” keep your UI minimal

๐Ÿ’ป React + Vite Example

Hereโ€™s how I implemented it in my project.

/src/hooks/useDocumentPiP.tsx

import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
import { createRoot, Root } from 'react-dom/client';

declare global {
  interface Window {
    documentPictureInPicture?: {
      requestWindow: (options?: { width?: number; height?: number }) => Promise<Window>;
    };
  }
}

// Copy CSS styles from main document to PiP window
const copyStyles = (source: Document, target: Document) => {
  Array.from(source.styleSheets).forEach((styleSheet) => {
    try {
      const rules = styleSheet.cssRules;
      const style = target.createElement('style');
      style.textContent = Array.from(rules)
        .map((rule) => rule.cssText)
        .join('\n');
      target.head.appendChild(style);
    } catch (error) {
      const ownerNode = styleSheet.ownerNode as HTMLLinkElement | null;
      if (ownerNode?.tagName === 'LINK' && ownerNode.href) {
        const link = target.createElement('link');
        link.rel = 'stylesheet';
        link.href = ownerNode.href;
        target.head.appendChild(link);
      }
    }
  });
};

export const useDocumentPiP = () => {
  const pipWindowRef = useRef<Window | null>(null);
  const pipRootRef = useRef<Root | null>(null);
  const [isOpen, setIsOpen] = useState(false);

  const isSupported = useMemo(
    () => typeof window !== 'undefined' && 'documentPictureInPicture' in window,
    []
  );

  const cleanup = useCallback(() => {
    if (pipRootRef.current) {
      pipRootRef.current.unmount();
      pipRootRef.current = null;
    }
    pipWindowRef.current = null;
    setIsOpen(false);
  }, []);

  const openPiP = useCallback(
    async (content: ReactNode, options?: { width?: number; height?: number }) => {
      if (!isSupported || !window.documentPictureInPicture) {
        throw new Error('Document Picture-in-Picture is not supported.');
      }

      cleanup();

      const pipWindow = await window.documentPictureInPicture.requestWindow(options);
      pipWindowRef.current = pipWindow;

      pipWindow.document.body.innerHTML = '';
      pipWindow.document.body.style.margin = '0';
      pipWindow.document.body.style.display = 'flex';
      pipWindow.document.body.style.alignItems = 'center';
      pipWindow.document.body.style.justifyContent = 'center';

      copyStyles(document, pipWindow.document);

      const container = pipWindow.document.createElement('div');
      container.style.width = '100%';
      container.style.height = '100%';
      pipWindow.document.body.appendChild(container);

      const root = createRoot(container);
      root.render(content);
      pipRootRef.current = root;
      setIsOpen(true);

      const handleClose = () => cleanup();
      pipWindow.addEventListener('pagehide', handleClose, { once: true });
      pipWindow.addEventListener('beforeunload', handleClose, { once: true });
    },
    [cleanup, isSupported]
  );

  const closePiP = useCallback(() => {
    pipWindowRef.current?.close();
    cleanup();
  }, [cleanup]);

  return { isSupported, isOpen, openPiP, closePiP };
};
Enter fullscreen mode Exit fullscreen mode

/src/components/PictureInPictureButton.tsx

import { useCallback, useState } from 'react';
import { MdPictureInPictureAlt } from 'react-icons/md';
import { Timer } from './Timer';
import { useDocumentPiP } from '../hooks/useDocumentPiP';

export const PictureInPictureButton = () => {
  const { isSupported, isOpen, openPiP, closePiP } = useDocumentPiP();
  const [isPending, setIsPending] = useState(false);

  const handleClick = useCallback(async () => {
    if (isPending) return;
    if (isOpen) {
      closePiP();
      return;
    }

    try {
      setIsPending(true);
      await openPiP(
        <div className="bg-base-100 text-base-content w-full h-full flex flex-col items-center justify-center gap-4 p-4">
          <Timer />
          <span className="text-sm font-medium">Picture in Picture</span>
        </div>,
        { width: 360, height: 220 }
      );
    } catch (error) {
      console.error('Failed to open PiP', error);
    } finally {
      setIsPending(false);
    }
  }, [closePiP, isOpen, isPending, openPiP]);

  if (!isSupported) return null; // Hide button if PiP is unsupported

  return (
    <button
      type="button"
      className="btn btn-outline btn-sm"
      onClick={handleClick}
      disabled={isPending}
      title="Open Picture-in-Picture"
    >
      <MdPictureInPictureAlt size={16} className="mr-1" />
      {isOpen ? 'Close PiP' : 'Open PiP'}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode


๐Ÿงฑ Common Pitfalls

Issue Cause Fix
CSS not applied Cross-origin stylesheet error Re-insert using <link> fallback
Blank screen <body> rendered before ready Use await properly or React 18โ€™s createRoot
Canโ€™t open two windows API limitation Use isOpen state to toggle

๐Ÿงช Testing Checklist

  1. Open chrome://flags/#document-picture-in-picture-api โ†’ Make sure itโ€™s Default or Enabled (not Disabled)
  2. Run npm run dev
  3. Click the PiP button โ†’ Small window should appear
  4. Switch tabs โ€” the PiP window should stay visible
  5. Press Esc or close it manually โ€” should return to normal

โ˜• Final Thoughts (and a Small Plug)

Implementing PiP in React was way easier than I expected.

Itโ€™s still limited to Chrome/Edge, but for timers, widgets, or chat tools, itโ€™s surprisingly practical.

Iโ€™ve already added this feature to my own app โ€”

๐Ÿ‘‰ Pomodoro Flow ๐Ÿ…

A minimal Pomodoro timer you can keep floating while working.

If you want to stay focused, check it out!


๐Ÿ”— References


๐Ÿงญ Outro

If this post helped you, drop a โค๏ธ or a comment below โ€” it really motivates me ๐Ÿ™Œ


Top comments (0)