David Lewin David Lewin
SEO, Traffic & Automation

Shoppable video in a headless store: a light API component vs a third-party widget iframe

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

A shoppable video widget hurts Core Web Vitals in two ways. If its box has no height until the widget loads, it pushes the page down (CLS). And if it autoplays a preview, it spends the shopper's bandwidth on video before they've asked for it. In our test on a throttled phone profile, an iframe widget without reserved space scored a CLS of 0.069 on every run and downloaded about 3.3 MB of video in the first seven seconds. Giving the same widget a CSS-sized slot brought CLS to 0, but it still downloaded the video. A small web component that reserves its height in CSS, fetches a JSON feed only when the rail nears the screen, and creates the video element only on tap had a CLS of 0, downloaded no video before a tap, and loaded 284 KB in total. If you use a hosted platform such as Tolstoy for tagging, analytics and UGC collection, give its widget a fixed-height slot and inject its script when that slot approaches the viewport.

Short product videos sell: a shopper sees the jacket move, the bag opened, the shoe on a real foot. The usual way to add them is a widget from a shoppable video platform: paste a script, get a carousel of vertical videos with product cards. On a headless store, where you've worked to keep the page fast, that widget can quietly undo the work. This guide measures why, and shows two fixes: a light component that reads your own JSON feed, and a way to load a hosted widget without the damage.

How this guide was made

We built a test product page and loaded it in headless Microsoft Edge with a phone viewport (390 x 844), a throttled connection (1.6 Mbps down, 150 ms latency) and 4x CPU slowdown. We compared four versions of the same video rail, three runs each, and measured layout shift, bytes downloaded and interaction timing with the browser's own performance APIs. The "third-party widget" is a stand-in we wrote to behave like a typical iframe widget; it is not any vendor's code. We also downloaded and read Tolstoy's current loader scripts and its developer documentation (October 2026). All 21 checks pass. We didn't test a live Tolstoy account.

1. The short answer

QuestionAnswer
What breaks CLS?A widget container with no height until the widget's iframe loads and reports its size
What fixes CLS?A height set in CSS before any script runs. That alone took our test from 0.069 to 0
What CSS doesn't fixBytes: an autoplaying preview still downloads video the shopper never asked for
What hurts INP?Building the video player inside the tap handler. Paint the pressed state first, then create the player
Lightest optionA web component that fetches a JSON feed near the viewport and creates <video> only on tap
Using a hosted platform?Give its widget a fixed-height slot and inject its script when the slot approaches the screen
Our pickTolstoy, if you want a platform for tagging, UGC and analytics; your own feed if you only need playback

2. Why a video widget costs Core Web Vitals

Three Core Web Vitals decide whether Google calls a page fast: Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS) and Interaction to Next Paint (INP), which replaced First Input Delay in March 2024. A typical shoppable video widget touches two of them, and your data plan touches the shopper.

  • CLS. The loader script creates an iframe in an empty container. The iframe's document loads, measures itself and tells the parent page its height with postMessage. When the parent applies that height, everything below the widget moves down. If the shopper is reading the product details under it, they move too.
  • Bytes and battery. Story-style carousels often autoplay a muted preview of the first video, and set preload on the rest. On a phone that's megabytes of video for a section the shopper may never look at.
  • INP. When the shopper taps a video, the work done before the next frame (creating the player, attaching sources, laying out) delays the visual response. Google's threshold for "good" is 200 ms.

LCP is usually safe, because the widget sits below the main product image. It isn't safe if the widget's iframe is the biggest element in the first screen.

