Skip to content
60fps/ui
@60fps/ui-videov0.0.5

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/ui-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/ui-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.

Everything VideoEngine accepts is forwarded, so a lazy background clip is { sources, decorative: true } — silent, inline, hidden from assistive tech, autoplayed once in view, and reloaded when a media condition hands the win to another <source>. See decorative videos. Give the poster an empty alt in that case: the preset hides the video from assistive tech, so the accessible name it would inherit from the image goes unused — and a decorative pair should be silent to a screen reader anyway.

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:

OptionTypeDefaultDescription
sourcesstring | VideoSource[]Required. A single src, or { src, type?, media? }[] mapped to <source> elements.
preloadMarginstring'200px'Root margin for the observer that creates/preloads the video ahead of the viewport.
fadeDurationnumber300Fade-in duration (ms) once the video can play, played with the Web Animations API.
destroyWhenFarbooleanfalseDestroy the video when it scrolls far away and recreate it on approach, to free the decoder.
mutedbooleantrueRequired for autoplay; mirrored onto the <video>.
loopbooleanfalseMirrored onto the <video>.
controlsbooleanfalseMirrored onto the <video>.
playsInlinebooleantrueMirrored onto the <video>.
videoAttrsRecord<string, string | number | boolean>Extra attributes for the <video> (e.g. crossorigin, preload).
videoClassstringClasses 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:

EventTypeDefaultDescription
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).