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.
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.
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
| Question | Answer |
|---|---|
| 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 fix | Bytes: 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 option | A 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 pick | Tolstoy, 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
preloadon 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.
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:
- 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.
- Reserved: the same widget, in a container whose height is set in CSS.
- Facade: the reserved container, with the widget's script injected only when the container nears the viewport.
- API component: the
<shoppable-reels>component from section 4.
| Version | CLS (3 runs) | Downloaded in the first 7 s | Of which video | Requests |
|---|---|---|---|---|
| Widget | 0.069, 0.069, 0.069 | 4,012 KB | 3,277 KB | 17 |
| Reserved | 0, 0, 0 | 4,024 KB | 3,284 KB | 17 |
| Facade (rail on the first screen) | 0, 0, 0 | 1,561 KB | 825 KB | 18 |
| API component | 0, 0, 0 | 284 KB | 0 KB | 10 |
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.
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:
- 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.
- 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.
- 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.
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
| Check | Result |
|---|---|
| Widget with no reserved space shifts the page on every run | Pass (CLS 0.069) |
| Same widget in a CSS-sized slot; facade; API component: CLS below 0.01 | Pass (0 in all runs) |
| Reserved slot still downloads as much video as the unreserved widget | Pass (documents the limit of CSS) |
| API component: no video bytes before a tap | Pass |
| Facade below the fold: no request to the widget's host until scrolled near; loader and iframe after the scroll; no shift | Pass |
| API component below the fold: no feed or poster requests before scrolling; feed and posters after; no shift | Pass |
| Tap: video element and file only now; no event over 200 ms | Pass (64 to 72 ms) |
Invalid entries dropped (javascript: video and product URLs, bad currency); prices formatted | Pass |
| HTML in a product name shown as text, never run | Pass |
| Starting a second video pauses the first; videos pause when scrolled out of view | Pass |
| Feed returns 404 with the rail on screen: space kept, message shown, no shift | Pass |
| Feed returns 404 with the rail just below the screen: rail removed, no shift | Pass |
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
| Tool | Role | Why it fits |
|---|---|---|
| Shoppable video platform | Carousels, 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:
rootMarginand 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-heightsmaller 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.
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.