react-poster-video
v0.1.0
Published
Poster-first autoplay hero/cover video for React: the static image is the header, a muted loop fades in only when it can actually play. Respects prefers-reduced-motion, pauses off-screen, and survives blocked autoplay.
Maintainers
Readme
react-poster-video
Autoplay hero video that doesn't ruin your site.
A tiny (~1 component, zero runtime deps) React component for cover/hero video that treats motion as a progressive enhancement, not the baseline:
- 🖼️ The poster image is the real header. It's the SSR markup and paints instantly. The muted loop fades in on top only once it can genuinely play — so a slow connection, a decode failure, or a blocked autoplay just leaves the photo. No black flash, no layout shift, no broken state.
- ♿ Respects
prefers-reduced-motion. The video never even mounts for those users. Accessibility-first, not bolted on. - 🔋 Plays only when on-screen (IntersectionObserver) and pauses when the tab is hidden — no off-screen video draining battery and bandwidth.
- 🤝 Survives the React autoplay footgun (the one everyone hits — see below).
- 🧩 Works in the Next.js App Router (ships the
"use client"directive).
npm i react-poster-videoimport { PosterVideo } from 'react-poster-video';
<PosterVideo
poster="/hero/fountain-poster.jpg"
src="/hero/fountain.mp4"
alt="A stone fountain welling over basalt"
style={{ aspectRatio: '16 / 9' }}
/>;Multiple codecs, a custom crop, and overlay content:
<PosterVideo
poster="/hero/falls-poster.jpg"
sources={[
{ src: '/hero/falls.webm', type: 'video/webm' },
{ src: '/hero/falls.mp4', type: 'video/mp4' },
]}
objectPosition="70% 40%"
crossfadeMs={800}
style={{ height: 480 }}
>
<h1 style={{ position: 'relative', color: 'white' }}>Where strangers become regulars</h1>
</PosterVideo>The React autoplay footgun
Browsers only allow autoplay for muted video. The catch: React's JSX muted
prop can render as an attribute without setting the DOM .muted property —
and the browser checks the property. So <video muted autoPlay> frequently gets
its play() rejected, and you ship a black box.
react-poster-video sets .muted = true on the element imperatively and calls
.play() itself, swallowing the rejection so the poster simply stays if autoplay
is still refused:
const v = videoRef.current;
v.muted = true; // the property, not just the attribute
v.play().then(reveal).catch(() => {/* poster stays — no broken state */});That single detail is the difference between a hero that "sometimes doesn't play" and one that's reliable.
Props
| Prop | Type | Default | |
| --- | --- | --- | --- |
| poster | string | — | Required. The static header image (paints instantly). |
| src | string | — | A single MP4 source. |
| sources | { src; type? }[] | — | Multiple sources (webm + mp4). Wins over src. |
| alt | string | '' | Poster alt text. |
| className / style | | | Applied to the wrapper. Set sizing here (aspectRatio/height). |
| objectFit | CSSProperties['objectFit'] | 'cover' | Fit for poster + video. |
| objectPosition | string | — | Crop position, e.g. "70% 40%". |
| crossfadeMs | number | 600 | Fade-in once the video plays. |
| loop | boolean | true | Loop playback. |
| playWhenVisible | boolean | true | Play only while on-screen. |
| rootMargin | string | '200px' | IntersectionObserver margin. |
| respectReducedMotion | boolean | true | Skip video for reduced-motion users. |
| onReady | () => void | — | Fires when the video is playing + revealed. |
| children | ReactNode | — | Overlay content (gradients, headings, CTAs). |
| videoProps | VideoHTMLAttributes | — | Passthrough to the <video>. |
The wrapper is position: relative; overflow: hidden; the poster and video are
absolutely positioned to fill it. You control the size via style/className.
Encoding the loop (the other half people get wrong)
A good hero loop is muted, web-optimized, and small. Strip the audio track, cap the size, and move the moov atom up front for fast start. ffmpeg:
# MP4 (H.264) — broad support, audio stripped, fast-start
ffmpeg -i source.mov -an -vf "scale=1600:-2" -c:v libx264 -profile:v high \
-crf 24 -preset slow -movflags +faststart hero.mp4
# WebM (VP9) — smaller; offer it first, fall back to MP4
ffmpeg -i source.mov -an -vf "scale=1600:-2" -c:v libvpx-vp9 \
-crf 33 -b:v 0 hero.webm
# Poster frame (grab a representative still ~1s in)
ffmpeg -i source.mov -ss 00:00:01 -frames:v 1 -q:v 3 hero-poster.jpgTips: keep loops short (a few seconds) and pick subjects where motion is the point — water, fire, steam, drifting clouds. A frozen frame of those reads as inert; almost everything else is better served by a plain image.
License
MIT © Hottub, Inc.
