Every Shopify theme ends up with the same handful of interactive pieces: quantity pickers, drawers, variant selectors, sliders, badges. If each one is a loose bundle of HTML, CSS and a script that runs once on page load, the theme gets fragile fast, especially in the theme editor, where sections re-render constantly. This tutorial is for theme developers and technical store owners who want reusable components built on the browser's own APIs, the same approach Shopify uses in Dawn and Horizon.
Key takeaways
- Web components are three browser standards: custom elements (your own HTML tags with behavior), shadow DOM (scoped markup and styles), and HTML templates and slots (reusable markup with insertion points).
- Shopify's Dawn theme uses custom elements in the light DOM for things like
quantity-inputandmenu-drawer. Horizon builds on a baseComponentclass with declarative shadow DOM support. - Custom elements suit the Shopify theme editor because
connectedCallbackanddisconnectedCallbackrun whenever a section is re-rendered. You don't need to wire up re-initialization by hand. - Do setup in
connectedCallback, not the constructor, and guardcustomElements.defineso a script that loads twice doesn't throw. - Light DOM is usually the better choice for theme UI that merchants restyle. Shadow DOM is better for self-contained widgets that must not inherit or leak styles.
What web components actually are
MDN describes web components as a suite of technologies for creating reusable custom elements whose functionality is kept apart from the rest of your code. In practice you will use three APIs:
- Custom elements. A JavaScript class that extends
HTMLElement, registered withcustomElements.define(). The tag name must start with a lowercase letter and contain a hyphen, such asquantity-stepper. - Shadow DOM. A separate DOM tree attached to an element. Styles inside it don't leak out, and page styles don't leak in (inherited properties and CSS custom properties still pass through).
<template>and<slot>. A template holds inert markup you can clone. A slot marks where the element's light-DOM children appear inside its shadow tree.
They work in every modern browser and need no framework or build step. There is one exception worth knowing: customized built-in elements (<p is="word-count">) are not supported in Safari, and MDN notes Safari has no plans to add them. Stick to autonomous custom elements that extend HTMLElement.
Step 1: A light-DOM custom element (the Dawn pattern)
Start with the most common Shopify case: progressive enhancement of markup that Liquid already rendered. The HTML works without JavaScript (the shopper can still type a number), and the element adds the plus and minus buttons' behavior.
<quantity-stepper>
<button type="button" name="minus" aria-label="Decrease quantity">−</button>
<input type="number" name="quantity" value="1" min="1" max="10" aria-label="Quantity">
<button type="button" name="plus" aria-label="Increase quantity">+</button>
</quantity-stepper>class QuantityStepper extends HTMLElement {
connectedCallback() {
this.input = this.querySelector('input');
this.addEventListener('click', this.onClick);
}
disconnectedCallback() {
this.removeEventListener('click', this.onClick);
}
onClick = (event) => {
const button = event.target.closest('button');
if (!button || !this.input) return;
const previous = this.input.value;
if (button.name === 'plus') this.input.stepUp();
if (button.name === 'minus') this.input.stepDown();
// stepUp/stepDown respect min and max, so only announce real changes.
if (this.input.value !== previous) {
this.input.dispatchEvent(new Event('change', { bubbles: true }));
}
};
}
if (!customElements.get('quantity-stepper')) {
customElements.define('quantity-stepper', QuantityStepper);
}A few details here matter:
- Setup lives in
connectedCallback. MDN advises against reading attributes or children in the constructor, because they may not be available yet.connectedCallbackruns each time the element is inserted into the document. - Cleanup lives in
disconnectedCallback. That is where you remove listeners and observers. - The arrow-function class field keeps
thisbound and gives a stable reference, so adding the listener twice doesn't register it twice. - The
customElements.getguard matters because callingdefinetwice with the same name throws. On Shopify that can happen when a script is included from more than one section. - Load the script with
defer(ortype="module", which defers by default) so the element's children are parsed before your code queries them.
This is essentially how Dawn's global.js is written. It defines light-DOM elements such as quantity-input, menu-drawer, modal-dialog, deferred-media and slider-component, and each one finds its children with this.querySelector(). None of them attach a shadow root.
Step 2: Why custom elements fit the Shopify theme editor
Shopify's theme editor docs point out that when a merchant edits a section, the editor adds, removes or re-renders that section's HTML in the existing page. Any JavaScript that ran on page load won't run again. The traditional fix is to listen for shopify:section:load and shopify:section:unload and re-initialize by hand.
Custom elements handle much of this for free. When re-rendered HTML containing <quantity-stepper> is inserted, the browser creates a new instance and calls connectedCallback. When the old markup is removed, disconnectedCallback runs. Your component re-initializes and cleans up without a central "init everything" function. You still need the editor events for editor-only behavior, such as opening a drawer when its block is selected (shopify:block:select). You can check Shopify.designMode to skip code that should only run on the live storefront.
The same applies to the Section Rendering API, which returns fresh section HTML over AJAX for things like cart drawers and filtered collections. Swap the HTML in, and the components inside it initialize themselves.
Step 3: Shadow DOM, templates and slots
Sometimes you want real encapsulation: a badge or widget whose styles can't be broken by the theme and can't break the theme either. App developers who inject UI into many different themes feel this most. That is the job of shadow DOM.
<template id="product-badge-template">
<style>
:host {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--badge-bg, #111);
color: var(--badge-color, #fff);
font: 600 0.75rem/1.4 system-ui, sans-serif;
}
:host([tone="sale"]) { --badge-bg: #b00020; }
::slotted(svg) { width: 1em; height: 1em; }
</style>
<slot name="icon"></slot>
<slot>New</slot>
</template>
<product-badge tone="sale">
<svg slot="icon" viewBox="0 0 16 16" aria-hidden="true">
<circle cx="8" cy="8" r="6" fill="currentColor"></circle>
</svg>
20% off
</product-badge>const badgeTemplate = document.getElementById('product-badge-template');
class ProductBadge extends HTMLElement {
constructor() {
super();
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' })
.append(badgeTemplate.content.cloneNode(true));
}
}
}
if (!customElements.get('product-badge')) {
customElements.define('product-badge', ProductBadge);
}What is happening:
attachShadow({ mode: 'open' })creates the shadow root. Creating it in the constructor is allowed, and MDN lists it as an appropriate constructor task.template.content.cloneNode(true)stamps out a fresh copy of the template for each badge.- The unnamed
<slot>shows whatever text you put inside<product-badge>and falls back to "New" when it's empty. The namediconslot picks up any child withslot="icon". :hoststyles the element itself, and:host([tone="sale"])styles it based on an attribute.::slotted(svg)styles slotted children, but only the top-level slotted nodes.- CSS custom properties pass through the shadow boundary, which is how the theme can still theme the badge:
product-badge { --badge-bg: var(--color-accent); }.
Step 4: Reacting to attributes
Liquid renders attributes, so attribute-driven components map neatly onto theme data. Here is a low-stock message that reads a count attribute:
class LowStock extends HTMLElement {
static observedAttributes = ['count'];
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; font-size: 0.875rem; }
:host([hidden]) { display: none; }
</style>
<p part="message"></p>
`;
}
connectedCallback() {
if (!this.hasAttribute('count')) this.hidden = true;
}
attributeChangedCallback(name, oldValue, newValue) {
const count = Number(newValue);
this.hidden = !(count > 0 && count <= 5);
this.shadowRoot.querySelector('p').textContent = `Only ${count} left in stock`;
}
}
if (!customElements.get('low-stock')) {
customElements.define('low-stock', LowStock);
}<low-stock count="{{ product.selected_or_first_available_variant.inventory_quantity }}"></low-stock>attributeChangedCallback fires only for attributes listed in observedAttributes. It also fires once when the element is parsed with the attribute already present, so the initial render is handled. When your variant-change script updates count, the message updates too. The part="message" attribute lets the theme style the inner paragraph from outside with low-stock::part(message) { color: var(--color-error); }. That only means anything if the product tracks inventory.
Step 5: Declarative shadow DOM (the Horizon direction)
The weakness of the example above is that the shadow content only exists after JavaScript runs. Declarative shadow DOM lets the server send the shadow root as HTML, using a <template shadowrootmode="open"> that the parser attaches straight to its parent. web.dev reports it reached Baseline "Newly available" in August 2024, with support in Chrome and Edge 111, Firefox 123 and Safari 16.4.
<product-badge>
<template shadowrootmode="open">
<style>:host { font-weight: 600; }</style>
<slot></slot>
</template>
Back in stock
</product-badge>The if (!this.shadowRoot) check in ProductBadge is what makes this work. When the parser has already attached a declarative root, the component reuses it instead of creating a second one. There is one catch: setting innerHTML does not parse declarative shadow roots, so markup injected later needs handling. Shopify's Horizon theme deals with this in a DeclarativeShadowElement base class. Its Component class extends that base, collects child elements marked with ref attributes, and adds an updatedCallback hook that runs when the Section Rendering API re-renders a component. (Horizon's source changes often, so read the current assets/component.js before you copy its patterns.)
Light DOM vs. shadow DOM for Shopify work
| Question | Light DOM (Dawn style) | Shadow DOM |
|---|---|---|
| Can theme CSS and the theme editor style it? | Yes, fully | Only through custom properties and ::part |
| Works before JavaScript loads? | Yes, if Liquid renders the markup | Only with declarative shadow DOM |
| Protected from conflicting styles? | No | Yes |
| Best for | Theme sections, product forms, navigation | App widgets, embeds, design-system primitives |
| SEO and accessibility | Content is in the main DOM | Slotted content stays in the light DOM; test focus and labels |
My default for theme work is light DOM. Merchants and future developers expect to restyle theme UI with ordinary CSS. I use shadow DOM when a component has to look the same whatever theme it lands in.
Pro tip: Keep components small and talk between them with
CustomEvents dispatched ondocument(for example acart:updatedevent). Shopify's reference themes also have components talk through events rather than calling each other directly, which keeps one component out of another's internals.
Shipping components in a Shopify theme
- Put each component in its own file in
assets/(or in a section's{% javascript %}tag for section-specific code). Shopify loads bundled{% javascript %}content withdefer. - Render the markup in Liquid first, so the page works and is crawlable before JavaScript runs.
- Guard every
customElements.definecall. - Handle editor-only behavior with
shopify:section:selectandshopify:block:select, and checkShopify.designMode. - Lint your Liquid with Theme Check and preview with
shopify theme devbefore anything reaches the live theme.
For the performance side of shipping JavaScript, see my guide to Shopify site speed and Core Web Vitals. For how themes compare with custom React storefronts, read headless Shopify vs. Liquid themes.
FAQ
Do web components work in all browsers?
Autonomous custom elements, shadow DOM, templates and slots work in all current major browsers. Customized built-in elements (the is attribute) don't work in Safari, so avoid them.
Does Shopify require web components in themes?
No. Shopify doesn't require any particular JavaScript approach. Its reference themes use custom elements, though, and following that pattern makes your code easier for other Shopify developers to pick up.
Should I use shadow DOM in a Shopify theme?
Use it sparingly. Light DOM keeps components easy to restyle and works with the theme editor's settings. Shadow DOM fits widgets that need strict style isolation.
Are web components bad for SEO?
Not if the content is in the HTML. Render meaningful content with Liquid in the light DOM, or with declarative shadow DOM, rather than injecting it only with JavaScript.
Can I use a framework like Lit instead?
Yes. Lit and similar libraries are thin layers over the same standards. For most theme components, the plain APIs shown here are enough and add no extra JavaScript.
Next steps
Pick one piece of interactive UI in your theme that breaks in the theme editor or duplicates code across sections, and rebuild it as a custom element using the light-DOM pattern above. Once that works, move to shared events and, where it helps, declarative shadow DOM. If you want a second opinion on your theme's JavaScript architecture, book a Shopify consultation.
Sources
- MDN: Web Components
- MDN: Using custom elements
- web.dev: Declarative Shadow DOM
- Shopify Dawn theme source: assets/global.js
- Shopify Horizon theme source: assets/component.js
- Shopify.dev: Integrate sections and blocks with the theme editor
- Shopify.dev: Section Rendering API
- Shopify.dev: JavaScript and stylesheet tags
- Shopify.dev: Theme Check
