Skip to content
60fps/ui

Poster Video

PosterVideoEngine is the Apple-homepage trick: ship only a lightweight poster <img>, and create the real <video> lazily as the poster nears the viewport. The video is overlaid on the poster, faded in once it can play, and then handed to a composed VideoEngine for autoplay and off-screen pausing. Until you scroll near it, there’s no <video> element, no decoder, no bytes.

Scroll the poster into view ↓
Poster onlyLoadingPaused
↑ scroll down to the poster
Big Buck Bunny trailer

No <video> exists in the DOM until the poster nears the viewport.

↓ scroll up to the poster
How it works

The page ships with only the poster <img>. As it approaches the viewport, PosterVideoEngine creates a <video>, overlays it on the poster, and fades it in once it can play — then a composed VideoEngine autoplays it and pauses it off-screen. The poster takes its alt along.

Scroll the poster into view ↓
Poster only Loading Paused
↑ scroll down to the poster
Big Buck Bunny trailer

No <video> exists in the DOM until the poster nears the viewport.

↓ scroll up to the poster
How it works

The page ships with only the poster <img>. As it approaches the viewport, PosterVideoEngine creates a <video>, overlays it on the poster, and fades it in once it can play — then a composed VideoEngine autoplays it and pauses it off-screen. The poster takes its alt along.

Scroll the poster into the panel — the <video> is created on approach, fades in, and starts playing.

Usage

Point the hook at a poster <img> inside a positioned wrapper. Pass the video sources; the engine does the rest.

import { useRef } from 'react';
import { usePosterVideo } from '@60fps/react/poster-video-engine';

function Hero() {
	const ref = useRef<HTMLImageElement>(null);
	usePosterVideo(ref, { sources: '/hero.mp4', autoPlay: true, loop: true });

	return (
		<div className="relative">
			<img ref={ref} src="/hero-poster.jpg" alt="Product hero" className="w-full object-cover" />
		</div>
	);
}
<script setup lang="ts">
import { ref } from 'vue';
import { usePosterVideo } from '@60fps/vue/poster-video-engine';

const poster = ref<HTMLImageElement | null>(null);
usePosterVideo(poster, { sources: '/hero.mp4', autoPlay: true, loop: true });
</script>

<template>
	<div class="relative">
		<img ref="poster" src="/hero-poster.jpg" alt="Product hero" class="w-full object-cover" />
	</div>
</template>

Responsive sources

A string is shorthand for a single src. Pass an array to emit <source> elements and let the browser choose by media / type (resolution per breakpoint, codec fallback):

usePosterVideo(ref, {
	sources: [
		{ src: '/hero-480.mp4', type: 'video/mp4', media: '(max-width: 600px)' },
		{ src: '/hero-1080.webm', type: 'video/webm' },
		{ src: '/hero-1080.mp4', type: 'video/mp4' }
	]
});

How it works

  1. A first IntersectionObserver watches the poster with a generous preloadMargin (200px by default), so the clip is created and preloaded before it’s on screen.
  2. On approach the engine creates the <video>, mirrors the poster’s object-fit / border-radius, and overlays it at opacity: 0 over the poster <img>, which stays behind as the placeholder (so the <video> needs no poster of its own).
  3. As soon as the video has a renderable frame (loadeddata / canplay / playing, whichever fires first) it fades it in and emits readycanplay alone is unreliable, so it isn’t trusted on its own.
  4. A composed VideoEngine takes over playback — pause off-screen and the prefers-reduced-motion policy, plus autoplay when in view if you pass autoPlay. Reach it via engine.video.

usePosterVideo(ref, options)

Extends every VideoEngine option — forwarded as-is to the composed engine. autoPlay is not defaulted here: the engine just lazily loads the video, so opt into playback yourself. Plus:

Option Type Default Description
sources string | VideoSource[] Required. A single src, or { src, type?, media? }[] mapped to <source> elements.
preloadMargin string '200px' Root margin for the observer that creates/preloads the video ahead of the viewport.
fadeDuration number 300 Fade-in duration (ms) once the video can play.
destroyWhenFar boolean false Destroy the video when it scrolls far away and recreate it on approach, to free the decoder.
muted boolean true Required for autoplay; mirrored onto the <video>.
loop boolean false Mirrored onto the <video>.
controls boolean false Mirrored onto the <video>.
playsInline boolean true Mirrored onto the <video>.
videoAttrs Record<string, string | number | boolean> Extra attributes for the <video> (e.g. crossorigin, preload).
videoClass string Classes for the <video>, on top of the mirrored object-fit / border-radius.

Engine events

The hook returns the engine — an emitter. Subscribe with engine.on(event, cb); reach the playback engine via engine.video:

Event Type Default Description
mount (video) The <video> was created and inserted over the poster.
ready (video) The video has a frame to show and has faded in.
unmount () The video was destroyed (only with destroyWhenFar).