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>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.
| 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.
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.
MIT © Hottub, Inc.