A video background website looks great in a design mockup and terrible in a Lighthouse report, at least when it is built the lazy way. Drop a 40 MB MP4 into a hero section, hit publish, and you have just traded 1.5 seconds of Largest Contentful Paint for a bit of motion that most mobile users will never see.
It does not have to work that way. This is a practical, performance-first tutorial: the exact HTML5 markup, the encoding commands, the poster image strategy, the mobile fallback, the Express server headers, and the measurements that prove the whole thing did not hurt your Core Web Vitals. We will also cover the part most tutorials skip: when a looping video is the wrong tool and a CSS animation or an animated image is smarter. Dynamic Websites: 5 Excellent Examples of Video Backgrounds covers this in more depth.
What a video background website really costs
Before writing a line of code, put a number on the decision. A hero video is a bandwidth purchase, and you should know the price.
| Approach | Typical weight (10s loop) | Risk to LCP |
|---|---|---|
| Unoptimised 1080p MP4 straight from the camera or stock site | 20 MB to 60 MB | Severe |
| Properly encoded H.264 MP4 + WebM, 1280px wide, no audio | 700 KB to 2 MB | Low if deferred |
| Animated GIF | 5 MB to 20 MB | Severe (blocking image decode) |
| CSS or SVG animation | 2 KB to 30 KB | None |
Rule of thumb: the video file should never be the first thing the browser downloads, and it should never exceed roughly 2 MB for a desktop hero. If your creative concept cannot survive that budget, change the concept, not the budget.

Step 1: Decide whether you actually need video
Most of the sites you find in a “best video background websites” gallery use footage that carries real meaning: a product in motion, a place, a process, people. If your loop is an abstract gradient, drifting particles, or slow-moving smoke, you are paying megabytes for something a few lines of CSS can fake.
| If the content is… | Use | Why |
|---|---|---|
| Real footage: product, location, people, manufacturing | HTML5 <video> loop | Only video reproduces real-world detail credibly |
| Abstract motion: gradients, blobs, grain, waves | CSS animation, canvas or animated SVG | Kilobytes instead of megabytes, resolution independent |
| Short 2 to 3 second UI demo | Muted MP4 or animated AVIF/WebP | Far smaller than GIF at the same quality |
| Anything on a page whose only job is conversion speed | Static image | Fastest paint, zero autoplay edge cases |
And one hard rule: never ship a GIF for a background. A silent MP4 or WebM is roughly ten to twenty times smaller for the same visual result, and the browser can decode it on the GPU.
Step 2: Cut and prepare the source clip
Editing decisions matter more than encoder settings. Before you touch ffmpeg:
- Keep it between 6 and 12 seconds. Longer loops rarely get watched and multiply file size linearly.
- Choose low-motion footage. Slow pans, shallow depth of field and static camera positions compress dramatically better than handheld shots with fast movement or falling confetti.
- Strip the audio track entirely. A background video must be muted to autoplay anyway, so an audio track is pure waste.
- Make the loop seamless. Cut on a matching frame, or add a short cross-dissolve between the end and the start so the jump is invisible.
- Design for the overlay. If text sits over the video, pick footage with a calm area, then darken it with a CSS gradient so contrast ratios stay accessible.
- Check the licence. Free libraries such as Pexels, Pixabay, Coverr and Mixkit are fine for commercial use, but read the terms for logos, faces and trademarks visible in the frame.

