Smart Background Video

Lazy loading, the right file for every screen and autoplay that works on iPhone — in 2.2 KB of vanilla JS.

  • Video
  • Performance
  • Vanilla JS
  • WCAG 2.2

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.

  1. 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;
        }
    }
  2. Drop in the HTML

    Only the MP4 is required. Leave out the -mob attributes 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>

    Copy a name

  3. Add the script once

    Before </body> or in your global JS. It finds every block by itself — including ones added later with BgVideo.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);

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 -an below. 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.

Terminal · ffmpeg
# 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

Video-heavy site that still has to load fast? That's the job I like.

Get a quote