Widget iframe vs a light API fetch
Widget iframe vs a light API fetchThird-party widget / Light component → Iframe injected / Near the viewport → Iframe document loads / One JSON request → Height arrives by postMessage / Posters render → Video downloading / TapThird-party widget: loader script runs in the page headThird-party widgetloader script runs in thepage headLight component: CSS reserves the rail's height before any scriptLight componentCSS reserves the rail'sheight before any scriptIframe injected: empty container, no height yetIframe injectedempty container, no heightyetNear the viewport: IntersectionObserver fires 600 px earlyNear the viewportIntersectionObserver fires600 px earlyIframe document loads: its own scripts, a muted preview autoplaysIframe document loadsits own scripts, a mutedpreview autoplaysOne JSON request: titles, posters, prices, product URLsOne JSON requesttitles, posters, prices,product URLsHeight arrives by postMessage: the page below jumps (CLS)Height arrives bypostMessagethe page below jumps (CLS)Posters render: into space that was already there: no shiftPosters renderinto space that was alreadythere: no shiftVideo downloading: before anyone asked to watchVideo downloadingbefore anyone asked to watchTap: only now a video element and its fileTaponly now a video element andits file
Left: what a typical iframe widget does on load. Right: the component in this guide. Both end with a playable rail; only one makes the shopper pay for it in advance.

3. What we measured

Four versions of one product page, each with a "Seen on customers" rail just below the buy box, partly on the first screen:

  1. Widget: the stand-in loader injects a cross-site iframe into an empty container. Inside, the first of six videos autoplays muted and the rest preload metadata. The iframe reports its height after it loads. Its scripts are about the size of Tolstoy's current loader files, uncompressed.
  2. Reserved: the same widget, in a container whose height is set in CSS.
  3. Facade: the reserved container, with the widget's script injected only when the container nears the viewport.
  4. API component: the <shoppable-reels> component from section 4.
VersionCLS (3 runs)Downloaded in the first 7 sOf which videoRequests
Widget0.069, 0.069, 0.0694,012 KB3,277 KB17
Reserved0, 0, 04,024 KB3,284 KB17
Facade (rail on the first screen)0, 0, 01,561 KB825 KB18
API component0, 0, 0284 KB0 KB10

Medians of three runs. The video file is a 1.1 MB CC0 clip; the widget fetches it once per video element, which is why its numbers exceed the file size. The facade downloads less here only because its script starts later: with the rail on the first screen it still loads the widget at once, as it should. Its benefit shows when the rail is further down, where it made no request to the widget's host until we scrolled.

Downloaded before any interaction (median, throttled phone)
Downloaded before any interaction (median, throttled phone)Widget, no reserved space: 4012 KB; Widget, reserved slot: 4024 KB; Facade, rail on first screen: 1561 KB; API component: 284 KBWidget, no reserved space: 4012 KBWidget, no reserved space4012 KBWidget, reserved slot: 4024 KBWidget, reserved slot4024 KBFacade, rail on first screen: 1561 KBFacade, rail on first screen1561 KBAPI component: 284 KBAPI component284 KB
First seven seconds after load. Video is most of the widget's bytes because it autoplays a muted preview. Reserving space fixes the layout shift, not the download.

A CLS of 0.069 is inside Google's "good" range (up to 0.1), so this one widget wouldn't fail the metric on its own. CLS adds up, though: a cookie banner, a review widget and a late font on the same page can each add a few hundredths. The fix is one CSS rule, so there's no reason to spend the budget on a widget.

4. The light component

The component reads a JSON feed and renders a horizontal rail of posters with product names and prices. Three rules keep it cheap:

  1. Height comes from CSS, computed from the card width, so the space exists before the script loads, before the feed arrives and if the script never runs at all.
  2. The feed is fetched when the rail is 600 px from the viewport, early enough that posters are ready when the shopper arrives, late enough that a shopper who never scrolls that far costs nothing.
  3. A <video> element is created only on tap. Before that, each card is a button with a poster image.

It's a custom element with no dependencies, so it works the same in Next.js, Hydrogen, Astro or a plain page. In React, render <shoppable-reels src="..."> and import the script on the client.

// <shoppable-reels src="/api/reels?collection=summer"></shoppable-reels>
// CSS reserves the rail's height before this script runs. The JSON is fetched when the rail is near the viewport,
// and a video file is requested only when the shopper taps a poster. No dependencies.
const safe = (u) => {
  try {
    const url = new URL(u, location.href);
    return url.protocol === 'https:' || url.origin === location.origin ? url.href : '';
  } catch { return ''; }
};
const el = (tag, props, ...kids) => {
  const n = Object.assign(document.createElement(tag), props);
  n.append(...kids); // text always goes in through textContent, never innerHTML
  return n;
};

