§24 / DEVELOPERS
REF: TG–DOC–24 · REV. 1.7
Web components
HTMLElement. There are no dependencies and no build step. Markup is rendered by Liquid first: forms post and links navigate without JavaScript. The custom elements add AJAX behaviour on top.TANGENT 1.7 / SHOPIFY OS 2.0 / UPDATED 2026-10-19
Architecture overview
assets/theme.jsis the core script. It is loaded on every page as a module fromlayout/theme.liquid.theme.jsdefines 10 custom elements and exposes shared helpers onwindow.Tangent.- 9 further modules (
product.js,cart.js,search.js,facets.js,extras.js,cards.js,recent.js,promo.js,compare.js) define the other 33 elements. Each module is imported only when one of its elements is on the page. - Module URLs come from the
modulesobject in thetheme-configJSON block (snippets/theme-config.liquid).
1graph TD2 subgraph Core ["assets/theme.js"]3 API["window.Tangent (helpers, cart)"]4 Inline["drawer-dialog, cart-drawer, footer-info, quantity-input, product-form,<br/>site-header, menu-drawer, localization-form, video-media, deferred-media"]5 Loader["loadModulesFor() + MutationObserver"]6 end7 subgraph Modules ["On-demand modules"]8 M1["product.js"]9 M2["cart.js"]10 M3["search.js"]11 M4["facets.js"]12 M5["extras.js"]13 M6["cards.js"]14 M7["recent.js"]15 M8["promo.js"]16 M9["compare.js"]17 end18 Loader -->|"import() when a mapped tag is in the DOM"| ModulesModule loader
theme.js maps tag names to module names in a lazyModules table. loadModulesFor() checks the DOM for each tag and calls import() on the module URL from config.modules. A MutationObserver on document.documentElement runs the same check on added nodes, so markup inserted later (for example by the Section Rendering API) also loads its module. The observer disconnects once all modules are loaded.
Elements with the data-module-defer attribute are skipped on sight. The header's <predictive-search> uses it. Its module loads when the drawer opens (DrawerDialog.open() calls wakeDeferred()), or earlier on pointerover, focusin or touchstart on a [data-open] button that targets the drawer.
1// assets/theme.js (excerpt)2function loadModulesFor(root = document, includeDeferred = false) {3 Object.entries(lazyModules).forEach(([tag, name]) => {4 if (loadedModules.has(name) || !config.modules?.[name]) return;5 const selector = includeDeferred ? tag : `${tag}:not([data-module-defer])`;6 if (root.querySelector?.(selector) || root.matches?.(selector)) {7 loadedModules.add(name);8 import(config.modules[name]).catch(() => loadedModules.delete(name));9 }10 });11}loadModulesFor and wakeDeferred are internal to theme.js. They are not on window.Tangent.
Element index
All elements registered with customElements.define() in assets/*.js.
| Tag | Class | File | Purpose |
|---|---|---|---|
drawer-dialog | DrawerDialog | theme.js | Wraps a native <dialog>; base class for drawers and the quick-add modal |
cart-drawer | CartDrawer | theme.js | Cart drawer; extends DrawerDialog with no extra code |
footer-info | FooterInfo | theme.js | Drawer that loads /pages/* content from footer links |
quantity-input | QuantityInput | theme.js | Plus/minus buttons for a number input |
product-form | ProductForm | theme.js | AJAX add to cart |
site-header | SiteHeader | theme.js | Menus, sticky and transparent header, sets --header-height |
menu-drawer | MenuDrawer | theme.js | Mobile menu with nested panels; extends DrawerDialog |
localization-form | LocalizationForm | theme.js | Country and language selectors |
video-media | VideoMedia | theme.js | Native video with play/pause toggle |
deferred-media | DeferredMedia | theme.js | YouTube/Vimeo embed loaded on click |
variant-picker | VariantPicker | product.js | Variant selection via the Section Rendering API |
media-gallery | MediaGallery | product.js | Product media track, thumbnails and counter |
pickup-availability | PickupAvailability | product.js | Loads the pickup-availability section |
share-button | ShareButton | product.js | Web Share API, clipboard fallback |
sticky-buy | StickyBuy | product.js | Sticky add-to-cart bar |
quick-order | QuickOrder | product.js | Quick order grid totals |
size-guide | SizeGuide | product.js | Size chart with cm/in switch |
delivery-estimate | DeliveryEstimate | product.js | Delivery date from shipping times and cutoff |
cart-items | CartItems | cart.js | Line quantity changes and removal |
cart-note | CartNote | cart.js | Saves the cart note |
gift-wrap | GiftWrap | cart.js | Adds or removes a gift-wrap variant |
cart-recommendations | CartRecommendations | cart.js | Loads the cart-recommendations section |
predictive-search | PredictiveSearch | search.js | Search suggestions |
facet-filters | FacetFilters | facets.js | Collection and search filtering and sorting |
price-range | PriceRange | facets.js | Two-thumb price slider |
grid-density | GridDensity | facets.js | Products-per-row switch |
load-more | LoadMore | facets.js | Load more button or infinite scroll |
quick-add | QuickAdd | extras.js | Opens the quick-add modal |
slider-component | SliderComponent | extras.js | Scroll-snap slider with prev/next |
product-recommendations | ProductRecommendations | extras.js | Related and complementary products |
collection-index | CollectionIndex | extras.js | Collection list with hover preview |
slide-show | SlideShow | extras.js | Slideshow with dots and autoplay |
video-reel | VideoReel | extras.js | Shoppable video reels |
scroll-text | ScrollText | extras.js | Pause button for scrolling text |
style-quiz | StyleQuiz | extras.js | Style quiz |
size-picker | SizePicker | extras.js | Styles a native select as a listbox |
product-card | ProductCard | cards.js | Card swatches, sizes and hover video |
recent-track | RecentTrack | recent.js | Records the viewed product handle |
recently-viewed | RecentlyViewed | recent.js | Renders recently viewed cards |
promo-popup | PromoPopup | promo.js | Promotional pop-up |
count-down | CountDown | promo.js | Countdown timer |
age-gate | AgeGate | promo.js | Age verification |
compare-slider | CompareSlider | compare.js | Before/after image comparison |
price-range is not in the loader table. It is defined when facets.js loads for facet-filters, grid-density or load-more.
Core elements
<drawer-dialog> and <cart-drawer>
- Classes:
DrawerDialog,CartDrawer extends DrawerDialog - File:
assets/theme.js
DrawerDialog wraps the first <dialog> inside it. CartDrawer adds nothing to it. MenuDrawer and FooterInfo also extend it, and the class is exposed as window.Tangent.DrawerDialog.
The cart drawer markup (sections/cart-drawer.liquid):
1<cart-drawer id="CartDrawer">2 <dialog class="drawer cart-drawer color-…" aria-labelledby="CartDrawerTitle">3 <div class="drawer__header">4 <h2 id="CartDrawerTitle" class="h3" tabindex="-1" data-cart-title>…</h2>5 <button type="button" class="icon-button" data-close aria-label="Close">…</button>6 </div>7 <div class="drawer__body cart-drawer__body" data-cart-root>8 <!-- snippets/cart-items.liquid -->9 </div>10 </dialog>11</cart-drawer>The section is rendered in layout/theme.liquid only when Cart type is Drawer and the page is not the cart page.
| Property | Type | Description |
|---|---|---|
dialog | HTMLDialogElement | null | The inner <dialog> |
opener | HTMLElement | Element that receives focus when the dialog closes |
| Method | Description |
|---|---|
open(opener?) | Stores opener (default document.activeElement), calls showModal() if not open, and loads deferred modules inside the drawer |
close() | Calls dialog.close() |
Behaviour:
- A click on the dialog backdrop or on a
[data-close]element closes it. - On close, focus returns to
opener. If the opener was re-rendered, the theme looks it up again with[data-open="<drawer id>"]. - A delegated click handler opens
<cart-drawer>from any[data-cart-toggle]link when Cart type isdrawer. - Any
[data-open="<id>"]button opens the element with that id if it has anopen()method. - The drawer does not listen for
cart:updated. The cart contents are replaced byTangent.cart.render()(see Section Rendering API).
1// Open the cart drawer from custom code2document.querySelector('cart-drawer')?.open(document.querySelector('#my-button'));34// Or respect the merchant's cart type (drawer or cart page)5window.Tangent.cart.open();<predictive-search>
- Class:
PredictiveSearch - File:
assets/search.js(on demand)
| Attribute | Description |
|---|---|
data-enabled | "false" disables the element. The header sets it from the Enable search suggestions setting (predictive_search). |
data-module-defer | Set in the header so search.js loads only when the search drawer is about to open. |
| Property | Type | Description |
|---|---|---|
input | HTMLInputElement | The input[type="search"] |
results | HTMLElement | The [data-search-results] container |
abort | AbortController | Cancels the previous request |
| Method | Description |
|---|---|
search() | Fetches routes.predictiveSearch with section_id=predictive-search and resources[type]=query,product,collection,page,article, resources[limit]=6, resources[limit_scope]=each. Called on input, debounced 220 ms. |
onKey(e) | ArrowUp/ArrowDown move aria-selected across [role="option"] items and set aria-activedescendant on the input |
Accessibility behaviour in the code:
- The input's
aria-expandedis set to"true"or"false"when results show or clear. - The result count is announced with
window.Tangent.announce()using thesuggestionsstring fromtheme-config. - Enter on a highlighted option follows its link.
<media-gallery>
- Class:
MediaGallery - File:
assets/product.js(on demand)
| Property | Type | Description |
|---|---|---|
track | HTMLElement | null | [data-gallery-track] |
counter | HTMLElement | null | [data-gallery-current] |
thumbs | NodeList | [data-thumb] elements; the value is a media id |
| Method | Description |
|---|---|
goTo(mediaId, smooth = true) | Scrolls to [data-media-id="<mediaId>"]. Uses instant scrolling when smooth is false or reduced motion is on. Sets aria-current on thumbnails. |
syncCounter() | Writes the current slide index into the counter |
The gallery listens for variant:changed on document. If the event's root contains the gallery, it calls goTo(root.dataset.featuredMediaId, false):
1// assets/product.js2this.onVariant = (e) => {3 if (!e.detail.root?.contains(this)) return;4 const mediaId = e.detail.root.dataset.featuredMediaId;5 if (mediaId) this.goTo(mediaId, false);6};7document.addEventListener('variant:changed', this.onVariant);<quick-add> and the quick-add modal
- Class:
QuickAdd - File:
assets/extras.js(on demand)
<quick-add> is rendered in product cards and the complete-the-look snippet. It pairs with a shared <drawer-dialog id="QuickAddModal"> from snippets/quick-add-modal.liquid, which is rendered only when the Enable quick add product card setting (card_quick_add) is on.
1<!-- snippets/product-card.liquid -->2<quick-add class="card__quick" data-url="/products/example">3 <button type="button">…</button>4</quick-add>56<!-- snippets/quick-add-modal.liquid -->7<drawer-dialog id="QuickAddModal">8 <dialog class="drawer quick-add-modal" aria-label="Choose options">9 <div class="drawer__header">10 <span class="label">Choose options</span>11 <button type="button" class="icon-button" data-close aria-label="Close">…</button>12 </div>13 <div class="drawer__body" data-quick-add-body></div>14 </dialog>15</drawer-dialog>| Method | Description |
|---|---|
open() | Fetches data-url with section_id=quick-add, copies [data-quick-add-content] into [data-quick-add-body], sets the dialog's aria-label to the product title, and opens the modal |
fallback() | Navigates to data-url |
Error handling:
- The request is aborted after 10 seconds.
- If the modal is missing,
fallback()runs immediately. - On any other error,
toast(strings.quickAddError, { error: true })shows, andfallback()runs after 1.2 seconds. - After a successful load it calls
window.Shopify?.PaymentButton?.init?.()andwindow.Tangent.loadModels(body).
<video-media>
- Class:
VideoMedia - File:
assets/theme.js
| Attribute | Description |
|---|---|
data-autoplay | "true" requests autoplay |
On the toggle button ([data-video-toggle]), data-play-label and data-pause-label supply the accessible labels.
| Method | Description |
|---|---|
sync() | Sets the toggle's aria-label to the next action, removes aria-pressed, and toggles is-paused |
Behaviour:
- Autoplay is off when
prefers-reduced-motion: reducematches or<html>has thesave-dataclass. In that casepreloadis set tonone. layout/theme.liquidaddssave-datawhennavigator.connection.saveDatais true orprefers-reduced-data: reducematches.- With autoplay, an
IntersectionObserverplays the video in view and pauses it out of view, unless the shopper paused it.
The window.Tangent object
theme.js assigns this object at load. On-demand modules destructure their helpers from it.
| Member | Description |
|---|---|
config, routes, strings | Parsed theme-config JSON, its routes and strings |
prefersReducedMotion() | true if prefers-reduced-motion: reduce matches |
saveData() | true if <html> has the save-data class |
debounce(fn, wait = 300) | Debounce helper |
parseHTML(text) | DOMParser to a Document |
swapParts(target, source, keys?) | Replaces [data-morph] elements in target with their counterparts from source |
withTransition(update) | Runs update inside document.startViewTransition() when supported and motion is allowed |
toast(message, { error }) | Shows the [data-toast] element and announces the message |
announce(message, error = false) | Writes to the [data-announce] (polite) or [data-announce-error] (assertive) live region |
cart | Cart queue and rendering (request, send, render, refresh, count, open, sectionIds, queue) |
reveal(scope) | Starts scroll-reveal for .reveal elements in scope |
DrawerDialog | The drawer base class |
sizes | Remembered size choices in localStorage (get(), remember(value)) |
loadModels(root) | Loads Shopify's model viewer UI and AR for 3D models in root |
There is no Tangent.cart.queue method; queue is the internal promise chain.
Writing your own element
The theme does not provide a registration API. Define elements with the standard customElements.define() in your own asset, and use window.Tangent helpers after theme.js has run (it loads as a deferred module).
1// assets/custom-badge.js2class CustomBadge extends HTMLElement {3 connectedCallback() {4 this.button = this.querySelector('button');5 this.button?.addEventListener('click', () => window.Tangent?.toast('Badge clicked'));67 this.onCartUpdated = () => {8 this.dataset.count = String(window.Tangent?.cart.count() ?? 0);9 };10 document.addEventListener('cart:updated', this.onCartUpdated);11 }1213 disconnectedCallback() {14 document.removeEventListener('cart:updated', this.onCartUpdated);15 }16}1718customElements.define('custom-badge', CustomBadge);Last updated for Tangent 1.7