Step 3: Encode the video properly
Two files cover the whole modern browser landscape: an MP4 (H.264) for universal support and a WebM (VP9 or AV1) that is typically 25 to 50 percent smaller where supported.
Recommended output targets
| Setting | Desktop hero | Tablet / small viewport |
|---|---|---|
| Width | 1600 to 1920 px | 1280 px |
| Frame rate | 24 to 25 fps | 24 fps |
| Audio | None | None |
| File size ceiling | 2 MB | 1 MB |
| Pixel format | yuv420p | yuv420p |
ffmpeg commands you can copy
H.264 MP4, with the moov atom moved to the front so playback can start before the file finishes downloading:
ffmpeg -i source.mov -an \
-vf "scale=1600:-2,fps=25" \
-c:v libx264 -profile:v high -pix_fmt yuv420p \
-crf 27 -preset slow -g 50 \
-movflags +faststart \
hero-1600.mp4
VP9 WebM (broad support, good ratio):
ffmpeg -i source.mov -an \
-vf "scale=1600:-2,fps=25" \
-c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 \
hero-1600.webm
AV1 WebM if your toolchain has SVT-AV1 (smallest files, slower encode):
ffmpeg -i source.mov -an \
-vf "scale=1600:-2,fps=25" \
-c:v libsvtav1 -crf 40 -preset 6 -pix_fmt yuv420p \
hero-1600-av1.webm
Raise the CRF value until you see artefacts, then step back one. Because the video sits behind a dark overlay and moving text, you can usually push CRF several points higher than you would for a video the user is meant to study.
Generate the poster image from the first frame
ffmpeg -i hero-1600.mp4 -ss 00:00:00.5 -frames:v 1 poster.png
cwebp -q 72 poster.png -o hero-poster.webp
avifenc --min 24 --max 34 poster.png hero-poster.avif
Extracting the poster from the encoded video, not from the raw source, guarantees the still frame and the first video frame match. No flash, no colour shift when playback starts.
Step 4: The HTML5 markup
Every attribute below is there for a reason.
<section class="hero">
<video
class="hero__video"
poster="/media/hero-poster.avif"
muted
loop
playsinline
preload="none"
disablepictureinpicture
aria-hidden="true"
tabindex="-1">
<source data-src="/media/hero-1600.webm" type="video/webm">
<source data-src="/media/hero-1600.mp4" type="video/mp4">
</video>
<div class="hero__overlay"></div>
<div class="hero__content">
<h1>Ship your Express app faster</h1>
<p>Minimal, unopinionated, production ready.</p>
<a class="btn" href="/get-started">Get started</a>
</div>
</section>
What each attribute does
- muted: mandatory. Chrome, Safari and Firefox all block autoplay with sound.
- playsinline: prevents iOS from opening the video full screen. Without it, your background becomes a takeover on iPhone.
- loop: continuous playback, no manual restart logic.
- preload=”none”: the browser fetches nothing until you tell it to. This is the single most important performance switch on the element.
- poster: the still frame shown before and instead of playback. Serve it as AVIF or WebP.
- data-src instead of src: the sources are inert until JavaScript activates them after the page has painted.
- aria-hidden and tabindex=”-1″: decorative motion should be invisible to assistive tech and skipped by keyboard navigation.
- Note that there is no autoplay attribute: we call play() from script once conditions are safe.
Step 5: CSS for a full-bleed hero that never shifts layout
.hero {
position: relative;
min-height: 70svh;
display: grid;
place-items: center;
overflow: hidden;
background: #0b1020 url('/media/hero-poster.avif') center / cover no-repeat;
}
.hero__video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.hero__overlay {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(180deg, rgba(0,0,0,.60), rgba(0,0,0,.30));
}
.hero__content {
position: relative;
z-index: 2;
color: #fff;
text-align: center;
max-width: 60ch;
padding: 2rem;
}
/* Mobile fallback: poster only, no video download */
@media (max-width: 768px) {
.hero__video { display: none; }
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
.hero__video { display: none; }
}
Two details worth underlining. First, the poster is also set as a CSS background on the container, so there is never a blank frame between paint and playback. Second, the hero has an explicit minimum height, so nothing moves when the video element finally appears. That keeps Cumulative Layout Shift at zero. It is argued more carefully on pexels.com.

