react-video-modal
v1.0.0
Published
Accessible, zero-dependency React modal for YouTube, Vimeo and native video, with focus trap, scroll lock and smooth animations.
Maintainers
Readme
react-video-modal
Accessible, zero-dependency React modal for YouTube, Vimeo and video files. Paste a URL and it just works.
Features
- Paste any URL. YouTube (watch, youtu.be, Shorts, embed, live, playlists), Vimeo (including unlisted links), video files (mp4, webm, mov, m3u8, blob: URLs), or any embeddable page.
- Right size, every time. Fits the viewport in both directions. Shorts open vertical, and video files are sized to their real aspect ratio.
- Accessible. A proper dialog with
aria-modal, a focus trap that even works when tabbing out of the player iframe, Esc to close, and focus returned to where it was. - Smooth. Fade and zoom animations that respect
prefers-reduced-motion, a loading spinner, and page scroll locked without layout shift. - Starts where you want. Reads
?t=1m30sfrom YouTube and Vimeo links, or setstartyourself. - Autoplay that works. If the browser blocks autoplay with sound for a video file, it falls back to muted playback.
- Privacy mode. Optional youtube-nocookie.com and Vimeo do-not-track.
- Open it from anywhere. Use a controlled
<VideoModal>or calluseVideoModal().open(url)from any component. - Easy to theme. CSS variables, class names, a custom close icon, and zero-specificity default styles, so your CSS always wins.
- Works everywhere. SSR-safe, Next.js App Router ready (
"use client"), CSP nonce support, TypeScript types, ESM and CJS, React 17 to 19, and no dependencies.
Install
npm install react-video-modalStyles are injected automatically, so there's no CSS file to import.
Usage
Controlled component
import { useState } from 'react';
import { VideoModal } from 'react-video-modal';
export function Hero() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Watch the video</button>
<VideoModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
src="https://www.youtube.com/watch?v=aqz-KE-bpKQ"
title="Product tour"
/>
</>
);
}Open from anywhere with the hook
Wrap your app once. Every useVideoModal() call then shares one modal. Props on the provider become defaults for every video.
import { VideoModalProvider, useVideoModal } from 'react-video-modal';
function App() {
return (
<VideoModalProvider privacyMode>
<Page />
</VideoModalProvider>
);
}
function WatchButton() {
const { open } = useVideoModal();
return <button onClick={() => open('https://vimeo.com/76979871', { title: 'Demo' })}>Play demo</button>;
}useVideoModal() returns { open(src, options?), close(), isOpen }.
Video files with captions
<VideoModal
isOpen={isOpen}
onClose={close}
src="/media/launch.mp4"
poster="/media/launch.jpg"
tracks={[{ src: '/media/launch.en.vtt', srcLang: 'en', label: 'English', default: true }]}
/>Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| isOpen | boolean | — | Whether the modal is open. Required. |
| onClose | () => void | — | Called on Esc, overlay click or the close button. Required. |
| src | string | — | YouTube/Vimeo URL, video file or embeddable page. Required. |
| title | string | "Video player" | Accessible name for the dialog and player. |
| autoplay | boolean | true | Start playing when opened. |
| muted | boolean | false | Start muted. |
| loop | boolean | false | Loop the video. |
| controls | boolean | true | Show player controls. |
| start | number | from URL | Start time in seconds. |
| privacyMode | boolean | false | Use youtube-nocookie.com / Vimeo dnt. |
| playerParams | Record<string, string \| number \| boolean> | — | Extra YouTube/Vimeo player parameters, e.g. { cc_load_policy: 1 }. |
| aspectRatio | number \| "16:9" \| "4/3" … | auto | Width-to-height ratio. Auto-detected for Shorts and files, otherwise 16:9. |
| maxWidth | number \| string | 1280 | Maximum player width. Numbers are pixels. |
| provider | "file" \| "iframe" | auto | Force <video> or a plain iframe, e.g. for extensionless stream URLs. |
| closeOnOverlayClick | boolean | true | Close when clicking outside the video. |
| closeOnEsc | boolean | true | Close on Escape. |
| showCloseButton | boolean | true | Show the close button. |
| closeIcon | ReactNode | × icon | Custom close button content. |
| closeLabel | string | "Close video" | Accessible label for the close button. |
| lockScroll | boolean | true | Prevent the page from scrolling while open. |
| animationDuration | number | 250 | Open/close animation in ms. |
| className | string | — | Class for the box around the player. |
| overlayClassName | string | — | Class for the full-screen overlay. |
| style | CSSProperties | — | Inline styles for the overlay, e.g. theme variables. |
| container | HTMLElement | document.body | Where to portal the modal. |
| nonce | string | — | CSP nonce for the injected <style> tag. |
| poster | string | — | Poster image for video files. |
| tracks | VideoTrack[] | — | Captions/subtitles for video files. |
| videoProps | VideoHTMLAttributes | — | Extra attributes for the <video> element. |
| onAfterClose | () => void | — | Called after the close animation finishes. |
Theming
Set any of these CSS variables on :root, on a class passed as overlayClassName, or through style:
:root {
--rvm-overlay-background: rgba(8, 8, 12, 0.88);
--rvm-backdrop-filter: blur(6px);
--rvm-border-radius: 12px;
--rvm-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
--rvm-max-width: 1280px;
--rvm-gutter: clamp(16px, 4vw, 64px);
--rvm-z-index: 9999;
--rvm-close-background: rgba(255, 255, 255, 0.12);
--rvm-close-hover-background: rgba(255, 255, 255, 0.24);
--rvm-close-color: #fff;
--rvm-focus-ring: #fff;
--rvm-spinner-color: #fff;
}You can also target the class names directly: .rvm-overlay, .rvm-content, .rvm-player, .rvm-close and .rvm-spinner. The overlay has data-state="open" or data-state="closed" during the close animation. Built-in styles use :where(), so any selector you write overrides them.
Utilities
The URL helpers are exported too, e.g. for thumbnails or custom players:
import { parseVideoUrl, getEmbedUrl } from 'react-video-modal';
const video = parseVideoUrl('https://youtu.be/aqz-KE-bpKQ?t=90');
// { provider: 'youtube', id: 'aqz-KE-bpKQ', start: 90, url: '…' }
getEmbedUrl(video, { muted: true, privacyMode: true });
// 'https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?rel=0&playsinline=1&autoplay=1&mute=1&start=90'Notes
- Esc inside the player: once keyboard focus is inside a YouTube or Vimeo iframe, the browser sends key presses to that iframe, so Esc can't reach the page. Tab back out, or click the close button or the overlay.
- HLS (
.m3u8) plays natively in Safari. Other browsers need a library such as hls.js with your own player.
Development
npm install
npm test # unit and component tests (Vitest + Testing Library)
npm run demo # interactive demo at http://localhost:5173
npm run buildLicense
ISC