class ShoppableReels extends HTMLElement {
  connectedCallback() {
    this.io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) { this.io.disconnect(); this.load(); }
    }, { rootMargin: '600px 0px' });
    this.io.observe(this);
  }

  disconnectedCallback() { this.io?.disconnect(); this.seen?.disconnect(); }

  async load() {
    let reels;
    try {
      const r = await fetch(this.getAttribute('src'), { headers: { Accept: 'application/json' } });
      if (!r.ok) throw new Error(r.status);
      reels = (await r.json()).reels.filter((x) => safe(x.poster) && safe(x.video) && safe(x.product?.url)
        && /^[A-Z]{3}$/.test(x.product.currency) && Number.isFinite(+x.product.price));
    } catch { reels = []; }
    if (!reels.length) {
      // Off screen, removing the rail moves nothing the shopper sees; on screen, keep its space.
      if (this.getBoundingClientRect().top > innerHeight) return this.remove();
      return void (this.textContent = 'Videos are unavailable right now.');
    }
    // Pause a playing video once it scrolls out of view.
    this.seen = new IntersectionObserver((es) => es.forEach((e) => e.isIntersecting || e.target.pause()));
    this.replaceChildren(...reels.slice(0, 12).map((x) => this.card(x)));
  }

  card(x) {
    const p = x.product;
    const price = new Intl.NumberFormat(undefined, { style: 'currency', currency: p.currency }).format(p.price);
    const poster = el('img', { src: safe(x.poster), alt: '', width: 360, height: 640, loading: 'lazy', decoding: 'async' });
    const play = el('button', { type: 'button', className: 'reel-play' }, poster);
    play.setAttribute('aria-label', 'Play video: ' + x.title);
    play.addEventListener('click', () => this.play(play, x), { once: true });
    const name = el('span', { className: 'reel-name', textContent: p.name });
    const cost = el('span', { className: 'reel-price', textContent: price });
    return el('figure', { className: 'reel' }, play, el('a', { className: 'reel-product', href: safe(p.url) }, name, cost));
  }

  play(button, x) {
    // Paint the pressed state first, then build the player: creating a media element is the slow part of the tap,
    // and INP counts until the next paint. The tap's user activation still covers play() a frame later.
    button.classList.add('is-loading');
    requestAnimationFrame(() => setTimeout(() => this.start(button, x)));
  }

  start(button, x) {
    const v = el('video', { src: safe(x.video), poster: safe(x.poster), controls: true, playsInline: true, preload: 'auto' });
    v.setAttribute('aria-label', x.title);
    button.replaceWith(v);
    this.querySelectorAll('video').forEach((o) => o !== v && o.pause());
    v.addEventListener('play', () => this.querySelectorAll('video').forEach((o) => o !== v && o.pause()));
    this.seen.observe(v);
    v.play().catch(() => {}); // autoplay with sound can be refused; the controls are there
    this.dispatchEvent(new CustomEvent('reel-play', { bubbles: true, detail: { id: x.id } }));
  }
}
customElements.define('shoppable-reels', ShoppableReels);

The CSS that does the reserving. --w is the card width; the rail's height follows from it:

