Harloop
Back to blog

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/hydrogen installed.

Step 1: Upload the Video in Shopify Admin

  1. Go to Admin → Products and open the target product.
  2. In the Media section, click Add media and upload the MP4 file.
  3. 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.
  4. Upload your poster image as a separate image media item, or store it in Admin → Content → Files and 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

  1. Open the product page in Chrome Incognito (to exclude cached resources).
  2. Open DevTools → Lighthouse and run an analysis on Mobile with Performance selected.
  3. In the results, expand Diagnostics and locate the LCP element. It should identify the poster <img>, not a <video> element.
  4. 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. Inspect document.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 playsinline attribute on the <video> element, or it opens in a separate fullscreen player. The JavaScript above adds playsinline programmatically. If you pregenerate the video tag in Liquid, add playsinline: true to the video_tag filter options.

  • Poster image appears stretched or causes layout shift. The aspect-ratio on 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. Import product-video-defer.js as a module in your product route component, or inline the logic as a React effect in the DeferredVideo component 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.

More posts