Table of Contents
- Introduction
- What We'll Build
- Why a Plain YouTube iframe Hurts Your Store
- The Facade Pattern Explained
- Setting Up the Theme App Extension
- Parsing the YouTube URL with Liquid
- Building the Facade Markup
- Styling a Responsive 16:9 Player
- Swapping in the Real iframe with JavaScript
- Making It Configurable from the Theme Editor
- Complete File
- Tips and Common Pitfalls
- Conclusion
- UR: Smart YouTube Embed
- References
Introduction
Product videos sell. A 30-second clip showing how a jacket moves, how a blender handles frozen fruit, or how a piece of furniture gets assembled does more for conversion than three paragraphs of copy. So merchants do the obvious thing: they grab the Embed code from YouTube, paste it into a Shopify page, and move on.
Then the Lighthouse score tanks.
A single default YouTube embed pulls in hundreds of kilobytes of JavaScript before the visitor has clicked anything. Put three of them on a collection page and you've added more weight than the rest of your theme combined. On mobile, where a big chunk of Shopify traffic lives, that's a measurable hit to Largest Contentful Paint and Interaction to Next Paint — the exact metrics Shopify surfaces in the Online Store Speed report.
The fix isn't to drop video. It's to stop loading the player until someone actually wants to watch.
In this tutorial we'll build a YouTube embed block as a Shopify Theme App Extension using the facade pattern: render a lightweight thumbnail with a play button, and only inject the real <iframe> on click. We'll parse any YouTube URL format in Liquid (watch links, short links, Shorts, embed URLs), build a responsive player that never causes layout shift, wire up click-to-load JavaScript that survives the theme editor, and expose everything as theme editor settings so merchants can drop the block anywhere without touching code.
Everything here is production code you can paste into an Online Store 2.0 theme today.
What We'll Build
A YouTube video block that a merchant can add to any section from the theme editor. It will:
-
Accept any YouTube URL format —
youtube.com/watch?v=,youtu.be/,/embed/,/shorts/, or/live/— and extract the video ID in Liquid - Render zero third-party JavaScript on page load — just one image and a play button
- Load the real player on click, with autoplay so the first click is the only click
- Stay perfectly responsive at 16:9, 9:16 (Shorts), or 4:3 with no cumulative layout shift
-
Use
youtube-nocookie.comso no tracking cookies are set until playback starts - Support a custom thumbnail uploaded through Shopify's image picker, with an automatic fallback when YouTube has no high-res thumbnail
-
Be keyboard accessible — a real
<button>, real focus states, a real accessible name
The whole thing is three files: one Liquid block, one CSS file, one JS file. No dependencies, no build step, no player library.
Why a Plain YouTube iframe Hurts Your Store
Here's what YouTube's own embed code looks like:
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/VIDEO_ID"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
It works. It's also expensive, for four separate reasons.
1. It downloads the full player before anyone presses play. The embed pulls the YouTube player bundle, its CSS, fonts, and thumbnail assets during initial page load. That's a large amount of render-blocking-adjacent work happening on the main thread for content the visitor may never watch. Multiply by the number of videos on the page.
2. It competes with your own assets. Your product images, your cart drawer script, your theme's JavaScript — they all fight the YouTube player for bandwidth and main-thread time. On a mid-range Android phone over 4G, that contention is exactly what pushes LCP past the 2.5s threshold.
3. It sets cookies immediately. The standard youtube.com embed drops tracking cookies on load, not on play. If you're selling into the EU, that has consent-banner implications before the visitor has interacted with anything.
4. Fixed width and height cause layout shift. width="560" height="315" is not responsive. Merchants who paste this into a rich text field get a video that overflows on mobile, and any CSS that resizes it after paint contributes to CLS.
The important insight: the visitor doesn't need the player until they click play. Everything before that moment is just a picture of a video with a play button on it. And a picture is cheap.
The Facade Pattern Explained
A facade is a lightweight stand-in for a heavy third-party embed. It looks like the real thing, but it's made of static HTML. When the user interacts with it, you replace it with the real embed.
For YouTube, the facade is:
- A thumbnail image, which YouTube serves for free at predictable URLs:
-
https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg— 1280×720, not available for every video -
https://img.youtube.com/vi/VIDEO_ID/hqdefault.jpg— 480×360, always available
-
- A play button drawn with inline SVG — no image request at all
- A click handler that builds the
<iframe>withautoplay=1and swaps it in
The tradeoff is one extra click's worth of latency: the player has to load after the click instead of before it. In practice this is barely noticeable — the player starts streaming in well under a second on a normal connection, and we'll warm up the connection on hover to shave off the DNS and TLS handshake.
What you get in return is a page that ships zero third-party JavaScript unless the visitor asks for it. On a product page with one video, that's typically the difference between a passing and a failing performance audit.
Here's the flow:
Page load → <img> + <svg> play button (~30 KB, cacheable)
User hovers → preconnect to YouTube (DNS + TLS warmed)
User clicks → <iframe autoplay=1> injected (player loads, video plays)
Now let's build it.
Setting Up the Theme App Extension
We're building this as a Theme App Extension rather than editing a theme directly. That's the modern approach for Shopify apps: your block lives in your app, merchants add it through the theme editor, and it survives theme updates because you never touch the merchant's theme files.
If you don't have an app yet, create one with the Shopify CLI:
npm init @shopify/app@latest
cd your-app-name
Then generate the extension:
shopify app generate extension --template theme_app_extension --name youtube-embed
You'll get this structure:
extensions/
└── youtube-embed/
├── assets/
├── blocks/
├── locales/
├── snippets/
└── shopify.extension.toml
The three directories that matter here:
-
blocks/— Liquid files that merchants can add to sections in the theme editor. Each file is one block. This is where our{% schema %}lives. -
assets/— CSS, JS, and images. Shopify serves these from its CDN. -
snippets/— reusable Liquid partials you can{% render %}from your blocks.
Create three files:
extensions/youtube-embed/
├── assets/
│ ├── youtube-embed.css
│ └── youtube-embed.js
└── blocks/
└── youtube-embed.liquid
Then start the dev server:
shopify app dev
The CLI gives you a preview URL. Open your development store's theme editor, click Add block inside any section, and your app's block appears under the Apps category. Changes to your extension files hot-reload in the editor.
One thing worth knowing up front: a theme app extension block cannot make network requests to your app's backend on render. It's Liquid, rendered by Shopify, with no server round-trip. Everything the block needs must come from block settings, shop metafields, or client-side JavaScript. For a YouTube embed that's not a limitation at all — a video URL is the only input we need.
Parsing the YouTube URL with Liquid
Merchants will paste whatever URL is in their address bar. That means you need to handle all of these:
https://www.youtube.com/watch?v=dQw4w9WgXcQ
https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s
https://youtu.be/dQw4w9WgXcQ
https://youtu.be/dQw4w9WgXcQ?si=AbCdEfGhIjKl
https://www.youtube.com/embed/dQw4w9WgXcQ
https://www.youtube.com/shorts/dQw4w9WgXcQ
https://www.youtube.com/live/dQw4w9WgXcQ
dQw4w9WgXcQ
Liquid has no regex, so we do it with split. The trick is to branch on the URL shape first, then run every result through the same cleanup chain:
{%- liquid
assign raw_url = block.settings.video_url | strip
assign video_id = ''
if raw_url contains 'youtu.be/'
assign video_id = raw_url | split: 'youtu.be/' | last
elsif raw_url contains '/embed/'
assign video_id = raw_url | split: '/embed/' | last
elsif raw_url contains '/shorts/'
assign video_id = raw_url | split: '/shorts/' | last
elsif raw_url contains '/live/'
assign video_id = raw_url | split: '/live/' | last
elsif raw_url contains 'v='
assign video_id = raw_url | split: 'v=' | last
else
assign video_id = raw_url
endif
assign video_id = video_id | split: '?' | first | split: '&' | first | split: '/' | first | strip
-%}
Walk through what that cleanup chain does:
| Input after branch | split: '?' \| first | split: '&' \| first | split: '/' \| first |
| --------------------- | --------------------- | --------------------- | --------------------- |
| dQw4w9WgXcQ?si=AbCd | dQw4w9WgXcQ | dQw4w9WgXcQ | dQw4w9WgXcQ |
| dQw4w9WgXcQ&t=42s | dQw4w9WgXcQ&t=42s | dQw4w9WgXcQ | dQw4w9WgXcQ |
| dQw4w9WgXcQ/ | dQw4w9WgXcQ | dQw4w9WgXcQ | dQw4w9WgXcQ |
Three filters, every case covered. The else branch means a merchant can also paste a bare video ID and it just works — which they will, so handle it.
Note the branch order matters. /embed/ and /shorts/ are checked before v= because an embed URL like .../embed/ID?rel=0&v=x would otherwise match the wrong branch.
Guarding against an empty URL
Never render broken markup. If there's no video ID, show nothing on the storefront — but show a helpful message inside the theme editor:
{%- if video_id == blank -%}
{%- if request.design_mode -%}
<div class="ur-yt__placeholder">
Add a YouTube URL in the block settings to display a video.
</div>
{%- endif -%}
{%- else -%}
{%- comment -%} video markup goes here {%- endcomment -%}
{%- endif -%}
request.design_mode is true only when the page is being rendered inside the theme editor. It's the right way to give merchants feedback about misconfiguration without leaking developer messaging to customers. Use it liberally.
Building the thumbnail URL
If the merchant uploaded a custom thumbnail, use Shopify's CDN with responsive sizing. Otherwise fall back to YouTube's:
{%- liquid
assign thumb_fallback = ''
if block.settings.custom_thumbnail != blank
assign thumb_src = block.settings.custom_thumbnail | image_url: width: 1280
else
assign thumb_src = 'https://img.youtube.com/vi/' | append: video_id | append: '/maxresdefault.jpg'
assign thumb_fallback = 'https://img.youtube.com/vi/' | append: video_id | append: '/hqdefault.jpg'
endif
-%}
maxresdefault.jpg gives you a crisp 1280×720 image — but it doesn't exist for every video. Older uploads and low-resolution sources only have hqdefault.jpg. We store the fallback in a data attribute and handle the 404 in JavaScript, which we'll get to shortly.
Building the Facade Markup
The facade is a <button> containing an <img> and an SVG play icon. Using a real <button> rather than a <div onclick> gets you keyboard activation, focus management, and screen reader semantics for free.
<div
class="ur-yt"
style="
--ur-yt-max-width: {{ block.settings.max_width }}px;
--ur-yt-radius: {{ block.settings.corner_radius }}px;
--ur-yt-play: {{ block.settings.play_button_color }};
--ur-yt-ratio: {{ block.settings.aspect_ratio }};
padding-top: {{ block.settings.padding_top }}px;
padding-bottom: {{ block.settings.padding_bottom }}px;
"
{{ block.shopify_attributes }}
>
{%- if block.settings.heading != blank -%}
<h2 class="ur-yt__heading">{{ block.settings.heading | escape }}</h2>
{%- endif -%}
<div class="ur-yt__frame">
<button
type="button"
class="ur-yt__facade"
data-ur-yt-facade
data-video-id="{{ video_id | escape }}"
data-start="{{ block.settings.start_seconds }}"
aria-label="{{ play_label | escape }}"
>
<img
class="ur-yt__thumb"
src="{{ thumb_src }}"
data-fallback="{{ thumb_fallback }}"
alt=""
width="1280"
height="720"
loading="lazy"
decoding="async"
>
<span class="ur-yt__play" aria-hidden="true">
<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor" focusable="false">
<path d="M8 5v14l11-7z"></path>
</svg>
</span>
</button>
</div>
{%- if block.settings.caption != blank -%}
<p class="ur-yt__caption">{{ block.settings.caption | escape }}</p>
{%- endif -%}
</div>
Several deliberate decisions in there:
{{ block.shopify_attributes }} is required. It renders the data attributes the theme editor uses to highlight and scroll to your block when a merchant selects it in the sidebar. Leave it out and the editing experience breaks in a way that's hard to debug.
CSS custom properties carry the settings. Rather than generating a <style> tag per block — which duplicates rules for every instance on the page — we set variables inline and keep all the actual rules in one cached CSS file. Multiple blocks on the same page each get their own values with zero extra CSS.
alt="" on the thumbnail is correct here. The image is decorative relative to the button, which already carries the accessible name via aria-label. If you also gave the image an alt text, screen readers would announce the video title twice.
width and height are on the image. Combined with the aspect-ratio on the wrapper, this reserves the exact layout box before the image loads. That's your CLS score protected.
The accessible label is built just above the markup:
{%- liquid
if block.settings.video_title != blank
assign play_label = 'Play video: ' | append: block.settings.video_title
else
assign play_label = 'Play video'
endif
-%}
If you're shipping to a merchant base outside English, move that string into locales/en.default.json and use {{ 'youtube.play_label' | t }} instead. Theme app extensions support the same translation system as themes.
Styling a Responsive 16:9 Player
The whole layout hinges on one property: aspect-ratio on the frame. Everything inside is absolutely positioned to fill it.
.ur-yt {
margin: 0 auto;
}
.ur-yt__heading {
max-width: var(--ur-yt-max-width, 900px);
margin: 0 auto 0.75rem;
text-align: center;
}
.ur-yt__frame {
position: relative;
width: 100%;
max-width: var(--ur-yt-max-width, 900px);
margin: 0 auto;
aspect-ratio: var(--ur-yt-ratio, 16 / 9);
border-radius: var(--ur-yt-radius, 8px);
overflow: hidden;
background-color: #000;
}
/* Both the facade button and the injected iframe fill the frame exactly. */
.ur-yt__frame > * {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ur-yt__iframe {
display: block;
border: 0;
}
.ur-yt__facade {
display: block;
padding: 0;
border: 0;
background-color: #000;
cursor: pointer;
appearance: none;
}
.ur-yt__thumb {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
The .ur-yt__frame > * rule is doing quiet but important work: the facade button and the iframe that replaces it are styled identically. When JavaScript swaps one for the other, there is no reflow, no flash, no jump. The video appears exactly where the thumbnail was.
Now the play button:
.ur-yt__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 68px;
height: 48px;
border-radius: 14px;
background-color: var(--ur-yt-play, #ff0000);
color: #fff;
opacity: 0.9;
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.ur-yt__facade:hover .ur-yt__play,
.ur-yt__facade:focus-visible .ur-yt__play {
opacity: 1;
transform: translate(-50%, -50%) scale(1.08);
}
.ur-yt__facade:focus-visible {
outline: 3px solid currentColor;
outline-offset: -3px;
}
pointer-events: none on the play button means clicks always land on the button element itself, never on the inner <span> or <svg>. That keeps the JavaScript's closest() lookup simple. (We handle it defensively in JS anyway, but belt and braces.)
Two more rules to be a good citizen:
@media (prefers-reduced-motion: reduce) {
.ur-yt__play {
transition: none;
}
.ur-yt__facade:hover .ur-yt__play,
.ur-yt__facade:focus-visible .ur-yt__play {
transform: translate(-50%, -50%);
}
}
.ur-yt__caption {
max-width: var(--ur-yt-max-width, 900px);
margin: 0.75rem auto 0;
text-align: center;
font-size: 0.9rem;
opacity: 0.8;
}
.ur-yt__placeholder {
padding: 2rem;
border: 1px dashed currentColor;
border-radius: 8px;
text-align: center;
opacity: 0.6;
}
Because the aspect ratio is a CSS variable, supporting vertical Shorts is a one-line setting change — 9 / 16 instead of 16 / 9 — with no separate stylesheet or media query.
Swapping in the Real iframe with JavaScript
Here's the part that matters most for correctness in a Shopify context.
Do not bind click handlers to elements on DOMContentLoaded. In the theme editor, Shopify re-renders blocks when a merchant changes a setting, adds a block, or drags one to a new position. Any handler you attached to the old DOM node dies with it, and the block stops responding until the merchant reloads the page. Merchants notice.
The fix is event delegation on document. One listener, attached once, that works for every block on the page — including blocks that didn't exist when the script ran.
(function () {
'use strict';
var EMBED_ORIGIN = 'https://www.youtube-nocookie.com';
var connectionWarmed = false;
var IFRAME_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
function warmConnection() {
if (connectionWarmed) return;
connectionWarmed = true;
var link = document.createElement('link');
link.rel = 'preconnect';
link.href = EMBED_ORIGIN;
link.crossOrigin = '';
document.head.appendChild(link);
}
function buildIframe(facade) {
var videoId = facade.getAttribute('data-video-id');
var start = parseInt(facade.getAttribute('data-start'), 10) || 0;
var params = ['autoplay=1', 'rel=0', 'modestbranding=1', 'playsinline=1'];
if (start > 0) {
params.push('start=' + start);
}
var iframe = document.createElement('iframe');
iframe.className = 'ur-yt__iframe';
iframe.src = EMBED_ORIGIN + '/embed/' + encodeURIComponent(videoId) + '?' + params.join('&');
iframe.title = facade.getAttribute('aria-label') || 'YouTube video player';
iframe.allow = IFRAME_ALLOW;
iframe.allowFullscreen = true;
iframe.setAttribute('frameborder', '0');
return iframe;
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!(target instanceof Element)) return;
var facade = target.closest('[data-ur-yt-facade]');
if (!facade) return;
var frame = facade.parentNode;
if (!frame) return;
var iframe = buildIframe(facade);
frame.replaceChild(iframe, facade);
iframe.focus();
});
// Warm up DNS + TLS as soon as the user shows intent, so the click feels instant.
['pointerover', 'focusin', 'touchstart'].forEach(function (eventName) {
document.addEventListener(
eventName,
function (event) {
var target = event.target;
if (target instanceof Element && target.closest('[data-ur-yt-facade]')) {
warmConnection();
}
},
{ passive: true, capture: true },
);
});
// maxresdefault.jpg doesn't exist for every video — fall back to hqdefault.jpg.
// Note: error events don't bubble, so this listener must use capture.
document.addEventListener(
'error',
function (event) {
var img = event.target;
if (!(img instanceof Element) || !img.classList.contains('ur-yt__thumb')) return;
var fallback = img.getAttribute('data-fallback');
if (!fallback) return;
img.removeAttribute('data-fallback'); // prevents an infinite retry loop
img.src = fallback;
},
true,
);
})();
Three details worth calling out:
autoplay=1 works here — and only here. Browsers block autoplay for unmuted video, unless the play was initiated by a user gesture. Because we create the iframe inside a click handler, the gesture carries over and the video plays with sound. If you ever load the iframe without a click (say, on scroll), you must add mute=1 or nothing will play.
The error listener uses capture. error events fired on an <img> do not bubble up the DOM. Passing true as the third argument catches them during the capture phase instead, which is what makes a single delegated listener possible for every thumbnail on the page.
iframe.focus() after the swap. The user just activated a button that no longer exists. Without moving focus, a keyboard user is dumped back to the top of the document. Focusing the iframe keeps them exactly where they were, and their next Tab press continues from the player.
To load these assets, reference them from the block's schema (covered next) — Shopify handles the CDN URLs and only loads them on pages where the block is present.
Making It Configurable from the Theme Editor
Everything so far is hardcoded to settings that don't exist yet. The {% schema %} tag at the bottom of the block file defines them.
{% schema %}
{
"name": "YouTube video",
"target": "section",
"stylesheet": "youtube-embed.css",
"javascript": "youtube-embed.js",
"settings": [
{
"type": "header",
"content": "Video"
},
{
"type": "text",
"id": "video_url",
"label": "YouTube URL",
"info": "Paste any YouTube link — watch, youtu.be, Shorts, or embed."
},
{
"type": "text",
"id": "video_title",
"label": "Video title",
"info": "Used for the accessible label and the player title."
},
{
"type": "number",
"id": "start_seconds",
"label": "Start at (seconds)",
"default": 0
},
{
"type": "image_picker",
"id": "custom_thumbnail",
"label": "Custom thumbnail",
"info": "Optional. Defaults to the YouTube thumbnail."
},
{
"type": "header",
"content": "Text"
},
{
"type": "text",
"id": "heading",
"label": "Heading"
},
{
"type": "text",
"id": "caption",
"label": "Caption"
},
{
"type": "header",
"content": "Layout"
},
{
"type": "select",
"id": "aspect_ratio",
"label": "Aspect ratio",
"options": [
{ "value": "16 / 9", "label": "16:9 (landscape)" },
{ "value": "9 / 16", "label": "9:16 (Shorts)" },
{ "value": "4 / 3", "label": "4:3 (classic)" },
{ "value": "1 / 1", "label": "1:1 (square)" }
],
"default": "16 / 9"
},
{
"type": "range",
"id": "max_width",
"label": "Maximum width",
"min": 320,
"max": 1400,
"step": 20,
"unit": "px",
"default": 900
},
{
"type": "range",
"id": "corner_radius",
"label": "Corner radius",
"min": 0,
"max": 40,
"step": 2,
"unit": "px",
"default": 8
},
{
"type": "color",
"id": "play_button_color",
"label": "Play button color",
"default": "#ff0000"
},
{
"type": "range",
"id": "padding_top",
"label": "Top padding",
"min": 0,
"max": 100,
"step": 4,
"unit": "px",
"default": 24
},
{
"type": "range",
"id": "padding_bottom",
"label": "Bottom padding",
"min": 0,
"max": 100,
"step": 4,
"unit": "px",
"default": 24
}
]
}
{% endschema %}
A few schema mechanics that trip people up:
"stylesheet" and "javascript" are the right way to load assets. Point them at filenames in your assets/ directory and Shopify injects them for you — deduplicated across multiple instances of the block, and only on pages where the block actually renders. Don't hand-roll <script> tags in the Liquid; you'll load the same file N times for N blocks.
"target": "section" lets merchants add the block inside existing sections (product pages, custom sections, wherever the theme allows app blocks). The alternative, "target": "body", is for blocks that should render once per page outside the normal content flow — sticky bars, popups, cookie notices. A video belongs in the content flow, so "section" it is.
Use liquid as the code fence language, not json. The {% schema %} block is JSON inside a Liquid tag. Editors and linters that treat the whole file as JSON will choke on the surrounding Liquid.
Comments are not allowed in schema JSON. No //, no /* */. A single comment breaks the block silently — it just won't appear in the theme editor's block list, with no error message telling you why.
Complete File
Here's everything, ready to paste.
extensions/youtube-embed/blocks/youtube-embed.liquid
{%- liquid
assign raw_url = block.settings.video_url | strip
assign video_id = ''
if raw_url contains 'youtu.be/'
assign video_id = raw_url | split: 'youtu.be/' | last
elsif raw_url contains '/embed/'
assign video_id = raw_url | split: '/embed/' | last
elsif raw_url contains '/shorts/'
assign video_id = raw_url | split: '/shorts/' | last
elsif raw_url contains '/live/'
assign video_id = raw_url | split: '/live/' | last
elsif raw_url contains 'v='
assign video_id = raw_url | split: 'v=' | last
else
assign video_id = raw_url
endif
assign video_id = video_id | split: '?' | first | split: '&' | first | split: '/' | first | strip
assign thumb_fallback = ''
if block.settings.custom_thumbnail != blank
assign thumb_src = block.settings.custom_thumbnail | image_url: width: 1280
else
assign thumb_src = 'https://img.youtube.com/vi/' | append: video_id | append: '/maxresdefault.jpg'
assign thumb_fallback = 'https://img.youtube.com/vi/' | append: video_id | append: '/hqdefault.jpg'
endif
if block.settings.video_title != blank
assign play_label = 'Play video: ' | append: block.settings.video_title
else
assign play_label = 'Play video'
endif
-%}
{%- if video_id == blank -%}
{%- if request.design_mode -%}
<div class="ur-yt__placeholder" {{ block.shopify_attributes }}>
Add a YouTube URL in the block settings to display a video.
</div>
{%- endif -%}
{%- else -%}
<div
class="ur-yt"
style="
--ur-yt-max-width: {{ block.settings.max_width }}px;
--ur-yt-radius: {{ block.settings.corner_radius }}px;
--ur-yt-play: {{ block.settings.play_button_color }};
--ur-yt-ratio: {{ block.settings.aspect_ratio }};
padding-top: {{ block.settings.padding_top }}px;
padding-bottom: {{ block.settings.padding_bottom }}px;
"
{{ block.shopify_attributes }}
>
{%- if block.settings.heading != blank -%}
<h2 class="ur-yt__heading">{{ block.settings.heading | escape }}</h2>
{%- endif -%}
<div class="ur-yt__frame">
<button
type="button"
class="ur-yt__facade"
data-ur-yt-facade
data-video-id="{{ video_id | escape }}"
data-start="{{ block.settings.start_seconds }}"
aria-label="{{ play_label | escape }}"
>
<img
class="ur-yt__thumb"
src="{{ thumb_src }}"
data-fallback="{{ thumb_fallback }}"
alt=""
width="1280"
height="720"
loading="lazy"
decoding="async"
>
<span class="ur-yt__play" aria-hidden="true">
<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor" focusable="false">
<path d="M8 5v14l11-7z"></path>
</svg>
</span>
</button>
</div>
{%- if block.settings.caption != blank -%}
<p class="ur-yt__caption">{{ block.settings.caption | escape }}</p>
{%- endif -%}
</div>
{%- endif -%}
{% schema %}
{
"name": "YouTube video",
"target": "section",
"stylesheet": "youtube-embed.css",
"javascript": "youtube-embed.js",
"settings": [
{
"type": "header",
"content": "Video"
},
{
"type": "text",
"id": "video_url",
"label": "YouTube URL",
"info": "Paste any YouTube link — watch, youtu.be, Shorts, or embed."
},
{
"type": "text",
"id": "video_title",
"label": "Video title",
"info": "Used for the accessible label and the player title."
},
{
"type": "number",
"id": "start_seconds",
"label": "Start at (seconds)",
"default": 0
},
{
"type": "image_picker",
"id": "custom_thumbnail",
"label": "Custom thumbnail",
"info": "Optional. Defaults to the YouTube thumbnail."
},
{
"type": "header",
"content": "Text"
},
{
"type": "text",
"id": "heading",
"label": "Heading"
},
{
"type": "text",
"id": "caption",
"label": "Caption"
},
{
"type": "header",
"content": "Layout"
},
{
"type": "select",
"id": "aspect_ratio",
"label": "Aspect ratio",
"options": [
{ "value": "16 / 9", "label": "16:9 (landscape)" },
{ "value": "9 / 16", "label": "9:16 (Shorts)" },
{ "value": "4 / 3", "label": "4:3 (classic)" },
{ "value": "1 / 1", "label": "1:1 (square)" }
],
"default": "16 / 9"
},
{
"type": "range",
"id": "max_width",
"label": "Maximum width",
"min": 320,
"max": 1400,
"step": 20,
"unit": "px",
"default": 900
},
{
"type": "range",
"id": "corner_radius",
"label": "Corner radius",
"min": 0,
"max": 40,
"step": 2,
"unit": "px",
"default": 8
},
{
"type": "color",
"id": "play_button_color",
"label": "Play button color",
"default": "#ff0000"
},
{
"type": "range",
"id": "padding_top",
"label": "Top padding",
"min": 0,
"max": 100,
"step": 4,
"unit": "px",
"default": 24
},
{
"type": "range",
"id": "padding_bottom",
"label": "Bottom padding",
"min": 0,
"max": 100,
"step": 4,
"unit": "px",
"default": 24
}
]
}
{% endschema %}
extensions/youtube-embed/assets/youtube-embed.css
.ur-yt {
margin: 0 auto;
}
.ur-yt__heading {
max-width: var(--ur-yt-max-width, 900px);
margin: 0 auto 0.75rem;
text-align: center;
}
.ur-yt__frame {
position: relative;
width: 100%;
max-width: var(--ur-yt-max-width, 900px);
margin: 0 auto;
aspect-ratio: var(--ur-yt-ratio, 16 / 9);
border-radius: var(--ur-yt-radius, 8px);
overflow: hidden;
background-color: #000;
}
.ur-yt__frame > * {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ur-yt__iframe {
display: block;
border: 0;
}
.ur-yt__facade {
display: block;
padding: 0;
border: 0;
background-color: #000;
cursor: pointer;
appearance: none;
}
.ur-yt__thumb {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ur-yt__play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: 68px;
height: 48px;
border-radius: 14px;
background-color: var(--ur-yt-play, #ff0000);
color: #fff;
opacity: 0.9;
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.ur-yt__facade:hover .ur-yt__play,
.ur-yt__facade:focus-visible .ur-yt__play {
opacity: 1;
transform: translate(-50%, -50%) scale(1.08);
}
.ur-yt__facade:focus-visible {
outline: 3px solid currentColor;
outline-offset: -3px;
}
.ur-yt__caption {
max-width: var(--ur-yt-max-width, 900px);
margin: 0.75rem auto 0;
text-align: center;
font-size: 0.9rem;
opacity: 0.8;
}
.ur-yt__placeholder {
padding: 2rem;
border: 1px dashed currentColor;
border-radius: 8px;
text-align: center;
opacity: 0.6;
}
@media (prefers-reduced-motion: reduce) {
.ur-yt__play {
transition: none;
}
.ur-yt__facade:hover .ur-yt__play,
.ur-yt__facade:focus-visible .ur-yt__play {
transform: translate(-50%, -50%);
}
}
extensions/youtube-embed/assets/youtube-embed.js
(function () {
'use strict';
var EMBED_ORIGIN = 'https://www.youtube-nocookie.com';
var IFRAME_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
var connectionWarmed = false;
function warmConnection() {
if (connectionWarmed) return;
connectionWarmed = true;
var link = document.createElement('link');
link.rel = 'preconnect';
link.href = EMBED_ORIGIN;
link.crossOrigin = '';
document.head.appendChild(link);
}
function buildIframe(facade) {
var videoId = facade.getAttribute('data-video-id');
var start = parseInt(facade.getAttribute('data-start'), 10) || 0;
var params = ['autoplay=1', 'rel=0', 'modestbranding=1', 'playsinline=1'];
if (start > 0) {
params.push('start=' + start);
}
var iframe = document.createElement('iframe');
iframe.className = 'ur-yt__iframe';
iframe.src = EMBED_ORIGIN + '/embed/' + encodeURIComponent(videoId) + '?' + params.join('&');
iframe.title = facade.getAttribute('aria-label') || 'YouTube video player';
iframe.allow = IFRAME_ALLOW;
iframe.allowFullscreen = true;
iframe.setAttribute('frameborder', '0');
return iframe;
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!(target instanceof Element)) return;
var facade = target.closest('[data-ur-yt-facade]');
if (!facade) return;
var frame = facade.parentNode;
if (!frame) return;
var iframe = buildIframe(facade);
frame.replaceChild(iframe, facade);
iframe.focus();
});
['pointerover', 'focusin', 'touchstart'].forEach(function (eventName) {
document.addEventListener(
eventName,
function (event) {
var target = event.target;
if (target instanceof Element && target.closest('[data-ur-yt-facade]')) {
warmConnection();
}
},
{ passive: true, capture: true },
);
});
document.addEventListener(
'error',
function (event) {
var img = event.target;
if (!(img instanceof Element) || !img.classList.contains('ur-yt__thumb')) return;
var fallback = img.getAttribute('data-fallback');
if (!fallback) return;
img.removeAttribute('data-fallback');
img.src = fallback;
},
true,
);
})();
Tips and Common Pitfalls
maxresdefault.jpg returns 404 more often than you'd think. Any video uploaded below 720p — and plenty of older ones above it — only has hqdefault.jpg. Without the fallback handler, those blocks render a broken image icon on a black background. Always ship the fallback.
rel=0 no longer removes related videos. It used to. Since a 2018 change, YouTube reinterpreted the parameter: instead of hiding suggestions entirely, it limits them to videos from the same channel. Set the expectation with merchants — there's no supported way to fully suppress end-screen suggestions on a standard embed.
Vertical Shorts need a vertical frame. Embedding a Short at 16:9 gives you a tiny video floating in two black pillars. That's what the aspect ratio setting is for. If you want it automatic, you'd need the YouTube Data API to look up the video's dimensions server-side — a merchant-facing dropdown is far simpler and works offline.
youtube-nocookie.com is not a magic GDPR pass. It defers cookies until playback begins, which is genuinely better — but once someone presses play, YouTube sets its cookies as normal. If your merchants operate under strict consent regimes, the facade needs to sit behind their consent banner. Shopify's Customer Privacy API is the hook for that.
Don't hardcode <script src> in the Liquid. Use the "javascript" and "stylesheet" schema keys. If a merchant adds five video blocks to one page and you injected the tag manually, the browser parses five copies of your script and registers five sets of delegated listeners — meaning every click builds five iframes.
Test inside the theme editor, not just the storefront. The editor is where re-render bugs surface. Add the block, change a setting, drag it to a different position, then click play. If your code binds handlers directly to DOM nodes at load time, this is exactly where it breaks — and it's the first thing a merchant will do.
Set width and height on the thumbnail even though CSS overrides them. The attributes give the browser an intrinsic aspect ratio to reserve space with during layout, before the image bytes arrive. Skipping them is a free CLS penalty.
Watch out for themes that already lazy-load images. Some themes apply their own IntersectionObserver to every <img> and rewrite src/data-src. If your thumbnail disappears, check whether the theme's script has claimed it — namespacing your class (ur-yt__thumb rather than lazyload) usually keeps you out of the way.
Measure before and after. Run Lighthouse on a page with a standard embed, then the same page with the facade. Seeing total blocking time drop is the argument you'll use with the merchant, and it's more persuasive than any explanation of the pattern.
Conclusion
Embedding a YouTube video in Shopify is trivial. Embedding one without paying for it in page speed takes a bit more thought — but not much. The facade pattern is roughly 60 lines of JavaScript, and it turns a multi-hundred-kilobyte third-party dependency into a single lazy-loaded image.
To recap what we built:
- A Theme App Extension block merchants can drop into any section
- Liquid-only URL parsing that handles watch links, short links, Shorts, live URLs, embed URLs, and bare IDs
- A facade that renders a thumbnail and play button with zero third-party JavaScript
- Click-to-load with autoplay that works because the browser sees a genuine user gesture
- Connection warming on hover, so the click feels instant
- Responsive aspect ratios with no layout shift, plus keyboard and screen reader support
- Theme editor settings for URL, thumbnail, ratio, width, radius, colors, and spacing
Take the complete files above and adapt them. The pattern generalizes too — Vimeo, Wistia, Google Maps, and Twitter embeds all benefit from exactly the same treatment.
And if you'd rather not maintain this yourself, we ship it as an app.
UR: Smart YouTube Embed
If you're a merchant reading this — or a developer whose client wants video on ten pages by Friday — UR: Smart YouTube Embed does everything above with no code.
- Paste any YouTube URL — watch links, short links, Shorts, playlists — and the app handles the rest
- Lazy-loaded by default, so videos never drag down your Online Store Speed score
- Fully responsive across desktop, tablet, and mobile, with aspect ratio control for vertical Shorts
- Customize everything from the theme editor — thumbnail, play button, size, corners, spacing — with a live preview
- Add videos anywhere — product pages, collection pages, the homepage, or any custom section
- Built as a Theme App Extension, so nothing is left behind in your theme if you uninstall
It's built by UnReact, a Shopify app development studio. We build our apps the way this article describes: performance-first, accessible, and native to the theme editor.
Build it yourself with the code above, or install the app and skip to the part where it's done. Either way, your videos shouldn't cost you your page speed.
Top comments (0)