/* The rail's height is fixed by CSS alone, so nothing below it moves when the posters arrive. */
shoppable-reels {
  --w: min(42vw, 220px);
  display: grid; grid-auto-flow: column; grid-auto-columns: var(--w); gap: 12px;
  height: calc(var(--w) * 16 / 9 + 60px); overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory; contain: layout paint;
}
.reel { margin: 0; scroll-snap-align: start; }
.reel-play, .reel video { display: block; width: 100%; aspect-ratio: 9 / 16; padding: 0; border: 0; border-radius: 12px; background: #1b1b1b; }
.reel-play { overflow: hidden; cursor: pointer; }
.reel-play img { width: 100%; height: 100%; object-fit: cover; }
.reel-product { display: flex; justify-content: space-between; gap: 8px; height: 44px; padding-top: 8px; font-size: 14px; line-height: 1.3; overflow: hidden; }
.reel-price { font-weight: 600; white-space: nowrap; }
.reel-play.is-loading { opacity: .6; }

The feed the component expects. Build it from wherever your videos live: a headless CMS, Shopify metaobjects, or a platform's export. Prices are numbers and the currency is an ISO code, so the browser formats them for the shopper's locale:

{"reels": [
  {"id": "r0", "title": "Linen shirt on a customer",
   "poster": "/media/r0.jpg", "video": "/media/r0.mp4",
   "product": {"name": "Linen shirt", "url": "/products/linen-shirt/", "price": "48.00", "currency": "USD"}}
]}

Treat the feed as untrusted, even if you wrote it: it's assembled from product data that many people can edit. The component drops entries whose URLs aren't https or same-origin (so a javascript: link never becomes an href), drops entries with an invalid currency code instead of crashing on them, and inserts every text with textContent. In the test, a product name containing an <img onerror> payload was shown as text and never ran.

The INP bug we found in our own first version

Our first version created the video element directly in the click handler. Under 4x CPU slowdown, the tap's event duration was 336 to 520 ms: the browser couldn't paint the response until the media element was built. That's in INP's "needs improvement" range. The fix is in play(): add a pressed state, wait for the next frame, then build the player. The same tap then measured 64 to 72 ms. The tap's user activation lasts long enough for video.play() to be allowed a frame later; the video started in all runs.

Tap on a poster: event duration (4x CPU slowdown)
Tap on a poster: event duration (4x CPU slowdown)Player built inside the click handler: 520 ms; Pressed state painted first: 72 msPlayer built inside the click handler: 520 msPlayer built inside the click handler520 msPressed state painted first: 72 msPressed state painted first72 ms
Worst run of each version. INP counts from the tap to the next paint, so work that can wait one frame should.

5. Loading a hosted widget without the damage

A platform like Tolstoy does far more than play videos: it collects UGC, tags products in bulk, runs shoppable carousels and stories, and reports which videos led to orders. Rebuilding that isn't worth it for most teams. What you can control is when its script loads and how much space it gets.

// Inject a third-party widget's script only when its slot nears the viewport. The slot's height is set in CSS,
// so the widget fills reserved space instead of pushing the page down.
export function loadWhenNear(slot, src, attrs = {}, margin = '400px 0px') {
  const io = new IntersectionObserver(([e]) => {
    if (!e.isIntersecting) return;
    io.disconnect();
    const s = Object.assign(document.createElement('script'), { src, type: 'module' });
    for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v);
    document.head.append(s);
  }, { rootMargin: margin });
  io.observe(slot);
}
<div class="video-slot" data-tolstoy-slot><!-- the widget's embed code --></div>
<style>.video-slot { height: 520px; contain: strict; }  /* the height the widget renders at */</style>
<script type="module">
  // Attributes as shown in Tolstoy's Website setup panel; copy yours from there.
  import { loadWhenNear } from '/lazy-widget.js';
  loadWhenNear(document.querySelector('[data-tolstoy-slot]'),
    'https://play.gotolstoy.com/widget-v2/widget.js',
    { id: 'tolstoy-widget-script', 'data-app-key': 'YOUR_APP_KEY', 'data-shop': 'your-store.com' });
</script>

Measure the widget's rendered height at each breakpoint and set it per media query. contain: strict keeps anything the widget does inside the box.

Does Tolstoy's script work when injected after the page has loaded? Its developer docs say to put the site code in the <head> of every page. We read the current loader (play.gotolstoy.com/widget-v2/widget.js, 18.5 KB compressed): it checks document.readyState and initializes immediately if the page has already loaded, and it watches for new widget containers with a MutationObserver. It also uses IntersectionObserver itself, so it already defers some of its work. That suggests late injection is supported, but we couldn't run it without an account, so test it on a staging page before you ship it. The second loader the docs mention, widget.gotolstoy.com/we/widget.js, is 43.9 KB compressed, and both load further chunks on demand.

