How to Add Interactive Video to a Shopify PDP Without Killing LCP
June 3, 2026 · chinmay
This post walks through adding an interactive video to a Shopify product detail page (PDP) while keeping Largest Contentful Paint under 2.5 seconds. You will need Shopify admin access (any plan), a product video in MP4 format, and a static poster image for the video. The steps cover Dawn and Sense themes; a Hydrogen variant is noted at the relevant steps. Budget about 25 minutes for a single product.
Before You Start
- Admin access to your Shopify store with theme edit permissions.
- A product video in MP4 format (H.264, under 1 GB). Shopify generates an HLS stream on upload automatically.
- A poster image (JPEG or WebP, 1200x630px minimum). This is the frame the browser paints before the video loads. It becomes your LCP candidate, so compress it to WebP before uploading.
- Theme file access via
Online Store → Themes → Edit code. For Hydrogen, a local dev environment with@shopify/hydrogeninstalled.
Step 1: Upload the Video in Shopify Admin
- Go to
Admin → Productsand open the target product. - In the Media section, click Add media and upload the MP4 file.
- Shopify processes the upload and generates HLS sources automatically. See the Shopify product media documentation for the full list of supported media types and Liquid rendering options.
- Upload your poster image as a separate image media item, or store it in
Admin → Content → Filesand copy its CDN URL for use in the next step.
Expected result: The product media panel shows a video thumbnail alongside your product images. The video is now accessible via product.media in Liquid.
Step 2: Replace the Default Video Embed With a Poster-First Wrapper
Dawn and Sense render video inline using the media_tag Liquid filter, which places a <video> element in the DOM on page paint. That makes the video a potential LCP element with a slow time-to-first-byte. The fix is to show the poster image immediately and load the video only when the visitor clicks play.
Open sections/main-product.liquid (Dawn) or snippets/product-media.liquid (Sense). Find the block that handles media.media_type == 'video' and replace the default media_tag output with this:
{%- when 'video' -%}
<div
class="product__media product__media--video js-defer-video"
data-video-id="{{ media.id }}"
style="aspect-ratio: 16/9; position: relative; overflow: hidden;"
>
<img
src="{{ media.preview_image | image_url: width: 1200 }}"
alt="{{ media.alt | escape }}"
width="1200"
height="675"
loading="eager"
fetchpriority="high"
class="product__media-poster"
style="width: 100%; height: 100%; object-fit: cover;"
>
<button
class="product__media-play-btn"
aria-label="Play video"
data-video-id="{{ media.id }}"
style="position: absolute; inset: 0; display: flex; align-items: center;
justify-content: center; background: transparent; border: none; cursor: pointer;"
>
<svg width="64" height="64" viewBox="0 0 64 64" aria-hidden="true">
<circle cx="32" cy="32" r="32" fill="rgba(0,0,0,0.5)"/>
<polygon points="26,20 50,32 26,44" fill="white"/>
</svg>
</button>
<template data-video-src="{{ media | video_tag: autoplay: false, loop: false, controls: true }}">
</template>
</div>
Expected result: The PDP renders the poster image at full width. The <video> element lives inside a <template> tag, which the browser does not parse or fetch until JavaScript moves it into the DOM.
Set aspect-ratio to match the video's native ratio. 16/9 is standard for most product videos; use 1/1 for square content. A mismatch causes layout shift (CLS), which also hurts Core Web Vitals.
Step 3: Defer the Video Load on Click
Create assets/product-video-defer.js and add:
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.product__media-play-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const wrapper = btn.closest('.js-defer-video');
const template = wrapper && wrapper.querySelector('template[data-video-src]');
if (!template) return;
// Move the video element out of the <template> and into the DOM
const tempDiv = document.createElement('div');
tempDiv.innerHTML = template.getAttribute('data-video-src');
const video = tempDiv.querySelector('video');
if (!video) return;
video.setAttribute('playsinline', '');
video.style.cssText = 'width:100%;height:100%;object-fit:cover;';
wrapper.replaceChildren(video);
video.play();
});
});
});
Reference the file in layout/theme.liquid just before </body>:
{{ 'product-video-defer.js' | asset_url | script_tag }}
Per the Shopify theme performance guidelines, scripts placed before </body> are not parser-blocking. Do not add async or defer to this tag; the DOM must be fully built before the click listeners attach.
Expected result: Clicking the play overlay replaces the poster image with the live <video> element. The video begins playing. No page reload occurs.
Hydrogen variant
In a Hydrogen storefront, use a React useState toggle instead of the DOM replacement:
import { useState } from 'react';
// Use the shape from your storefront's product.media Storefront API query
interface VideoMedia {
sources: Array<{ url: string; mimeType: string }>;
previewImage: { url: string; altText: string | null };
}
export function DeferredVideo({ media }: { media: VideoMedia }) {
const [active, setActive] = useState(false);
if (active) {
return (
<video
src={media.sources[0].url}
controls
autoPlay
playsInline
style={{ width: '100%', aspectRatio: '16/9', objectFit: 'cover' }}
/>
);
}
return (
<button onClick={() => setActive(true)} style={{ display: 'block', width: '100%', border: 'none', padding: 0, cursor: 'pointer' }}>
<img
src={media.previewImage.url}
alt={media.previewImage.altText ?? ''}
style={{ width: '100%', aspectRatio: '16/9', objectFit: 'cover', display: 'block' }}
/>
</button>
);
}
Reported to work on @shopify/hydrogen 2025.x and later; verify on earlier versions.
Step 4: Add Harloop's Personalization Snippet
Once the deferred wrapper is in place, replace the <template> tag content with the Harloop embed to route the right video clip per visitor. The snippet is available from your Harloop dashboard after connecting your Shopify store. It uses the same poster-first pattern, so LCP behavior is unchanged.
Visit the Harloop demo to see how traffic-source routing works across TikTok, Meta, and organic visitors before wiring your first rule.
Step 5: Verify LCP in Lighthouse
- Open the product page in Chrome Incognito (to exclude cached resources).
- Open
DevTools → Lighthouseand run an analysis on Mobile with Performance selected. - In the results, expand Diagnostics and locate the LCP element. It should identify the poster
<img>, not a<video>element. - Target: LCP under 2.5 seconds on a simulated mobile connection.
What to check: Open DevTools → Network, reload the page, and filter by Media. Confirm no video file begins loading on the initial page load. The video request should appear only after clicking the play button.
What Can Go Wrong
-
Video element appears as the LCP element in Lighthouse. The
<template>tag is not being honored. Some older Shopify theme versions render template tags as visible elements. Inspectdocument.querySelector('template[data-video-src]')in the console to confirm it exists but is not rendered. If it is rendered, wrap the snippet in a<div style="display:none">and adjust the JS selector. -
Video does not play on iOS after clicking. iOS Safari requires the
playsinlineattribute on the<video>element, or it opens in a separate fullscreen player. The JavaScript above addsplaysinlineprogrammatically. If you pregenerate the video tag in Liquid, addplaysinline: trueto thevideo_tagfilter options. -
Poster image appears stretched or causes layout shift. The
aspect-ratioon the wrapper div must match the uploaded video's native ratio. Set it explicitly in the inline style on the wrapper div. A mismatch causes CLS, which Lighthouse will flag separately from LCP. -
Script not loading in Hydrogen. Hydrogen is a React app and does not use
layout/theme.liquid. Importproduct-video-defer.jsas a module in your product route component, or inline the logic as a React effect in theDeferredVideocomponent above.
Summary
The poster-first, click-to-load pattern keeps your LCP element a static image while still giving visitors access to video on demand. For how to decide which video to surface based on where the visitor came from, see how PDP video routing works before wiring the personalization rules.