Step 6: Load the video after the page is usable
This script decides at runtime whether the visitor should get the video at all, then loads it only once the critical rendering work is finished.
const video = document.querySelector('.hero__video');
function shouldPlayBackgroundVideo() {
if (!video) return false;
const bigEnough = window.matchMedia('(min-width: 769px)').matches;
const motionOk = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const conn = navigator.connection || {};
const saveData = conn.saveData === true;
const slowNet = /(^|-)2g$/.test(conn.effectiveType || '');
return bigEnough && motionOk && !saveData && !slowNet;
}
function startBackgroundVideo() {
video.querySelectorAll('source[data-src]').forEach(function (s) {
s.src = s.dataset.src;
s.removeAttribute('data-src');
});
video.load();
const p = video.play();
if (p && p.catch) p.catch(function () { /* poster stays visible, that is fine */ });
}
if (shouldPlayBackgroundVideo()) {
window.addEventListener('load', function () {
if ('requestIdleCallback' in window) {
requestIdleCallback(startBackgroundVideo, { timeout: 2000 });
} else {
setTimeout(startBackgroundVideo, 500);
}
});
}
What this buys you:
- Phones and tablets receive a single optimised image, never the video bytes.
- Data Saver users and 2G connections are excluded automatically.
- The video competes with nothing during the critical path, so it cannot delay LCP.
- If autoplay is refused, for example on iOS Low Power Mode, the promise rejection is caught and the poster simply stays on screen. No broken hero, no console noise.
Pause the video when it is off screen
A looping video that keeps decoding while the user reads your pricing table drains battery for nothing.
const io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.play().catch(function () {});
} else {
entry.target.pause();
}
});
}, { threshold: 0.1 });
if (video) io.observe(video);
Step 7: Serve the files correctly from Express
Delivery settings can undo perfect encoding. Video needs byte-range support so the browser can start playing early and seek without refetching, plus long-lived immutable caching and no compression middleware wasting CPU on already-compressed bytes.
const path = require('path');
const express = require('express');
const compression = require('compression');
const app = express();
// Do not gzip or brotli media files
app.use(compression({
filter: function (req, res) {
const type = res.getHeader('Content-Type') || '';
if (/^(video|image|audio)\//.test(type)) return false;
return compression.filter(req, res);
}
}));
app.use('/media', express.static(path.join(__dirname, 'public', 'media'), {
maxAge: '1y',
immutable: true,
acceptRanges: true, // enables HTTP 206 partial responses
etag: true,
setHeaders: function (res, filePath) {
if (filePath.endsWith('.mp4')) res.setHeader('Content-Type', 'video/mp4');
if (filePath.endsWith('.webm')) res.setHeader('Content-Type', 'video/webm');
}
}));
app.listen(3000);
Verify it works with a quick range request:
curl -I -H "Range: bytes=0-1023" https://example.com/media/hero-1600.mp4
# Expect: HTTP/1.1 206 Partial Content and Accept-Ranges: bytes
Because the filenames are cached for a year with immutable, use a content hash in the name (hero-1600.a91f3c.mp4) so a new edit is a new URL. And put a CDN in front of the origin: video benefits more from edge proximity than almost any other asset.
Step 8: Measure the LCP impact, do not guess it
Chrome treats a video poster image, and in recent versions the first painted video frame, as a valid Largest Contentful Paint candidate. On a full-screen hero, that element almost certainly is your LCP. So the poster must be small, prioritised and fast.
Preload it in the document head:
<link rel="preload" as="image" href="/media/hero-poster.avif"
type="image/avif" fetchpriority="high">
Confirm which element Google is timing
import { onLCP, onCLS, onINP } from 'web-vitals';
onLCP(function (metric) {
const el = metric.entries[metric.entries.length - 1];
console.log('LCP', Math.round(metric.value), el && el.element);
});
A repeatable test routine
- Run Lighthouse in Chrome DevTools with Mobile and Slow 4G throttling, three times, and take the median.
- Record the baseline before adding the video, then re-run after. The delta is the number that matters.
- In the Network panel, filter by Media and confirm zero video bytes are transferred before the load event on desktop, and zero at any point on a mobile viewport.
- Check PageSpeed Insights for the field data section once real traffic arrives, since lab scores can flatter a video hero.
- Test on a real iPhone in Low Power Mode and a mid-range Android, not only in the simulator.
Targets to hold yourself to
| Metric | Target |
|---|---|
| LCP (mobile, field data) | Under 2.5 s |
| Poster image weight | Under 80 KB |
| Cumulative Layout Shift | Under 0.1, ideally 0 |
| Bytes before the load event | No video bytes at all |
| LCP delta versus static hero | Under 100 ms |

Accessibility and UX rules that keep you out of trouble
- Honour prefers-reduced-motion. Vestibular disorders are real and the media query costs you two lines.
- Keep contrast above 4.5:1 between hero text and the brightest frame of the loop, not the darkest.
- Offer a pause control if the loop runs longer than five seconds and contains strong movement. WCAG 2.2 asks for a mechanism to stop moving content.
- No flashing content. Anything above three flashes per second is a seizure risk.
- Mark it decorative with aria-hidden. If the footage carries information, that information must also exist in text.
Pre-launch checklist
- Video is under 2 MB and shorter than 12 seconds
- Audio track removed entirely
- MP4 encoded with
-movflags +faststart - WebM alternative served first in the source order
- Poster exported from the encoded file, delivered as AVIF or WebP, and preloaded
- muted, loop, playsinline and preload=”none” all present
- Mobile viewports get the poster only
- prefers-reduced-motion and Save-Data respected
- play() rejection caught in a try or catch
- IntersectionObserver pauses playback off screen
- Server returns 206 for range requests and caches with immutable headers
- Lighthouse re-run and LCP delta documented
FAQ
How do I get a background video from a website?
Open DevTools, go to the Network panel, filter by Media, reload the page and you will see the MP4 or WebM request with its full URL. That is useful for inspecting how a competitor encoded their loop, checking bitrate and file size. Reusing someone else’s footage on your own site is a copyright issue, so download from a licensed library instead.
Where can I find free background videos?
Pexels, Pixabay, Coverr and Mixkit all offer royalty-free clips suitable for commercial projects. Always re-encode what you download: stock files are usually delivered at 4K and 60 fps, which is ten to thirty times heavier than a web hero needs. Originally covered on https://fireart.studio.
What is the best format for a website background video?
MP4 with H.264 for universal compatibility, plus WebM with VP9 or AV1 listed first so capable browsers pick the smaller file. Skip HLS and DASH for short decorative loops: the manifest overhead is not worth it below roughly 30 seconds.
Do video backgrounds hurt SEO?
The video itself is not a ranking signal, but the page experience it produces is. A hero video that pushes LCP past 2.5 seconds on mobile degrades Core Web Vitals, and slow pages lose conversions regardless of ranking. Implemented with a poster-first, deferred-load approach as described above, the SEO impact is effectively neutral.
Why does my background video not autoplay on iPhone?
Three usual causes: the muted attribute is missing, the playsinline attribute is missing, or the device is in Low Power Mode, which blocks autoplay entirely. You cannot override the third one, which is exactly why a good poster image is not optional.
Should I use a GIF instead?
No. A GIF of the same clip is typically ten to twenty times larger, capped at 256 colours, decoded on the CPU, and impossible to pause. If you need a very short motion element, use a muted MP4 or an animated AVIF or WebP.
Can I use a YouTube or Vimeo embed as a background?
You can, but it loads several hundred kilobytes of third-party JavaScript, adds extra connections, brings tracking and cookie-consent complications, and gives you no control over the player’s loading behaviour. A self-hosted file behind your own CDN is faster and simpler in almost every case.
How long should the loop be?
Between 6 and 12 seconds. Shorter than 6 and the repetition becomes obvious, longer than 12 and you are paying for footage that visitors will have scrolled past.
Wrapping up
A video background website only becomes a performance problem when the video is treated as a page requirement rather than an enhancement. Ship the poster first, load the video last, exclude mobile and reduced-motion users, serve the bytes from an origin that supports range requests, and measure the LCP delta before and after. Do that and you get the visual impact of the galleries everyone bookmarks, with the Core Web Vitals scores they usually do not have.
