Smart Background Video
Lazy loading, the right file for every screen and autoplay that works on iPhone — in 2.2 KB of vanilla JS.
This block is the snippet.
Scroll it out of view — it pauses. Come back — it carries on. The button in the corner is part of it too.
Six things it handles for you.
Background video is where page speed goes to die. Not here.
-
Loads when it's needed
Nothing downloads until the block is 200px from the screen. A video at the bottom of the page costs nothing to people who never get there.
-
The right file per screen
Landscape for desktop, portrait for phones. Rotate the phone or resize the window and it swaps the file — only if it's actually different.
-
Autoplay on iPhone and Mac
Muted and inline are set before the source loads — exactly what iOS checks. If Safari still refuses, the same MP4 keeps moving as a background image.
-
Pauses when nobody's looking
Off-screen or in a background tab, the video stops decoding. Laptops stay cool, phones keep their battery.
-
Respects the visitor
Reduced motion: it waits for a tap. Data saver: poster only. Low Power Mode on iPhone: the video keeps moving as an image, and the first tap switches to the real player.
-
A pause button, built in
WCAG 2.2.2 asks for a way to stop anything that moves for longer than five seconds. A keyboard-reachable button with a proper label.
The Safari trick, only where it pays off
Safari can play an MP4 used as a background-image — and image “video” ignores autoplay blocks, so it keeps moving in Low Power Mode or with “Never Auto-Play” on a Mac. Version 1 always layered that image over the video. Every other browser downloads the file again for it and can’t play it: in our test Chrome pulled 544 KB of MP4 on top of the 189 KB WebM it was actually showing. Version 2 keeps the trick for Safari and switches it on only after Safari refuses to play the video. Everyone else downloads the file once.
Set it up in three steps.
Works in WordPress, Webflow, Builderius or a plain HTML page. Nothing to install.
-
Add the CSS
Layers the poster, the video and the pause button. The parent block needs
position: relative.CSS /* Smart Background Video v2 — styles ──────────────────────────────────────────────────────────── Where to change what: · placeholder colour before load ... .lazy-video-bg { background-color } · how the video is cropped ......... .lazy-video-bg__video { object-fit / object-position } · fade-in of the video ............. transition on .lazy-video-bg__video · pause button position/size/colour .lazy-video-bg__toggle The parent block needs position: relative (isolation: isolate is even better), so the z-index: -1 layer sits under its content, not the page. */ .lazy-video-bg { position: absolute; inset: 0; z-index: -1; overflow: hidden; background-color: #0d1109; background-position: center; background-size: cover; background-repeat: no-repeat; } .lazy-video-bg__video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.6s ease; pointer-events: none; } /* The video fades in only once frames are actually playing — no black flash. */ .lazy-video-bg[data-bgv-state="playing"] .lazy-video-bg__video, .lazy-video-bg[data-bgv-state="paused"] .lazy-video-bg__video { opacity: 1; } .lazy-video-bg__toggle { position: absolute; right: 1rem; bottom: 1rem; display: grid; place-items: center; width: 2.75rem; height: 2.75rem; padding: 0; border: 0; border-radius: 50%; background-color: rgba(13, 17, 9, 0.55); color: #fff; cursor: pointer; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); transition: background-color 0.2s ease, opacity 0.2s ease; } .lazy-video-bg__toggle:hover { background-color: rgba(13, 17, 9, 0.8); } .lazy-video-bg__toggle:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } /* One pseudo-element draws the icon: two bars for pause, a triangle for play. */ .lazy-video-bg__toggle::before { content: ""; width: 0.875rem; height: 0.875rem; background-color: currentColor; clip-path: polygon(0 0, 35% 0, 35% 100%, 0 100%, 0 0, 65% 0, 100% 0, 100% 100%, 65% 100%, 65% 0); } .lazy-video-bg__toggle[data-bgv-playing="false"]::before { clip-path: polygon(15% 0, 100% 50%, 15% 100%); } /* Nothing to pause — no button. */ .lazy-video-bg[data-bgv-state="error"] .lazy-video-bg__toggle, .lazy-video-bg[data-bgv-state="static"] .lazy-video-bg__toggle { display: none; } @media (prefers-reduced-motion: reduce) { .lazy-video-bg__video, .lazy-video-bg__toggle { transition-duration: 0.01ms !important; } } -
Drop in the HTML
Only the MP4 is required. Leave out the
-mobattributes and phones get the desktop files.HTML <!-- The parent needs position: relative --> <section class="hero"> <div class="lazy-video-bg" data-poster-desk="/video/hero-poster.avif" data-webm-desk="/video/hero.webm" data-mp4-desk="/video/hero.mp4"></div> <h1>Your content sits on top</h1> </section>HTML <!-- Desktop (wider than 720px): landscape files. Phones (720px and below): portrait files. --> <div class="lazy-video-bg" data-poster-desk="/video/desktop-poster.avif" data-webm-desk="/video/desktop.webm" data-mp4-desk="/video/desktop.mp4" data-poster-mob="/video/mobile-poster.avif" data-webm-mob="/video/mobile.webm" data-mp4-mob="/video/mobile.mp4"></div>Copy a name
-
Add the script once
Before
</body>or in your global JS. It finds every block by itself — including ones added later withBgVideo.init().JavaScript ((t,e)=>{"use strict";const s={breakpoint:720,rootMargin:"200px",controls:!0,saveData:!0,safariFallback:!0,labelPause:"Pause background video",labelPlay:"Play background video"},i=new WeakMap,a=t.matchMedia("(prefers-reduced-motion: reduce)"),r=new Map,o=new IntersectionObserver(t=>{for(const e of t)i.get(e.target)?._setVisible(e.isIntersecting)}),n=/^Apple/.test(navigator.vendor??""),l=["pointerup","touchend","keydown"];class h{#t;#e;#s;#i=null;#a=null;#r="";#o="";#n;#l=!1;#h=!1;#d=!1;#c=!1;#u=!1;#b=!1;#v=!1;#p;#g;#y;#m;constructor(n,l={}){if(!n)throw new Error("BgVideo: root element not passed.");const h=i.get(n);if(h)return h;this.#t=n,this.#e={...s,...this.#k(n),...l},this.#n=n.getAttribute("style"),this.#s=t.matchMedia(`(max-width: ${this.#e.breakpoint}px)`),i.set(n,this),this.#p=()=>this.#f(),this.#g=()=>this.#S(),this.#y=()=>this.#P(),this.#m=t=>this.#A(t),this.#s.addEventListener("change",this.#p),a.addEventListener("change",this.#g),e.addEventListener("visibilitychange",this.#g),n.addEventListener("click",this.#m),this.#E(),this.#e.saveData&&!0===navigator.connection?.saveData?this.#M("static"):(this.#M("idle"),(t=>{let e=r.get(t);return e||(e=new IntersectionObserver(t=>{for(const s of t)s.isIntersecting&&(e.unobserve(s.target),i.get(s.target)?._load())},{rootMargin:t}),r.set(t,e)),e})(this.#e.rootMargin).observe(n),o.observe(n))}get state(){return this.#o}get video(){return this.#i}get source(){return this.#r}play(){this.#c=!1,this.#h=!1,this.#d=!0,this.#i||this._load(),this.#S()}pause(){this.#h=!0,this.#d=!1,this.#L(!1),this.#S()}destroy(){r.get(this.#e.rootMargin)?.unobserve(this.#t),o.unobserve(this.#t),this.#s.removeEventListener("change",this.#p),a.removeEventListener("change",this.#g),e.removeEventListener("visibilitychange",this.#g),this.#t.removeEventListener("click",this.#m),this.#w(),this.#i&&(this.#i.pause(),this.#i.querySelectorAll("source").forEach(t=>t.remove()),this.#i.load(),this.#i.remove()),this.#a?.remove(),null===this.#n?this.#t.removeAttribute("style"):this.#t.setAttribute("style",this.#n),this.#t.removeAttribute("data-bgv-state"),this.#t.removeAttribute("data-bgv-source"),this.#t.removeAttribute("data-bgv-fallback"),i.delete(this.#t)}static get(t){return i.get(t)??null}static init(t){return Array.from(e.querySelectorAll(".lazy-video-bg, [data-bg-video]"),e=>new h(e,t))}_load(){if(this.#i||"static"===this.#o)return;if(!this.#G("mp4"))return void this.#M("error");const t=e.createElement("video");t.muted=!0,t.defaultMuted=!0,t.playsInline=!0,t.loop=!0,t.preload="metadata",t.disablePictureInPicture=!0,t.setAttribute("muted",""),t.setAttribute("playsinline",""),t.setAttribute("aria-hidden","true"),t.setAttribute("tabindex","-1"),t.className="lazy-video-bg__video",t.addEventListener("playing",()=>{this.#c=!1,this.#L(!1),this.#u=!0,this.#M("playing")}),t.addEventListener("pause",()=>this.#S()),t.addEventListener("error",()=>this.#M("error"),!0),this.#i=t,this.#B(),this.#t.prepend(t),this.#e.controls&&this.#C(),this.#S()}_setVisible(t){this.#l=t,this.#S()}#S(){const t=this.#i;if(!t||"error"===this.#o)return;a.matches&&!this.#d&&this.#L(!1);const s=!this.#h&&!e.hidden&&this.#l&&(!a.matches||this.#d);s&&t.paused&&!this.#c?(this.#M("loading"),t.play().catch(t=>{"AbortError"!==t?.name&&(this.#c=!0,this.#M("blocked"),this.#e.safariFallback&&n&&!a.matches&&this.#L(!0),this.#D())})):s||t.paused?s||this.#c||this.#M(this.#u||this.#h?"paused":"ready"):t.pause(),this.#q()}#D(){this.#b||(this.#b=!0,l.forEach(t=>e.addEventListener(t,this.#y,{capture:!0,passive:!0})))}#w(){this.#b&&(this.#b=!1,l.forEach(t=>e.removeEventListener(t,this.#y,{capture:!0})))}#P(){this.#w(),this.#c=!1,this.#S()}#G(t){const e=this.#t.dataset,s=e[`${t}Desk`]||e[t]||"";return this.#s.matches&&e[`${t}Mob`]||s}#B(){const t=this.#i;t.querySelectorAll("source").forEach(t=>t.remove());const s=(s,i)=>{if(!s)return;const a=e.createElement("source");a.src=s,a.type=i,t.append(a)};s(this.#G("webm"),"video/webm"),s(this.#G("mp4"),"video/mp4"),this.#r=this.#G("mp4");const i=this.#G("poster");i&&(t.poster=i),this.#I("data-bgv-source",this.#s.matches&&this.#t.dataset.mp4Mob?"mobile":"desktop")}#f(){this.#E(),this.#i&&this.#G("mp4")!==this.#r&&(this.#B(),this.#i.load(),this.#S())}#E(){const t=t=>`url("${t.replaceAll('"',"%22")}")`,e=[];this.#v&&this.#G("mp4")&&e.push(t(this.#G("mp4"))),this.#G("poster")&&e.push(t(this.#G("poster"))),e.length&&(this.#t.style.backgroundImage=e.join(", "))}#L(t){this.#v!==t&&(this.#v=t,this.#E(),t?this.#I("data-bgv-fallback","image"):this.#t.removeAttribute("data-bgv-fallback"),this.#q())}#C(){const t=e.createElement("button");t.type="button",t.className="lazy-video-bg__toggle",t.setAttribute("data-bgv-toggle",""),this.#a=t,this.#t.append(t)}#q(){const t=this.#a;if(!t)return;const e="playing"===this.#o||"loading"===this.#o||this.#v,s=e?this.#e.labelPause:this.#e.labelPlay;t.getAttribute("aria-label")!==s&&t.setAttribute("aria-label",s),t.dataset.bgvPlaying!==String(e)&&(t.dataset.bgvPlaying=String(e))}#A(t){if(t.defaultPrevented||!t.target.closest?.("[data-bgv-toggle]"))return;const e=this.#a;"true"===e?.dataset.bgvPlaying?this.pause():this.play()}#M(t){t!==this.#o&&(this.#o=t,this.#I("data-bgv-state",t),this.#q(),this.#t.dispatchEvent(new CustomEvent("bgvideo:state",{bubbles:!0,detail:{state:t,instance:this}})))}#I(t,e){this.#t.getAttribute(t)!==e&&this.#t.setAttribute(t,e)}#k(t){const e=t.dataset,s={};if(void 0!==e.bgvBreakpoint){const t=parseFloat(e.bgvBreakpoint);Number.isNaN(t)||(s.breakpoint=Math.max(0,t))}return e.bgvMargin&&(s.rootMargin=e.bgvMargin),void 0!==e.bgvControls&&(s.controls="false"!==e.bgvControls),void 0!==e.bgvSaveData&&(s.saveData="false"!==e.bgvSaveData),void 0!==e.bgvSafariFallback&&(s.safariFallback="false"!==e.bgvSafariFallback),e.bgvLabelPause&&(s.labelPause=e.bgvLabelPause),e.bgvLabelPlay&&(s.labelPlay=e.bgvLabelPlay),s}}"loading"===e.readyState?e.addEventListener("DOMContentLoaded",()=>h.init(),{once:!0}):h.init(),t.BgVideo=h})(window,document);JavaScript /*! Smart Background Video v2.1 | leggo.dev/tools/background-video/ | MIT */ ((window, document) => { "use strict"; const SELECTOR = ".lazy-video-bg, [data-bg-video]"; const DEFAULTS = { breakpoint: 720, // at or below this width the *-mob files are used rootMargin: "200px", // how far ahead of the viewport to start loading controls: true, // pause button: WCAG 2.2.2 for looping video longer than 5 s saveData: true, // with data saver on, show the poster only safariFallback: true, // Safari: animate the MP4 as a background image when autoplay is blocked labelPause: "Pause background video", labelPlay: "Play background video", }; const instances = new WeakMap(); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); // Observers are shared by all instances: one "time to load" observer per // rootMargin (blocks may use different ones) and one "is it on screen". const nearObservers = new Map(); const nearObserver = (margin) => { let io = nearObservers.get(margin); if (!io) { io = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; io.unobserve(e.target); instances.get(e.target)?._load(); } }, { rootMargin: margin }); nearObservers.set(margin, io); } return io; }; const viewObserver = new IntersectionObserver((entries) => { for (const e of entries) instances.get(e.target)?._setVisible(e.isIntersecting); }); const saveDataOn = () => navigator.connection?.saveData === true; // Safari (and every browser on iOS) can play an MP4 used as an image. // Image "video" is not subject to autoplay rules, so it works where <video> is blocked. const appleWebKit = /^Apple/.test(navigator.vendor ?? ""); // Only events that count as user activation in browsers. const GESTURES = ["pointerup", "touchend", "keydown"]; class BgVideo { #root; #opts; #mql; #video = null; #button = null; #src = ""; #state = ""; #rootStyle; #visible = false; #userPaused = false; // a pause from the visitor beats any automation #userPlayed = false; // explicit play — required to start under reduced motion #blocked = false; // play() was rejected: iOS Low Power Mode etc. #hasPlayed = false; #gestureArmed = false; #still = false; // MP4 shown as background image (Safari fallback) #onMedia; #onSync; #onGesture; #onClick; constructor(root, options = {}) { if (!root) throw new Error("BgVideo: root element not passed."); const existing = instances.get(root); if (existing) return existing; this.#root = root; this.#opts = { ...DEFAULTS, ...this.#readDataset(root), ...options }; this.#rootStyle = root.getAttribute("style"); this.#mql = window.matchMedia(`(max-width: ${this.#opts.breakpoint}px)`); instances.set(root, this); this.#onMedia = () => this.#swapSourceIfNeeded(); this.#onSync = () => this.#sync(); this.#onGesture = () => this.#retryAfterGesture(); this.#onClick = (e) => this.#handleClick(e); this.#mql.addEventListener("change", this.#onMedia); reduceMotion.addEventListener("change", this.#onSync); document.addEventListener("visibilitychange", this.#onSync); root.addEventListener("click", this.#onClick); this.#applyPoster(); if (this.#opts.saveData && saveDataOn()) { this.#setState("static"); // poster is already in place; the video is never downloaded return; } this.#setState("idle"); nearObserver(this.#opts.rootMargin).observe(root); viewObserver.observe(root); } get state() { return this.#state; } get video() { return this.#video; } get source() { return this.#src; } play() { this.#blocked = false; // called from a click = a user gesture, so try again this.#userPaused = false; this.#userPlayed = true; if (!this.#video) this._load(); this.#sync(); } pause() { this.#userPaused = true; this.#userPlayed = false; this.#setStill(false); // the fallback image moves too, so pausing stops it this.#sync(); } destroy() { nearObservers.get(this.#opts.rootMargin)?.unobserve(this.#root); viewObserver.unobserve(this.#root); this.#mql.removeEventListener("change", this.#onMedia); reduceMotion.removeEventListener("change", this.#onSync); document.removeEventListener("visibilitychange", this.#onSync); this.#root.removeEventListener("click", this.#onClick); this.#disarmGestureRetry(); if (this.#video) { this.#video.pause(); this.#video.querySelectorAll("source").forEach((s) => s.remove()); this.#video.load(); // aborts the download, otherwise the browser keeps fetching this.#video.remove(); } this.#button?.remove(); // Put the root back exactly as it was. if (this.#rootStyle === null) this.#root.removeAttribute("style"); else this.#root.setAttribute("style", this.#rootStyle); this.#root.removeAttribute("data-bgv-state"); this.#root.removeAttribute("data-bgv-source"); this.#root.removeAttribute("data-bgv-fallback"); instances.delete(this.#root); } static get(root) { return instances.get(root) ?? null; } static init(options) { return Array.from(document.querySelectorAll(SELECTOR), (el) => new BgVideo(el, options)); } /* ── Called by the observers ───────────────────────────────── */ _load() { if (this.#video || this.#state === "static") return; if (!this.#pick("mp4")) { this.#setState("error"); return; } const v = document.createElement("video"); // muted and playsInline as properties, before any source is attached: // this is exactly what iOS checks before allowing inline autoplay. // The attributes are for older Safari. v.muted = true; v.defaultMuted = true; v.playsInline = true; v.loop = true; v.preload = "metadata"; v.disablePictureInPicture = true; v.setAttribute("muted", ""); v.setAttribute("playsinline", ""); v.setAttribute("aria-hidden", "true"); v.setAttribute("tabindex", "-1"); v.className = "lazy-video-bg__video"; v.addEventListener("playing", () => { this.#blocked = false; this.#setStill(false); this.#hasPlayed = true; this.#setState("playing"); }); v.addEventListener("pause", () => this.#sync()); v.addEventListener("error", () => this.#setState("error"), true); // capture: errors on <source> do not bubble this.#video = v; this.#fillSources(); this.#root.prepend(v); if (this.#opts.controls) this.#addButton(); this.#sync(); } _setVisible(isVisible) { this.#visible = isVisible; this.#sync(); } /* ── Logic ─────────────────────────────────────────────────── */ // The single place that decides "play or stay paused". #sync() { const v = this.#video; if (!v || this.#state === "error") return; if (reduceMotion.matches && !this.#userPlayed) this.#setStill(false); const shouldPlay = !this.#userPaused && !document.hidden && this.#visible && (!reduceMotion.matches || this.#userPlayed); if (shouldPlay && v.paused && !this.#blocked) { this.#setState("loading"); v.play().catch((err) => { if (err?.name === "AbortError") return; // play() interrupted by our own pause() this.#blocked = true; this.#setState("blocked"); // Safari refused to play the <video> (Low Power Mode, "Never Auto-Play"): // let the same MP4 run as a background image instead — only here, // so no other browser downloads the file twice. if (this.#opts.safariFallback && appleWebKit && !reduceMotion.matches) this.#setStill(true); this.#armGestureRetry(); }); } else if (!shouldPlay && !v.paused) { v.pause(); } else if (!shouldPlay && !this.#blocked) { this.#setState(this.#hasPlayed || this.#userPaused ? "paused" : "ready"); } this.#updateButton(); } // In Low Power Mode iOS rejects play() without a gesture. // The first tap or key press anywhere is a valid gesture, so retry then. #armGestureRetry() { if (this.#gestureArmed) return; this.#gestureArmed = true; GESTURES.forEach((t) => document.addEventListener(t, this.#onGesture, { capture: true, passive: true })); } #disarmGestureRetry() { if (!this.#gestureArmed) return; this.#gestureArmed = false; GESTURES.forEach((t) => document.removeEventListener(t, this.#onGesture, { capture: true })); } #retryAfterGesture() { this.#disarmGestureRetry(); this.#blocked = false; this.#sync(); } #pick(kind) { const d = this.#root.dataset; const desk = d[`${kind}Desk`] || d[kind] || ""; return this.#mql.matches ? d[`${kind}Mob`] || desk : desk; } #fillSources() { const v = this.#video; v.querySelectorAll("source").forEach((s) => s.remove()); const add = (src, type) => { if (!src) return; const s = document.createElement("source"); s.src = src; s.type = type; v.append(s); }; // WebM first: where it is supported it is usually lighter than MP4. add(this.#pick("webm"), "video/webm"); add(this.#pick("mp4"), "video/mp4"); this.#src = this.#pick("mp4"); const poster = this.#pick("poster"); if (poster) v.poster = poster; this.#attr("data-bgv-source", this.#mql.matches && this.#root.dataset.mp4Mob ? "mobile" : "desktop"); } // Phone rotated or window resized across the breakpoint: swap the file, // but only if it is actually a different one. #swapSourceIfNeeded() { this.#applyPoster(); if (!this.#video || this.#pick("mp4") === this.#src) return; this.#fillSources(); this.#video.load(); this.#sync(); } // Poster as the root background: visible at once — before, under and instead of the video. #applyPoster() { const url = (u) => `url("${u.replaceAll('"', "%22")}")`; const layers = []; if (this.#still && this.#pick("mp4")) layers.push(url(this.#pick("mp4"))); if (this.#pick("poster")) layers.push(url(this.#pick("poster"))); if (layers.length) this.#root.style.backgroundImage = layers.join(", "); } // MP4 on top, poster underneath: the poster shows until the image decodes. #setStill(on) { if (this.#still === on) return; this.#still = on; this.#applyPoster(); if (on) this.#attr("data-bgv-fallback", "image"); else this.#root.removeAttribute("data-bgv-fallback"); this.#updateButton(); } #addButton() { const b = document.createElement("button"); b.type = "button"; b.className = "lazy-video-bg__toggle"; b.setAttribute("data-bgv-toggle", ""); this.#button = b; this.#root.append(b); } #updateButton() { const b = this.#button; if (!b) return; const playing = this.#state === "playing" || this.#state === "loading" || this.#still; const label = playing ? this.#opts.labelPause : this.#opts.labelPlay; if (b.getAttribute("aria-label") !== label) b.setAttribute("aria-label", label); if (b.dataset.bgvPlaying !== String(playing)) b.dataset.bgvPlaying = String(playing); } #handleClick(e) { if (e.defaultPrevented || !e.target.closest?.("[data-bgv-toggle]")) return; const b = this.#button; if (b?.dataset.bgvPlaying === "true") this.pause(); else this.play(); } #setState(state) { if (state === this.#state) return; this.#state = state; this.#attr("data-bgv-state", state); this.#updateButton(); this.#root.dispatchEvent(new CustomEvent("bgvideo:state", { bubbles: true, detail: { state, instance: this }, })); } #attr(name, value) { if (this.#root.getAttribute(name) !== value) this.#root.setAttribute(name, value); } #readDataset(root) { const d = root.dataset; const out = {}; if (d.bgvBreakpoint !== undefined) { const n = parseFloat(d.bgvBreakpoint); if (!Number.isNaN(n)) out.breakpoint = Math.max(0, n); } if (d.bgvMargin) out.rootMargin = d.bgvMargin; if (d.bgvControls !== undefined) out.controls = d.bgvControls !== "false"; if (d.bgvSaveData !== undefined) out.saveData = d.bgvSaveData !== "false"; if (d.bgvSafariFallback !== undefined) out.safariFallback = d.bgvSafariFallback !== "false"; if (d.bgvLabelPause) out.labelPause = d.bgvLabelPause; if (d.bgvLabelPlay) out.labelPlay = d.bgvLabelPlay; return out; } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => BgVideo.init(), { once: true }); } else { BgVideo.init(); } window.BgVideo = BgVideo; })(window, document);
Options.
Everything is set with attributes on the block. The defaults are what most sites need.
| Attribute | Default | What it does |
|---|---|---|
data-mp4-desk |
required | H.264 MP4 — the one format every iPhone plays. |
data-webm-desk |
— | Tried first where supported. Usually lighter than the MP4. |
data-poster-desk |
— | Shown straight away and kept when the video can't play. |
data-*-mob |
desktop files | Phone versions of the three above. |
data-bgv-breakpoint |
720 |
Width in px at and below which the phone files are used. |
data-bgv-margin |
200px |
How early to start loading before the block reaches the screen. |
data-bgv-controls |
true |
The pause button. Set to false only if the page gives another way to stop the video. |
data-bgv-safari-fallback |
true |
When Safari blocks autoplay, run the MP4 as a background image instead. |
data-bgv-save-data |
true |
Poster only when the visitor has data saver on. |
data-bgv-label-pause, data-bgv-label-play
|
English | Button labels for screen readers — translate them for other languages. |
Styling hooks
The block reports what it's doing in data-bgv-state: idle, ready, loading, playing, paused, blocked, static or error. data-bgv-source says desktop or mobile.
From your own JS
BgVideo.get(el).play(), .pause() and .destroy(). BgVideo.init() picks up blocks added later. Every change fires a bgvideo:state event.
Before you upload the file.
The best script can't save a badly exported video. Safari checks the file itself.
-
H.264 only
Main or Baseline profile. H.265 (HEVC) often shows a black frame instead of playing.
-
No audio track
Not a silent track — no track at all. Untick “Export audio” in your editor, or use
-anbelow. The file gets smaller too. -
Fast start
The index (moov atom) must sit at the start of the file so playback can begin before it finishes loading. “Web optimized” in HandBrake.
-
Low Power Mode is handled
iOS blocks every video autoplay in it. The snippet falls back to the MP4 as an image, and the first tap switches to the real player.
# MP4 for every browser: H.264, no audio track, fast start
ffmpeg -i source.mov -c:v libx264 -profile:v main -pix_fmt yuv420p \
-crf 24 -preset slow -an -movflags +faststart hero.mp4
# Lighter WebM for Chrome, Firefox and Edge
ffmpeg -i source.mov -c:v libvpx-vp9 -b:v 0 -crf 36 -an hero.webm
# Poster from the first frame
ffmpeg -i hero.mp4 -frames:v 1 -c:v libaom-av1 -still-picture 1 hero-poster.avif