Script&Theory

§24 / DEVELOPERS

REF: TG–DOC–24 · REV. 1.7

Web components

Tangent's storefront JavaScript is plain custom elements that extend 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.js is the core script. It is loaded on every page as a module from layout/theme.liquid.
  • theme.js defines 10 custom elements and exposes shared helpers on window.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 modules object in the theme-config JSON block (snippets/theme-config.liquid).
example.mmd
1graph TD
2 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 end
7 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 end
18 Loader -->|"import() when a mapped tag is in the DOM"| Modules
mermaid

Module 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.

example.js
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}
javascript

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.

TagClassFilePurpose
drawer-dialogDrawerDialogtheme.jsWraps a native <dialog>; base class for drawers and the quick-add modal
cart-drawerCartDrawertheme.jsCart drawer; extends DrawerDialog with no extra code
footer-infoFooterInfotheme.jsDrawer that loads /pages/* content from footer links
quantity-inputQuantityInputtheme.jsPlus/minus buttons for a number input
product-formProductFormtheme.jsAJAX add to cart
site-headerSiteHeadertheme.jsMenus, sticky and transparent header, sets --header-height
menu-drawerMenuDrawertheme.jsMobile menu with nested panels; extends DrawerDialog
localization-formLocalizationFormtheme.jsCountry and language selectors
video-mediaVideoMediatheme.jsNative video with play/pause toggle
deferred-mediaDeferredMediatheme.jsYouTube/Vimeo embed loaded on click
variant-pickerVariantPickerproduct.jsVariant selection via the Section Rendering API
media-galleryMediaGalleryproduct.jsProduct media track, thumbnails and counter
pickup-availabilityPickupAvailabilityproduct.jsLoads the pickup-availability section
share-buttonShareButtonproduct.jsWeb Share API, clipboard fallback
sticky-buyStickyBuyproduct.jsSticky add-to-cart bar
quick-orderQuickOrderproduct.jsQuick order grid totals
size-guideSizeGuideproduct.jsSize chart with cm/in switch
delivery-estimateDeliveryEstimateproduct.jsDelivery date from shipping times and cutoff
cart-itemsCartItemscart.jsLine quantity changes and removal
cart-noteCartNotecart.jsSaves the cart note
gift-wrapGiftWrapcart.jsAdds or removes a gift-wrap variant
cart-recommendationsCartRecommendationscart.jsLoads the cart-recommendations section
predictive-searchPredictiveSearchsearch.jsSearch suggestions
facet-filtersFacetFiltersfacets.jsCollection and search filtering and sorting
price-rangePriceRangefacets.jsTwo-thumb price slider
grid-densityGridDensityfacets.jsProducts-per-row switch
load-moreLoadMorefacets.jsLoad more button or infinite scroll
quick-addQuickAddextras.jsOpens the quick-add modal
slider-componentSliderComponentextras.jsScroll-snap slider with prev/next
product-recommendationsProductRecommendationsextras.jsRelated and complementary products
collection-indexCollectionIndexextras.jsCollection list with hover preview
slide-showSlideShowextras.jsSlideshow with dots and autoplay
video-reelVideoReelextras.jsShoppable video reels
scroll-textScrollTextextras.jsPause button for scrolling text
style-quizStyleQuizextras.jsStyle quiz
size-pickerSizePickerextras.jsStyles a native select as a listbox
product-cardProductCardcards.jsCard swatches, sizes and hover video
recent-trackRecentTrackrecent.jsRecords the viewed product handle
recently-viewedRecentlyViewedrecent.jsRenders recently viewed cards
promo-popupPromoPopuppromo.jsPromotional pop-up
count-downCountDownpromo.jsCountdown timer
age-gateAgeGatepromo.jsAge verification
compare-sliderCompareSlidercompare.jsBefore/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):

example.html
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>
html

The section is rendered in layout/theme.liquid only when Cart type is Drawer and the page is not the cart page.

PropertyTypeDescription
dialogHTMLDialogElement | nullThe inner <dialog>
openerHTMLElementElement that receives focus when the dialog closes
MethodDescription
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 is drawer.
  • Any [data-open="<id>"] button opens the element with that id if it has an open() method.
  • The drawer does not listen for cart:updated. The cart contents are replaced by Tangent.cart.render() (see Section Rendering API).
example.js
1// Open the cart drawer from custom code
2document.querySelector('cart-drawer')?.open(document.querySelector('#my-button'));
3
4// Or respect the merchant's cart type (drawer or cart page)
5window.Tangent.cart.open();
javascript
  • Class: PredictiveSearch
  • File: assets/search.js (on demand)
AttributeDescription
data-enabled"false" disables the element. The header sets it from the Enable search suggestions setting (predictive_search).
data-module-deferSet in the header so search.js loads only when the search drawer is about to open.
PropertyTypeDescription
inputHTMLInputElementThe input[type="search"]
resultsHTMLElementThe [data-search-results] container
abortAbortControllerCancels the previous request
MethodDescription
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-expanded is set to "true" or "false" when results show or clear.
  • The result count is announced with window.Tangent.announce() using the suggestions string from theme-config.
  • Enter on a highlighted option follows its link.
  • Class: MediaGallery
  • File: assets/product.js (on demand)
PropertyTypeDescription
trackHTMLElement | null[data-gallery-track]
counterHTMLElement | null[data-gallery-current]
thumbsNodeList[data-thumb] elements; the value is a media id
MethodDescription
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):

example.js
1// assets/product.js
2this.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);
javascript

<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.

example.html
1<!-- snippets/product-card.liquid -->
2<quick-add class="card__quick" data-url="/products/example">
3 <button type="button">…</button>
4</quick-add>
5
6<!-- 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>
html
MethodDescription
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, and fallback() runs after 1.2 seconds.
  • After a successful load it calls window.Shopify?.PaymentButton?.init?.() and window.Tangent.loadModels(body).

<video-media>

  • Class: VideoMedia
  • File: assets/theme.js
AttributeDescription
data-autoplay"true" requests autoplay

On the toggle button ([data-video-toggle]), data-play-label and data-pause-label supply the accessible labels.

MethodDescription
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: reduce matches or <html> has the save-data class. In that case preload is set to none.
  • layout/theme.liquid adds save-data when navigator.connection.saveData is true or prefers-reduced-data: reduce matches.
  • With autoplay, an IntersectionObserver plays 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.

MemberDescription
config, routes, stringsParsed 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
cartCart queue and rendering (request, send, render, refresh, count, open, sectionIds, queue)
reveal(scope)Starts scroll-reveal for .reveal elements in scope
DrawerDialogThe drawer base class
sizesRemembered 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).

example.js
1// assets/custom-badge.js
2class CustomBadge extends HTMLElement {
3 connectedCallback() {
4 this.button = this.querySelector('button');
5 this.button?.addEventListener('click', () => window.Tangent?.toast('Badge clicked'));
6
7 this.onCartUpdated = () => {
8 this.dataset.count = String(window.Tangent?.cart.count() ?? 0);
9 };
10 document.addEventListener('cart:updated', this.onCartUpdated);
11 }
12
13 disconnectedCallback() {
14 document.removeEventListener('cart:updated', this.onCartUpdated);
15 }
16}
17
18customElements.define('custom-badge', CustomBadge);
javascript

Last updated for Tangent 1.7

Get support ↗