You want a Shopify Liquid section that renders a silent looping background video behind content, and it has to work on Safari, Chrome, and Firefox. By the end of this walkthrough, you'll have exactly that: a single section file, a small piece of JavaScript, and a lifecycle that loads, plays, and pauses videos based on viewport visibility. Zero third-party dependencies.
The reason this is a walkthrough and not a one-liner: each browser implements the HTML5 video autoplay spec differently, and Shopify's Liquid rendering compounds the inconsistencies. I built the pattern below while shipping four layout variants (columns, scroll, collapsible, slider) on a DTC engagement where each layout needed the option of an image or a video in every slot.
Prerequisites
- A Shopify theme on Online Store 2.0 (sections schema is required)
- Permission to add Liquid files to
sections/and JavaScript to the theme's assets - A set of MP4 source files sized appropriately for web (H.264, under 3MB each)
- A poster image per video for the fallback path
You don't need any Shopify app; you don't need a third-party video CDN. Shopify's file upload gives you hosted MP4s with a stable URL, which is enough.
Before starting, make sure you've taken a look at the sections vs blocks decision rubric. This video section is built as a section, not a block, because the merchant needs to control placement at the template level and because the video lifecycle logic needs to live in one place.
Step 1: The section skeleton
Create sections/background-video.liquid:
{%- comment -%}
sections/background-video.liquid
Plays a silent looping video on viewport enter, pauses on viewport exit.
Falls back to a static poster image if autoplay is denied.
{%- endcomment -%}
<div
class="bg-video-section"
data-bg-video-root
id="BackgroundVideo-{{ section.id }}"
>
{%- if section.settings.video -%}
<video
class="bg-video-section__video"
data-bg-video
data-src="{{ section.settings.video | default: '' }}"
poster="{{ section.settings.poster | image_url: width: 1600 }}"
muted
loop
playsinline
preload="none"
aria-label="{{ section.settings.alt | default: 'Background video' | escape }}"
></video>
{%- else -%}
<img
class="bg-video-section__fallback"
src="{{ section.settings.poster | image_url: width: 1600 }}"
alt="{{ section.settings.alt | default: '' | escape }}"
loading="lazy"
/>
{%- endif -%}
<div class="bg-video-section__content">
{%- if section.settings.heading != blank -%}
<h2 class="bg-video-section__heading">{{ section.settings.heading | escape }}</h2>
{%- endif -%}
</div>
</div>
<script src="{{ 'bg-video.js' | asset_url }}" defer></script>
{% schema %}
{
"name": "Background video",
"settings": [
{ "type": "video", "id": "video", "label": "Video source" },
{ "type": "image_picker", "id": "poster", "label": "Poster image" },
{ "type": "text", "id": "alt", "label": "Accessible label" },
{ "type": "text", "id": "heading", "label": "Overlay heading" }
],
"presets": [{ "name": "Background video" }]
}
{% endschema %}
Key details:
muted,loop,playsinlineare required for autoplay to succeed on mobile Safari and Chromepreload="none"prevents the browser from downloading the video until we explicitly tell it to (critical for the performance budget)data-srcholds the actual URL; we setsrcat load time via JavaScript, not at render time- The poster image renders as the fallback if JavaScript is off or autoplay is denied
- 01browser → videorender (preload=none)no bytes requested
- 02browser → observerobserve(video)rootMargin 200px
- 03browser → observerscroll eventcompositor thread
- 04observer → videoisIntersecting: trueenters viewport
- 05video ↻ videosrc = data-src + load()fetch begins
- 06video ↻ videoloadeddataFirefox-safe signal
- 07video ↻ videoplay()muted + playsinline
- 08browser → observerscroll pastleaving viewport
- 09observer → videoisIntersecting: falsepause() battery+
Step 2: The video element and the Safari catch
Safari has a quirk the other two browsers don't: on a page with multiple video elements, Safari will autoplay only the first one it encounters, and subsequent videos require a user gesture to start.
The fix is to keep preload="none" on every video element initially, and to set the src attribute only when the video is actually entering the viewport. Safari's autoplay policy checks for a user gesture when you call .play() on a video that has a pre-set src; it does not check if the src is being set at the moment the video enters view, which reads as a programmatic load triggered by a user-initiated scroll.
There's a subtlety: Safari also treats videos that loaded early in the page lifecycle as "pre-consented" even without a gesture, which is why the first video works. Every subsequent video has to go through the IntersectionObserver path.
Create assets/bg-video.js:
(function () {
"use strict";
const CONFIG = {
rootMargin: "200px 0px 200px 0px",
threshold: 0.1,
};
function loadAndPlay(video) {
if (!video.dataset.src) return;
if (video.src) {
// Already loaded; just play if paused
if (video.paused) video.play().catch(() => {});
return;
}
video.src = video.dataset.src;
video.load();
video.addEventListener(
"loadeddata",
function onReady() {
video.play().catch(() => {
video.classList.add("bg-video-section__video--autoplay-denied");
});
video.removeEventListener("loadeddata", onReady);
},
{ once: true }
);
}
function pauseAndRelease(video) {
if (video.src && !video.paused) {
video.pause();
}
}
function init() {
const videos = document.querySelectorAll("[data-bg-video]");
if (!videos.length) return;
const observer = new IntersectionObserver(
function handle(entries) {
entries.forEach((entry) => {
const video = entry.target;
if (entry.isIntersecting) {
loadAndPlay(video);
} else {
pauseAndRelease(video);
}
});
},
CONFIG
);
videos.forEach((video) => observer.observe(video));
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
This is the core of the pattern. Three things worth noting:
rootMargin: "200px 0px 200px 0px"starts loading videos 200px before they enter the viewport and pauses them 200px after they leave. This tuning is what keeps the experience smooth on scroll without wasting bandwidthvideo.load()is explicit after settingsrc. Without it, some browsers wait until the next natural media event to start buffering- The
play()call is wrapped in a.catch()because the promise rejects if autoplay is denied. We add a class to the video element so CSS can handle the fallback visually
Step 3: The IntersectionObserver lifecycle
The reason IntersectionObserver is the right primitive (not scroll events, not getBoundingClientRect in rAF) is that it runs on the browser's compositor thread. Scroll handlers block the main thread; IntersectionObserver doesn't. For a theme that might have six videos across four sections on the same long page, this matters for INP.
The lifecycle in sequence:
- Page loads. All videos have
preload="none"and nosrc. Zero bytes requested. - User scrolls. The video element approaches the 200px extended viewport.
- IntersectionObserver fires the callback with
isIntersecting: true. loadAndPlaysetssrc, callsload(), waits forloadeddata, then callsplay().- Video plays, loops silently.
- User scrolls past. IntersectionObserver fires with
isIntersecting: false. pauseAndReleasepauses the video. The bytes stay in browser cache; no new request is made if the user scrolls back.
Step 4: The Chrome autoplay gate
Chrome has a Media Engagement Index (MEI) that grants autoplay privileges to sites a user has visited frequently and interacted with media on. For a first-time visitor, Chrome allows autoplay only when all three conditions are met: the video is muted, it has playsinline, and the call to play() happens in response to something user-driven (page load counts, scrolling counts indirectly via observer callbacks).
The code in Step 2 satisfies all three. The specific reason it works in Chrome where naive implementations fail is the muted attribute being present in the HTML, not added via JavaScript. Chrome's autoplay check runs on HTML state, not on the video element's current property values.
If you find Chrome blocking autoplay in dev, the first thing to check is whether muted is a literal HTML attribute on the <video> tag, not something you're setting via video.muted = true at runtime. This is the single most common mistake.
Step 5: The Firefox state-lie workaround
Firefox has a different problem: its video.paused property returns true immediately after you call play(), before the video has actually started playing. If you write code that reads video.paused to decide whether to act, Firefox will lie to you.
The workaround is the loadeddata event listener in Step 2. Instead of polling video.paused, we wait for a media event that definitively means the video is ready, then call play() once. The .catch() on the promise handles the case where autoplay is denied despite our best efforts.
“Safari plays only the first video. Chrome renders thumbnails until you coax it. Firefox lies about whether the video is paused. One IntersectionObserver pattern handles all three.
”
A secondary Firefox issue: on some older builds, preload="none" is treated as a hint rather than a directive, and Firefox will still fetch the first frame for the poster. This is fine because it's small (a single frame, not the whole video), and modern Firefox respects preload="none" properly. It's worth knowing in case you're debugging an older Firefox build that's making requests you didn't expect.
Step 6: The fallback image path
When autoplay is denied, the poster image is already rendered by the <video> element. The CSS class .bg-video-section__video--autoplay-denied (added in the .catch() handler) lets you style the fallback state explicitly:
.bg-video-section__video {
width: 100%;
height: 100%;
object-fit: cover;
}
.bg-video-section__video--autoplay-denied {
/* The video will show its poster image. Optional: add a play button overlay. */
cursor: pointer;
}
You can optionally add a click handler that calls video.play() on tap, converting the fallback state into a user-initiated play. For a background video meant to be decorative, most stores I've shipped this on just let the poster stand in.
Common mistakes
Setting muted via JavaScript instead of as an HTML attribute. Chrome's autoplay gate checks HTML state. If muted isn't in the HTML, autoplay fails even if you set video.muted = true before calling play.
Using scroll events instead of IntersectionObserver. Scroll events run on the main thread and will blow your INP budget the first time someone scrolls. IntersectionObserver runs on the compositor thread and stays under the ceiling even with six videos on the page.
Forgetting playsinline. Without playsinline, mobile Safari will try to open the video fullscreen on play. This is the opposite of "background video." It's a one-attribute fix that every production theme gets right eventually, after noticing it in a browser test.
Assuming preload="none" is respected by every browser. Modern browsers do respect it. Older builds and some embedded WebViews don't. If you need guaranteed zero initial requests, you have to defer setting the src attribute via JavaScript, which the pattern above does.
Loading every video URL at render time. If you set src directly in the Liquid template, every browser will start requesting all video URLs the moment the page loads, regardless of preload. The data-src -> src move in JavaScript is what actually defers the network.
What to try next
Once the single-section pattern is working, the next question is usually how to apply it to four different layouts on the same store (a columns section, a scroll section, a collapsible section, a slider section) without duplicating the JavaScript in each. The answer is to treat bg-video.js as a shared asset that watches for any element matching [data-bg-video] anywhere on the page, which is exactly how the code above is written.
You'll also want to pair this with the PDP conversion framework if the video is playing the "anchor" role on a product page; the video has to load fast enough not to blow the LCP ceiling, which typically means a smaller source file or a first-frame poster that's aggressively optimized.
The Shopify rebuild where I first shipped this pattern is the cross-browser Shopify video engagement, where each of the four layouts carried an optional video variant and all four needed to behave the same across Safari, Chrome, and Firefox. The code above is the distilled version of what shipped on that engagement.
All of this fits inside the DTC Shopify theme architecture for brands past 2M, where video sections are one of the most common places the theme needs to be deliberate about what it loads and when. And if you're weighing whether this kind of custom theme work is worth it vs. going headless, the Hydrogen vs Liquid tradeoffs write-up walks through how the video-section problem changes shape on the Hydrogen side.
The exact section file and JavaScript above are included in the Shopify Theme Starter video module, pre-wired with the performance budget's Lighthouse CI so you can't accidentally regress any of the behaviors.
FAQ
Can I use HLS or DASH streaming instead of MP4?
Yes, but it adds complexity that most DTC background-video use cases don't need. HLS requires a polyfill (hls.js) on browsers that don't natively support it, which adds JavaScript to your budget. For short looping background videos under 3MB, plain MP4 with preload="none" is the cleaner path.
What about accessibility?
Background video should be decorative, not content-carrying. The aria-label in the Liquid template gives screen readers a hint, but the video shouldn't be the primary way a visitor gets information. If the video contains important content, transcribe or caption it separately. Also respect prefers-reduced-motion: add a media query in CSS that hides the video and shows the poster image when the user has that preference set.
How do I handle multiple videos in a slider?
Play the visible slide; pause the hidden ones. The IntersectionObserver still works, but you'll want to combine it with the slider's own visibility logic so that a slide sitting in the viewport but scrolled off-screen within the slider doesn't keep playing. Most modern slider libraries fire an event on slide change that you can hook into.
Will this break if I use Shopify's theme editor preview?
The theme editor preview runs in an iframe with its own autoplay policy. In my experience, videos play in the preview on most modern Shopify Admin builds, but don't treat the editor preview as ground truth for autoplay behavior. Test on a real storefront URL in each target browser.
Can I lazy-load the poster image too?
The poster on a <video> tag doesn't support the loading="lazy" attribute the way <img> does. The browser always fetches it eagerly because it needs something to render before the video is ready. If the poster's weight is a concern, optimize the image aggressively (WebP, subsampled, under 40KB) rather than trying to defer its load.
What if the client insists on a third-party video app for analytics?
Third-party video apps add their own JavaScript and often their own player, which will blow the performance budget. If analytics on the background video are critical (usually they're not; it's a decorative element), implement a small custom tracker that fires an event when loadeddata succeeds. This stays under 2KB of added JS.
Sources and specifics
- Pattern shipped on a DTC Shopify engagement in Q4 2025 across four layout variants (columns, scroll, collapsible, slider).
- Tested on Safari 17+, Chrome 120+, Firefox 120+, on iOS 17 and Android 14 mobile builds.
- Zero third-party dependencies. The pattern uses only native browser APIs (IntersectionObserver, HTMLVideoElement).
- The
rootMarginof 200px is tuned for standard PDPs; for hero-heavy home pages, 400px can be a better fit. - Code assumes Online Store 2.0 sections schema; the pattern works on legacy themes too but the schema block differs.