Keep the head-loaded site code on pages with floating widgets (a bubble or story launcher visible on load) and on the order confirmation page, where Tolstoy's conversion tracking runs. Tolstoy attributes an order only if the shopper saw Tolstoy in the same browser, which a deferred widget below the fold may never do for shoppers who don't scroll. That's a real cost: weigh it against the bytes saved.

6. Test results

CheckResult
Widget with no reserved space shifts the page on every runPass (CLS 0.069)
Same widget in a CSS-sized slot; facade; API component: CLS below 0.01Pass (0 in all runs)
Reserved slot still downloads as much video as the unreserved widgetPass (documents the limit of CSS)
API component: no video bytes before a tapPass
Facade below the fold: no request to the widget's host until scrolled near; loader and iframe after the scroll; no shiftPass
API component below the fold: no feed or poster requests before scrolling; feed and posters after; no shiftPass
Tap: video element and file only now; no event over 200 msPass (64 to 72 ms)
Invalid entries dropped (javascript: video and product URLs, bad currency); prices formattedPass
HTML in a product name shown as text, never runPass
Starting a second video pauses the first; videos pause when scrolled out of viewPass
Feed returns 404 with the rail on screen: space kept, message shown, no shiftPass
Feed returns 404 with the rail just below the screen: rail removed, no shiftPass

Not tested: Safari and Firefox, real devices, a live Tolstoy or Firework widget, and HLS streaming. For long videos, serve HLS or DASH from a video host and attach it on tap the same way; short clips under about 30 seconds are fine as MP4.

7. Our opinion: which to use

  • Build the component if your videos are already yours (shot by your team, on your CDN) and you only need playback with product links. It's about 80 lines and you control every byte.
  • Use a platform if the hard part is getting the videos: collecting customer clips, tagging products across hundreds of videos, seeing which ones sell. Then load its widget with the facade and a reserved slot.
  • Never autoplay with preload on a phone for a section below the buy box. A poster is enough to make people tap.
  • Measure with field data, not just a lab run. Chrome's UX Report or your own RUM tell you what real phones see; a lab test like ours tells you why.

Related: headless Shopify reviews without ruining LCP and server-side social proof for WooCommerce.

8. Tools

Top pick

ToolRoleWhy it fits
TolstoyShoppable video platformCarousels, stories, embeds and a product gallery from your own and customers' videos, with bulk product tagging, AI creative tools and order attribution. Works on Shopify and on other sites through its site code, with product data uploaded by CSV for non-Shopify stores. Plans from $8 a month billed yearly (Starter, 5,000 viewers a month) to $299 (Max, 120,000); every paid plan includes shoppable videos, and the pricing page lists a Products API, a Media API, embeds and a custom JavaScript widget. Free account without a card, with a 30-day trial of publishing (pricing page, October 2026).

Alternatives: free tools and references

We don't earn anything from these links.

  • Firework: a shoppable video and livestream platform. The same rules apply: a fixed slot and a deferred script.
  • Videowise: shoppable video for Shopify, with a free plan on the Shopify App Store.
  • web.dev: Optimize INP: why yielding to the next paint helps, with patterns beyond the one used here.
  • MDN: Intersection Observer API: rootMargin and thresholds for loading on approach.

9. Pitfalls

  • Reserving space with JavaScript: the script runs after the first paint, so the shift has already happened. Use CSS.
  • A min-height smaller than the widget: the shift is smaller, not gone. Use the rendered height, per breakpoint.
  • loading="lazy" on the iframe as the only fix: it delays the load but the box still has no height when it arrives.
  • Removing a failed rail while it's on screen: that's a shift too. Remove it only when it's off screen.
  • Building the player in the tap handler: slow taps on mid-range phones, and a worse INP.
  • Trusting feed URLs: one javascript: link in a product URL becomes a script on click.
  • Deferring the platform's script on the order page: you lose the conversion data you're paying for.
Bottom line

Give every video rail its height in CSS, fetch its data when it nears the screen, and create the video only when someone taps. A hosted platform like Tolstoy can follow the same rules if you give its widget a fixed slot and inject its script on approach. In our test that turned a 0.069 layout shift and 3 MB of unrequested video into no shift and no video until a tap.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.