Web Components

Building a Web Component: Custom Elements, Shadow DOM and Slots for Shopify Themes

A hands-on web components tutorial with working code, plus the patterns Shopify's own Dawn and Horizon themes use for interactive UI.

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-input and menu-drawer. Horizon builds on a base Component class with declarative shadow DOM support.
  • Custom elements suit the Shopify theme editor because connectedCallback and disconnectedCallback run 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 guard customElements.define so 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:

  1. Custom elements. A JavaScript class that extends HTMLElement, registered with customElements.define(). The tag name must start with a lowercase letter and contain a hyphen, such as quantity-stepper.
  2. 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).
  3. <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. connectedCallback runs 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 this bound and gives a stable reference, so adding the listener twice doesn't register it twice.
  • The customElements.get guard matters because calling define twice 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 (or type="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 named icon slot picks up any child with slot="icon".
  • :host styles 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

QuestionLight DOM (Dawn style)Shadow DOM
Can theme CSS and the theme editor style it?Yes, fullyOnly through custom properties and ::part
Works before JavaScript loads?Yes, if Liquid renders the markupOnly with declarative shadow DOM
Protected from conflicting styles?NoYes
Best forTheme sections, product forms, navigationApp widgets, embeds, design-system primitives
SEO and accessibilityContent is in the main DOMSlotted 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 on document (for example a cart:updated event). 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

  1. 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 with defer.
  2. Render the markup in Liquid first, so the page works and is crawlable before JavaScript runs.
  3. Guard every customElements.define call.
  4. Handle editor-only behavior with shopify:section:select and shopify:block:select, and check Shopify.designMode.
  5. Lint your Liquid with Theme Check and preview with shopify theme dev before 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

Keep reading