Poster-to-loop video
A silent looping video that shows a matching poster frame until it can actually play, and never blocks the page on itself.
Grade: ARuntimes: cssStatus: code-only
Documented, no live demo yet — the code below is complete.
Intent
Background video carries motion that a still cannot, but it is also the single heaviest thing on most pages and the most likely to fail. Making the poster frame the first-class state means the section is complete before the video arrives, and stays complete if it never does.
Implementation
The `<source>` carries `data-src` rather than `src`, so nothing is fetched until the element is near the viewport.
Dependencies: none
<figure class="loop">
<video
class="loop__video"
poster="/media/studio-poster.jpg"
width="1920"
height="1080"
muted
loop
playsinline
preload="metadata"
aria-hidden="true"
data-lazy-video
>
<source data-src="/media/studio-loop.mp4" type="video/mp4" />
</video>
</figure>
<style>
.loop { margin: 0; }
.loop__video {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--frame-radius, 0);
}
</style>
<script type="module">
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Under reduced motion the poster is the finished state — nothing is fetched.
if (!reduce) {
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target;
if (!entry.isIntersecting) {
video.pause();
continue;
}
const source = video.querySelector("source[data-src]");
if (source) {
source.src = source.dataset.src;
source.removeAttribute("data-src");
video.load();
}
video.play().catch(() => {
/* Autoplay refused: the poster remains, which is a valid final state. */
});
}
},
{ rootMargin: "20% 0px" },
);
for (const video of document.querySelectorAll("[data-lazy-video]")) io.observe(video);
addEventListener("visibilitychange", () => {
if (document.hidden) {
for (const video of document.querySelectorAll("[data-lazy-video]")) video.pause();
}
});
}
</script>Use it when
- Hero backgrounds, case-study process footage, product loops under ten seconds.
Avoid it when
- For anything with information in the audio, which must then be a real player with controls and captions.
- On more than one section per page — the bandwidth cost compounds.
Accessibility
- Decorative background video takes `aria-hidden` and no controls. Video that carries meaning needs controls, captions, and a transcript — at which point this pattern is the wrong one.
- Under reduced motion, do not autoplay. Show the poster and offer a play control if the video matters.
- Text over the video needs the `--veil` scrim and a contrast check against the brightest frame, not a representative one.
Performance
- `preload="metadata"` — never `auto` for a decorative loop.
- Attach `src` lazily via IntersectionObserver so off-screen videos cost nothing.
- Keep loops under about ten seconds and encode them small; the poster carries first paint.
- Pause when off-screen and on `visibilitychange` — a hidden playing video still burns decode and battery.
Knobs
- aspect
- Ratio shared by poster and video. Default: 16 / 9
- preload
- Preload hint. Default: metadata
Composition
Conflicts with
None.
Instruction for Claude Code
Add a poster-to-loop background video: a <video> with muted, loop, playsinline, preload="metadata", explicit width/height, a matching poster image, aspect-ratio and object-fit cover, and aria-hidden if decorative. Put the file on a data-src attribute of the <source> and only assign src + call load() when an IntersectionObserver with 20% rootMargin reports it near the viewport; pause when it leaves and on visibilitychange when the document hides. Catch the play() rejection and leave the poster showing — that is a valid final state. Under prefers-reduced-motion, never fetch or autoplay the video at all.