# JavaScript components

The custom elements and data attributes in `assets/techno.js` and in the theme's `{% javascript %}` tags, how they enhance the page, and which ones you can use in custom code.

Techno's JavaScript is `assets/techno.js`, loaded deferred, plus the `{% javascript %}` tags in the header section, `sections/announcement-bar.liquid`, `sections/quick-order-list.liquid`, `sections/slideshow.liquid`, `blocks/_buy_buttons.liquid`, `snippets/back-to-top.liquid`, `snippets/product-media.liquid`, `snippets/quick-view-drawer.liquid`, `snippets/card-swatches.liquid`, `sections/compare.liquid` and `snippets/compare-tray.liquid`, which Shopify bundles into one deferred file for section scripts, one for block scripts and one for snippet scripts, loaded after `techno.js`. A bundle holds every such file's script, but each file's script runs only on pages that render that file, and in the theme editor. HTML that arrives later, such as recommendations or the quick view's content, doesn't bring a script with it. None of them has dependencies. It follows progressive enhancement: the server renders working HTML, and custom elements add in-place updates on top. With JavaScript turned off, adding to cart, the cart page, filters, sorting, pagination, search and the sticky header all still work. The exception is the mobile menu at 900 px and below, which needs JavaScript to open. If JavaScript is on but `techno.js` fails to load, the option pills stop responding and a plain variant dropdown appears in the buy box instead, so Add to cart still adds the variant the shopper chose.

The script dispatches Shopify's standard storefront events and configures its storefront actions, so apps can follow and change the cart (see [Standard events and actions](#standard-events-and-actions)). It also dispatches one event of its own, `tc:cart-add`, and the product comparison scripts dispatch `tc:compare-change` (see [Events](#events)). It exposes no global object for your code: `window.__technoCart` holds the handlers the layout configures the actions with, and isn't an API. Integrate through the elements, attributes and those events. To announce something to screen readers from your own code, write to the live region as [Accessibility conventions](architecture.md#accessibility-conventions) describes.

## Custom elements

| Element | Wraps | Adds |
| --- | --- | --- |
| `<tc-product-form>` | A product form: the buy box, each kit, and a product card's quick add | Adds to cart with `fetch`, marking the pressed submit button busy with `aria-disabled` as well as the form's own, including one outside the form such as the sticky bar's. The pressed button takes focus first, without scrolling, because Safari and Firefox don't focus a button clicked with the mouse; closing the cart drawer then returns focus to it. It doesn't when focus is already on it, or when it's the form's own submit button and focus is on one of the form's fields, as when Enter in the quantity field submits the form. When a button outside the form was pressed and the cart refuses the add, it scrolls the buy box's error into view, instantly under reduced motion, keeping a gift card recipient field that took focus in view. It refreshes the cart sections and header count, then opens the cart drawer and announces its `data-added-message` through the drawer's live region once the drawer is open. If another drawer is already open it announces the add through that drawer's live region instead of opening over it. The exception is the quick view the form is in: it closes and the cart drawer opens in its place. A form the quick view no longer shows, because the shopper opened another product or chose another variant while the add was on its way, declines as for any other drawer. Shows errors in `[data-form-error]`. If the request fails or the answer isn't JSON, it posts the fields it sent to the form's `action` as a normal form post, so the shopper lands on the cart page with what they added, even if the form changed while the add was on its way. A file chosen in a file input is posted with it; an empty file input is left out. The request is sent with `keepalive`, so a shopper who follows a link before the answer still gets the add, once. An add carrying a file is sent without it. `keepalive`'s 64 KiB limit is shared with every other `keepalive` request on the page, such as Shopify's analytics, so when the browser refuses the request, because the add is large, such as long line item properties, or the limit is already in use, the add is sent once more without `keepalive`, if the refusal comes at once (within 300 ms) and the shopper isn't leaving the page; a slower failure is posted as a normal form post instead, as above. While the shopper is leaving the page, a failed add isn't sent or posted again. |
| `<tc-variant-picker>` | Option inputs, each with the value's `data-option-value-id`, and a JSON list of the product's first 250 variants that carries only `id`, `options` and `available`, plus `requires_selling_plan` and `selling_plan_allocations` (each with only `selling_plan_id`) on a product with selling plans, and `"preorder": true` on a variant the section's pre-order settings make a pre-order (only while one is on). Read other variant fields from Liquid, not from this list. For `shopify:product:select` it also carries the product's `data-product-id`, `data-product-handle` and `data-product-title`, each option group's `data-option-name`, and a `<script type="application/json" data-selected-variant>` with the shown variant as `standard_event_data` gives it | Finds the chosen variant and re-renders the section's variant regions. On a product with more than 250 variants the picker carries `data-variants-partial`, and a choice the list doesn't hold is requested with `option_values` instead of `variant`: the server picks the variant and names it in the fetched picker's `data-variant-id`, which is empty when no variant has those options. Until that answer, the section's add-to-cart buttons are turned off and keep their label, and the `inventory` and `pickup` regions are hidden. In the Product section, on its product's own page, it updates the URL once those regions show the new variant, so a failed or superseded request leaves the URL on the variant still on screen. A picker in any other section, such as Featured product, never changes the URL, even when it shows the page's own product (see `data-product-main` under Data attributes). After the swap it announces the text of the fetched section's `[data-variant-announce]` element, which `blocks/_variant_picker.liquid` renders hidden, and it announces "Unavailable" for a combination that makes no variant. At every change, before any request, it writes the chosen value of each option into the section's `[data-spec-option]` elements (see Data attributes), so the spec table names the choice even when the request fails or no variant matches. If the request fails, it disables the section's add-to-cart buttons, hides their accelerated checkout and the stock and pickup lines, and shows its `data-failed-label` in each buy box's `[data-form-error]`, which screen readers hear once. The next variant change clears the message. A superseded request announces nothing, and nothing is announced while a drawer is open, unless the picker is inside that drawer, as in the quick view, where it announces through the drawer's own live region. When the browser restores an earlier choice on Back or Forward, which fires no `change` event, it runs the same update without announcing the choice. A value whose `data-product-url` names another product of a combined listing loads that product instead, asked for with `option_values`: in the Product section it replaces all of `#MainContent` from the fetched page, sets `document.title`, pushes the product's address onto the browser history (tagged `{ tcProduct: true }`; a restored choice replaces the entry instead, and the theme editor writes none), and goes back and forth between such entries on `popstate`; a product on another template, whose sections differ, is opened as a page, unless the shopper is already leaving the page. In the quick view it loads the product into the dialog through `<tc-quick-view>`'s `load()`, and in any other section it replaces the section. Neither changes the address. Focus moves to the chosen value in the new picker, and the new section's hidden `[data-product-announce]` sentence is announced. A failed request fails the same way as a variant change. Such values are never struck through at a change: the server's mark stands. It checks when it connects, at a click before the first `pageshow`, and at every `pageshow`. The swap re-renders each buy box's subscription plan radios (`input[name="selling_plan"]`) with the default checked, so the picker checks the shopper's plan again afterwards, box by box. That only works for a radio that carries `data-plan-offered` and isn't disabled. The theme's plan snippet marks "One-time purchase" and each plan the new variant is sold with, and disables every other plan radio. A plan radio without the attribute, for example one your own code adds inside a `data-variant-region="buy"` element, goes back to the default on every variant change. If the chosen plan isn't offered, the default stays and the buy box's `[data-form-error]` shows the text of the `data-plan-reset-label` attribute from an element in that box. If no plan radio can be checked at all, no message shows: the chosen plan is kept on the `data-variant-region="buy"` element as `data-plan-pending` and checked again on the next variant that offers it. The theme sets that attribute on the plan list. Picking a plan in that box, the next variant change or an add clears the message. For a product that requires a plan, a variant whose JSON lists no `selling_plan_allocations` has its `inventory` and `pickup` regions hidden at the change, in stock or sold out, as the server renders them. An in-stock one is also treated like a sold-out one: Add to cart is turned off with its "Unavailable" label. Until the swap, a buy box whose checked plan isn't among the new variant's `selling_plan_allocations` has its Add to cart turned off and its accelerated checkout hidden, and the checked radio is left as it is. So does a box that has plan radios but none checked when the new variant's JSON has `requires_selling_plan`. A box whose quantity field follows a B2B quantity rule (it carries `data-min-message`) has its Add to cart buttons turned off until the swap, which brings the new variant's rule, and so does a box without a `[data-preorder-property]` input when the new variant's JSON has `"preorder": true`, which also has its accelerated checkout hidden, so a pre-order is never added without its line item property |
| `<tc-gallery>` | The product media gallery | Switches the visible media when a thumbnail is chosen, pausing a video or 3D model it hides. Its `show(id)` method shows the media with that id the same way; the stage swipe and `<tc-lightbox>` use it. A touch or pen swipe on the stage shows the next or previous media item, mirrored in right-to-left, unless it starts on a video, an iframe, a model or the AR button. On a device with a coarse pointer, those players sit between 32px strips of the stage, where a swipe can start. The swipe is defined in `snippets/product-media.liquid`; the quick view dialog renders that snippet with `script_only: true`, which outputs nothing, so the swipe also works in quick view on pages with no gallery of their own |
| `<tc-lightbox>` | The full-screen image viewer, a `dialog.tc-drawer` rendered after the gallery while "Enable image zoom" is on. Defined in `snippets/product-media.liquid` | A click on a `[data-lightbox="DIALOG-ID"]` link, or on the stage image beside one, opens the dialog on the image named by the link's `data-lightbox-media`, instead of following the link to the full-size image. Steps with its buttons, the arrow keys (mirrored in right-to-left), Home, End and swipe, without wrapping; each step calls the gallery's `show(id)`, writes "Image 3 of 7" to the dialog's live region and points `data-return-focus-id` at that image's zoom link. Zooms 2.5 times at a click, pinches between 1 and 4 times, and pans by drag or native scroll. Resets zoom on every step and on close, and returns focus to the zoom link itself when `techno.js` hasn't |
| `<tc-quick-view>` | The quick view dialog's panel, `dialog#QuickView`, rendered once in the layout while Theme settings › Product cards › "Show quick view button" or "Show quick add button" is on. Defined in `snippets/quick-view-drawer.liquid` | A click on any `[data-quick-view-url]` element opens the dialog through the theme's own drawer opener and loads that URL with `?section_id=quick-view` (the `sections/quick-view.liquid` section). A click with Ctrl, Cmd, Shift or Alt held, a middle click, or any click when `techno.js` hasn't defined `<tc-product-form>` is left to the link. The heading, which takes focus and names the dialog, shows `data-quick-view-title` at once and the product's title once loaded; the content carries `aria-busy` until then. The response is inserted as HTML, with any `<style data-section-stylesheet>` Shopify sends with it, and the 3D bootstrap is run again. A later request replaces an earlier one's answer, and closing the dialog drops any answer still on its way. A failed request, or an answer that isn't the quick view section, loads the product page instead, unless the shopper is already leaving the page. Closing empties the content, which stops any video or 3D model. Its `load(url)` method loads another product into the open dialog |
| `<tc-quantity>` | A number input and −/+ buttons | Steps the value by the input's `step`, on the grid of its multiples, and fires `change`. `min` is where − stops (0 in the cart, which removes the line); `data-min`, set only for a B2B quantity rule, is the rule's minimum above it, which + jumps to and − drops from, and `data-max` the rule's maximum. Clamps to `max`. A field that adds to a cart line, in the buy box or a Quick order list row, also carries `data-in-cart`, how many the cart holds, and `data-in-cart-variant`: the rule then applies to the line, so it steps on the line's multiples from the least it can add, which is one increment once the cart holds the minimum, up to the maximum less what's in the cart. A field that carries `data-min-message`, `data-step-message` and `data-max-message` is checked before it's posted: the buy box shows the message under Add to cart and sends nothing, and a cart line is put back with the message. After an add or a quantity change, every `[data-in-cart-for="VARIANT_ID"]` count beside a rule is rewritten from the cart, and so is every field's `data-in-cart`, with its `max` and, in the buy box, its `min` |
| `<tc-cart-items>` | Cart lines in the drawer or cart page | Updates quantities and removals with `fetch`, and announces the result. When the cart refuses a change (Shopify answers with an error: 422 for stock or a quantity rule, 400 for a line that has gone), it reads the cart again and updates the header count and in-cart counts; if the cart still changed, as when Shopify raises a line only as far as stock allows, it renders the cart sections again and puts focus back on the cart control the shopper was on, such as the line's − or +, as it does for an accepted change. Shopify's message then shows at the top of the lines in `[data-cart-error]`, which screen readers hear once. When the line has gone from the cart, such as one removed in another tab, the message is the element's `data-line-gone-message` instead ("This item is no longer in your cart.", `cart.line_gone`) |
| `<tc-gift-recipient>` | The gift card recipient fields | Counts message characters, keeps typed values through variant changes, marks field errors, and opens the fields again when the browser restores a ticked checkbox on Back or Forward |
| `<tc-card-swatches>` | A product card's swatch row: a disabled `<fieldset>` of radios, one per value, rendered while Theme settings › Product cards › "Show swatches" is on. Defined in `snippets/card-swatches.liquid` | Enables the fieldset. When a radio is chosen, it points the card's `.tc-card__link` and every `[data-quick-view-url]` control in the card at the radio's `data-url` (and `data-quick-view-url` at its `data-view-url`, or `data-url` when there is none). It rebuilds the card image's `srcset` from the radio's `data-media` for the card's six widths, or restores the featured image when there is none, and replaces the SKU in `[data-card-sku]` and the foot's `.tc-stock` with the radio's `data-sku` and `data-stock` markup. A radio without `data-stock`, a value no variant has with the card's other options, hides both. While the chosen value isn't the one rendered checked, the card carries `data-swatch-changed`; choosing that value puts back the card exactly as rendered. With a mouse, pointing at a swatch shows its image, and leaving the row shows the chosen one again. It follows a radio the browser restores on Back or Forward. The layout renders the snippet with no card id, which outputs nothing, while the setting is on, so its script also runs on pages whose cards arrive later, such as a product page's recommendations |
| `<tc-predictive-search>` | A search form | Fetches suggestions from `/search/suggest` after two characters. If the request fails, it closes the suggestion list instead of leaving an earlier term's suggestions under what the shopper has typed |
| `<tc-facets>` | The filter and sort form | Applies changes without a reload and updates the URL. If the request fails, it falls back to loading the filtered URL as a full page, so the address bar and the results on screen never disagree. It doesn't when the shopper is already leaving the page, for example by clicking a product while the results load. If the page turns out not to have been left, as after a click on a link that downloads a file, it asks once more after 2 seconds |
| `<tc-recommendations>` | An empty recommendations section | Loads recommendations once, with the CSS Shopify sends for them (see [Section Rendering](#section-rendering)): when the browser is next idle after the page's `load` event (at most 2 seconds later), or earlier if the section comes within 320 px of the viewport. With the browser's data saver on (`navigator.connection.saveData`), only when it nears the viewport. It reserves no space, so a section with nothing to recommend stays empty. If a focused element below the section is on screen when the recommendations arrive, it's scrolled back into view. This differs from Dawn, which loads them only as the shopper approaches. If the shopper reaches the bottom before they arrive, the row still pushes the footer down once |
| `<tc-overflow-strip>` | A horizontal scroller marked `data-overflow-track`: the header's status strip, the Announcement bar, and the Logo list's row on phones when "Layout on mobile" is Scroll | While the scroller overflows and holds nothing focusable, gives it `tabindex="0"` so the arrow keys scroll it, as Safari doesn't make scrollers focusable. Scrolls a focused child into view, and sets `data-more-start` and `data-more-end` on itself for each edge with content past it |
| `<tc-header-menu>` | The desktop header menu list. Defined in `sections/header.liquid` | Closes an open menu panel when keyboard focus moves out of it. With `data-hover="true"` (Header › "Open menus on hover") and a mouse or pen, opens a panel once the pointer has rested on it for 120 ms, closing any other (a panel that holds keyboard focus first moves it to its own summary), and closes it 300 ms after the pointer leaves unless focus is inside it. A click on a panel that hover opened keeps it open. Touch never opens a panel by hover. Escape and outside clicks are handled by `techno.js` |
| `<tc-countdown>` | A Countdown block in the Announcement bar. Defined in `sections/announcement-bar.liquid` | Reads `data-deadline` (milliseconds since the epoch) and, if the deadline has passed, hides itself or shows its ended message (`data-after="message"`) at once. Otherwise it shows the `aria-hidden` clock and the Pause button, makes the deadline text visually hidden, and updates on the next whole second, or minute under reduced motion. It stops while the tab is hidden and catches up when it's shown. Pause freezes the digits and swaps the button's text and name; the deadline is still watched. On expiry it moves focus that was inside it to the ended message or `#MainContent`, and hides the bar when nothing in it is left. It writes no cookies or storage |
| `<tc-sticky-buy>` | The sticky add-to-cart bar, rendered hidden inside the Product section's buy box while Buy buttons › "Show sticky add-to-cart bar" is on. Defined in `blocks/_buy_buttons.liquid`, not `techno.js` | Its button is a second submit button for the buy box's form (the `form` attribute), so `<tc-product-form>` posts the add and `<tc-variant-picker>` keeps its label and state. The element only decides when the bar shows: while the buy box's `AddToCart-…` button is above the viewport, or under the stuck header (`--tc-header-height`), and the footer isn't on screen. While shown it sets `--tc-dock-buy` on `:root` to its height (see [Design tokens › Docked bar tokens](design-tokens.md#docked-bar-tokens)) and `data-docked` on itself. It shows "Qty 3" when the form's quantity isn't the value it rendered with, including a quantity the browser puts back after Back or Forward (read again at `pageshow`), stays shown while focus is inside it when the footer comes on screen, hiding once focus moves on (focus that moves into a dialog, such as the cart drawer, is waited out), unless that focus has no visible ring (`:focus-visible`) and the last press inside the bar was a mouse's (`pointerType` "mouse"): keyboard, touch and screen reader focus hold it, a mouse click's doesn't. It hands focus to the buy box's button, without scrolling, when that button is back in view with focus inside, and when the footer takes the bar from a mouse click's focus. It does nothing when `techno.js` didn't define `<tc-product-form>`, outside the `data-product-main` section, or for any bar but the section's first |
| `<tc-quick-order>` | The Quick order list's table and its `<tc-product-form>`. Defined in `sections/quick-order-list.liquid` | Keeps each row's quantity by variant, across pages, checks it against the row's quantity rule (and whole numbers), and writes the rows with a quantity into `items[][id]` and `items[][quantity]` hidden inputs for the form to post in one `/cart/add.js` request. Until a row has a quantity the Add button is marked off with `aria-disabled` and a press on it does nothing; it stays focusable, with an id, so the cart drawer an add opens gives focus back to it when it closes. Each row's check is run again when its page comes back, when Add is pressed and when another form's `tc:cart-add` has changed the in-cart counts. A submit with a refused row is stopped before `<tc-product-form>` sees it, with "2 rows need a different quantity." and focus on the first. Pagination links load the section's next page with the Section Rendering API and put entered quantities back. A page that fails to load is opened by following the link, unless the shopper is already leaving the page. Clears the rows on `tc:cart-add` with `ok: true`. With `ok: false`, it compares each row it sent with `detail.cart` and the row's in-cart count when it was sent: rows that went in, wholly or in part, are cleared, a partly added one says how many went in, and a status line says how many rows were cleared |
| `<tc-slideshow>` | A Slideshow section with two or more slides. Defined in `sections/slideshow.liquid` | Shows the Previous, Next and pause controls and the "01 / 03" counter, and makes every slide but the one on screen `inert`. An `IntersectionObserver` on the scroll-snap track makes the slide at least 60% in view current, so swipes, trackpad scrolls and button presses end in the same state; focus inside a slide that goes inert moves to Next first. Previous and Next wrap, scroll smoothly unless reduced motion is preferred, and write "Slide 2 of 3: heading" to the reachable live region. With `data-autoplay="true"` it advances every `data-interval` milliseconds unless reduced motion is preferred, and never announces. Rotation stops for good, until the pause button is pressed again, on a press of Previous or Next, on focus anywhere in the element (on the pause button only when it's keyboard focus, `:focus-visible`, so a mouse click on it just toggles), on a pointer press in the track, and when reduced motion starts to be preferred; it holds while a mouse is over it, the tab is hidden, less than half of it is on screen, or a slide is selected in the theme editor |
| `<tc-back-to-top>` | The back-to-top link, rendered after the Footer group while "Show back-to-top button" is on. Defined in `snippets/back-to-top.liquid` | Sets `data-visible`, which shows it, while the page is scrolled more than one screen, watching an empty sentinel element with an `IntersectionObserver` rather than listening to scroll. On click it scrolls to the top (instantly under reduced motion) and focuses `#MainContent` without scrolling, and doesn't add `#MainContent` to the address |
| `<tc-sticky-header>` | Nothing. The header renders it empty and hidden when "Sticky header" is Always or On scroll up. Defined in `sections/header.liquid`, not `techno.js` | Sets `--tc-header-height` on `:root` to the header's height, and keeps it current as the header resizes (see [Design tokens](design-tokens.md#header-tokens)). In On scroll up it sets `data-header-hidden` on the header's section wrapper once the shopper has scrolled down past the header, and removes it on any scroll up, on focus inside the header, on Shift+Tab and when the Header section is selected in the theme editor. It never hides the header while a header dropdown is open or focus is inside it. When it disconnects it removes both |
| `<tc-compare-toggle>` | A product card's "Compare" checkbox, in the card's action row while Theme settings › Product cards › "Show compare checkbox" is on and a comparison page is chosen. It and an action row whose only control it is (`data-compare-row`) are laid out but invisible (`visibility: hidden`) until they carry `data-ready`, so nothing moves when they appear, and aren't laid out at all when scripting is off. Defined in `snippets/compare-tray.liquid`, not `techno.js` | Reads the `tc-compare` key in `localStorage` once (a read only) and, if that works, sets `data-ready` on itself and on a `data-compare-row` around it; if it can't, it sets `hidden` on both. Ticking adds the card's `data-handle` and `data-title` to the list, up to 3, and unticking removes it; a fourth tick is undone. Each change writes the list, dispatches `tc:compare-change` and announces through `#tc-live-region`, using the strings on `<tc-compare-tray>`. It follows `tc:compare-change` and other tabs' `storage` events. Cards that arrive later, in recommendations or after a filter change, work too, because the layout renders the snippet on every page while comparison is on |
| `<tc-compare-tray>` | The compare bar, rendered `hidden` by `layout/theme.liquid` after the main content while comparison is on. Defined in `snippets/compare-tray.liquid` | Shows while the list holds a product, except on a page with a `<tc-compare>`: the count, a remove button per product (hidden under 600px), "Compare now", linking to the comparison page with `?products=`, and "Clear". While shown it sets `data-docked` on itself and `--tc-dock-compare` on `:root` to its height, and sits on the sticky add-to-cart bar by `--tc-dock-buy`. Removing moves focus to the next remove button, or to `#MainContent` when none is left; "Clear" removes the `tc-compare` key and focuses `#MainContent` |
| `<tc-compare>` | The comparison page's table, in the Compare section (`sections/compare.liquid`). Defined there | Takes up to 3 handles from `?products=`, or from the list when the address has none, and requests `/products/HANDLE?section_id=SECTION` for each (see [Section Rendering](#section-rendering)). Builds a `<table>` from each response's `[data-compare-column]`: a column header per product with its media and title link, a row of Remove buttons under the headers, then rows for price, stock and every `[data-spec-key]` row, labelled by the row's `dt` and filled with its `dd`'s text. Marks a row whose values aren't all the same, a missing one included, with "≠" and hidden "values differ". "Show only differences" hides the rest. A product whose request answers 404 is dropped, from the list too, and the page's status line, "Some products are no longer available and were removed." or, when a request fails outright, "The comparison couldn't be loaded.", is also announced through `#tc-live-region`. A Remove that leaves one product announces the "Add another product" hint with it. The list is written only when the shopper removes a product here, so a shared link doesn't replace the visitor's own selection. Keeps the address's `?products=` in step with `history.replaceState` |

## Using `<tc-product-form>` in custom code

Wrap a standard product form to get the theme's add-to-cart behavior anywhere, for example in a Custom Liquid section:

```liquid
{%- assign item = all_products['usb-c-hub'] -%}
<tc-product-form data-added-message="{{ 'cart.added' | t }}">
  {%- form 'product', item, id: 'CustomAdd-usb-c-hub' -%}
    <input type="hidden" name="id" value="{{ item.selected_or_first_available_variant.id }}">
    <button class="tc-btn tc-btn--primary" type="submit" name="add">Add to cart</button>
    <p class="tc-buybox__error" data-form-error role="alert" hidden></p>
  {%- endform -%}
</tc-product-form>
```

Requirements:

* Give the form a unique `id`.
* Include one `input[name="id"]` with the variant ID and a submit button.
* Include an element with `data-form-error` for error messages. It's unhidden when an add fails. In a buy box, `<tc-variant-picker>` also shows a message there when a variant change can't be loaded, and when a variant change resets the shopper's subscription plan because the new variant isn't sold with it.
* Set `data-added-message` on `<tc-product-form>` to the text screen readers hear after a successful add. The theme uses `{{ 'cart.added' | t }}`, "Item added to cart." Without it, the add isn't announced.
* Without JavaScript, the form posts normally and the shopper lands on the cart page.

Its adds dispatch `shopify:cart:lines-update` and `tc:cart-add` as the theme's own forms do (see [Standard events and actions](#standard-events-and-actions) and [Events](#events)).

## Data attributes

| Attribute | On | Effect |
| --- | --- | --- |
| `data-drawer-open="ID"` | Any clickable element | Opens the `<dialog>` with that id: `CartDrawer`, `MenuDrawer`, `SearchDrawer`, or `QuickView` while the quick view is on. Use it on a link so it still works without JavaScript. When any drawer opens, the theme records the `id` of whichever element has focus at that moment, which isn't always the element that opened it, and closing the drawer returns focus to the element that has that `id` by then. When the drawer is opened from inside another drawer, which closes, the new drawer takes over the closing drawer's recorded element instead, so closing the cart drawer after an add from quick view returns focus to the card's "Quick view" button. So give a focusable element in a region the theme re-renders, such as a `data-variant-region` element, a unique `id`. |
| `data-quick-view-url="URL"` | A link, the product card's "Quick view" and "Choose options" | Opens the quick view dialog on that product URL (see `<tc-quick-view>`). Put the product's page in `href` too, so the link works without JavaScript. `data-quick-view-title` gives the dialog's heading while the product loads. Needs the quick view dialog on the page |
| `data-drawer-close` | An element inside a drawer | Closes that drawer |
| `data-drawer-initial-focus` | An element inside a drawer | Receives focus when the drawer opens |
| `data-cart-count` | Any element | Its text is set to the cart's item count after every cart change, and when the browser shows the page again from its back/forward cache |
| `data-card-sku` | The span around a product card's SKU and its separator, rendered only while "Show swatches" is on | `<tc-card-swatches>` rewrites the SKU in it for the chosen variant, or hides it for a variant without one. A card with no SKU has none, and never gains one |
| `data-product-url="URL"` | An option input or `<option>` in `<tc-variant-picker>`, for a value that belongs to another product of a combined listing (`product_option_value.product_url`) | Choosing it loads that product in place (see `<tc-variant-picker>`). Only rendered on combined listings |
| `data-product-main` | The Product section's `<section>` element, `sections/product.liquid` | Marks the section whose variant picker writes the chosen variant into the page URL as `?variant=`. A variant picker in a section with no element carrying it leaves the URL alone. Keep it on your own product section if you replace the theme's |
| `data-plan-offered` | An `input[name="selling_plan"]` radio in a buy box | Marks a plan the rendered variant can be bought with. The shopper's plan is checked again after a variant change only on a radio that has it and isn't disabled. See `<tc-variant-picker>` above |
| `data-plan-reset-label="TEXT"` | An element in a buy box, the theme's plan list | The message shown in the buy box's `[data-form-error]` when a variant change resets the chosen plan |
| `data-spec-key="KEY"` | Each row of the spec table, the `.tc-kv` element around its `dt` and `dd`, `snippets/spec-table.liquid` and `blocks/_spec_row.liquid` | Names the row the same way on every product: `sku`, `option-` and the option name's handle (such as `option-color`), `type`, `vendor`, `weight` and `barcode` for the built-in rows, and the block's id for a Spec row. Set by Liquid; no script writes it |
| `data-spec-option="N"` | An element inside a `data-variant-region="specs"` element, the theme's spec table value cells | Its text is set to the chosen value of the product's option at position N, counting from 0 in the order of `product.options_with_values`, at every variant change in that section. With two variant pickers in one section, the last one changed sets it. The section's next successful update replaces it with the server's render |

Order note fields, `textarea[name="note"]` inside an element with `data-cart-section`, are saved to the cart automatically when changed.

## Events

| Event | On | When | `detail` |
| --- | --- | --- | --- |
| `tc:cart-add` | The `<tc-product-form>` that added, bubbling. Kept for existing code; new code should listen for `shopify:cart:lines-update`, which covers every cart change (see [Standard events and actions](#standard-events-and-actions)) | After an add settles: when it succeeds, once the cart sections, header count and in-cart counts are refreshed and just before the drawer opens; when the cart refuses it (a 422), once the cart has been read again | `{ ok: true, cart }`, the `/cart.js` answer (null if that request failed), or `{ ok: false, message, cart }`, with the cart as it is after the refusal (null if it couldn't be read) |
| `tc:compare-change` | `document` | After the product comparison list in `localStorage` changes in this tab: a tick, a removal or "Clear" | None. Read the list from `localStorage.getItem('tc-compare')`: a JSON array of up to 3 `{ "handle", "title" }` objects, or no key when it's empty |

A refused add can still have added some items: a batch add with a line above its stock adds what's available and the other lines. After a refusal the theme reads the cart again and updates the header count and every in-cart count, and when the cart's item count has changed, it renders the cart drawer and cart page sections again, so the drawer shows what went in. A refusal that changed nothing leaves them as they are. A network failure dispatches no `tc:cart-add`, because the add is posted again as a normal form and the page moves on, or, when the shopper is leaving the page, the page they chose replaces this one.

The event fires while the add still holds its form's submit button: the theme takes `aria-disabled` off that button right after the listeners have run. If your code keeps that button's `aria-disabled` itself, mark the button `data-own-aria-disabled`: the theme then leaves it alone, and your code releases the hold once the add has settled, for example in a `setTimeout` from the `tc:cart-add` listener, as the Quick order list does.

## Standard events and actions

Techno dispatches Shopify's [standard storefront events](https://shopify.dev/docs/api/storefront-events-and-actions/events) and configures the [storefront actions](https://shopify.dev/docs/api/storefront-events-and-actions/actions/configure), so an app can follow what shoppers do and change the cart without reading the theme's markup. `snippets/standard-events.liquid`, rendered in each layout, loads Shopify's library from `https://cdn.shopify.com/storefront/standard-events.js` as `window.StandardEvents` and defines `<tc-view-event>`, the library's view-event element, which the sections below wrap around a product, collection or cart, or render empty and hidden.

Every event bubbles, so a listener on `document` hears them all. Each comes from the element where it happened:

| Event | Dispatched from | `context` | Promise |
| --- | --- | --- | --- |
| `shopify:page:view` | `document`, once per page load, at `DOMContentLoaded` and before any other standard event. `page.template` is the template name, such as `index`, `product`, `collection`, `search`, `cart` or `404` | | None |
| `shopify:product:view` | The Product section, once at load. The Featured product layout, once, when half of it is on screen. Each product card in a Collection, Featured collection, Search results or Product recommendations section, once, when half of the card is on screen; a filter or sort change renders new cards, which are seen again. The quick view, each time it loads a product | `page` (Product and Featured product), `collection` (Collection and Featured collection cards), `search`, `recommendation`, `dialog` (quick view) | None |
| `shopify:product:select` | The `<tc-variant-picker>`, once per choice, 200 ms after the last option change. A choice the browser restores on Back or Forward dispatches nothing, and neither does a value that loads another product of a combined listing, which sends that product's `shopify:product:view` instead | | Resolves with the variant the page shows once it's painted, including its price in the shopper's currency, or `{ variant: null }` for a combination that isn't sold. Rejects with an `AbortError` when a newer choice overtakes it, and with the network error when the variant can't be loaded |
| `shopify:cart:lines-update`, `add` | The `<tc-product-form>` that adds: the buy box, the sticky add-to-cart bar, Featured product, Kits, the quick view, a card's quick add, the Quick order list and your own. The Quick order list's event lists every row it adds | `product` | Resolves with the cart after the add, just before the drawer opens. A refused add resolves with the cart as it stands, which a partly added batch has changed, and `userErrors`. A network failure rejects, dispatches `shopify:cart:error`, and the add is then posted as a normal form, unless the shopper is leaving the page |
| `shopify:cart:lines-update`, `update` or `remove` | The `<tc-cart-items>` in the cart drawer or on the cart page, naming the line by its key | `dialog` in the drawer, `cart` on the cart page | Resolves with the cart the change returned, once the cart is shown again. If the answer's item count disagrees with the count in the cart drawer it has just shown, which happens when a newer change reached Shopify while that answer was being made, the theme reads `/cart.js` once and resolves with that cart instead; if that read fails, it resolves with the answer's cart. When two quick changes to one line cross on their way and the answer that lands last doesn't show the newer quantity, the theme sends the newer quantity again, once, with its own `update` event (`remove` for 0), so the last event to settle carries the cart the page shows. If the line has gone by then, Shopify refuses the re-send; its event resolves with the cart as it stands and `userErrors`, and the shopper sees no message. Every change names its line by the key, so a change that reaches Shopify after its line was removed is refused instead of changing another line (when no other lines are left, Shopify answers it with the empty cart, and its event resolves with that cart and no `userErrors`); when the shopper has already made a newer change to that line, such as the − that removed it, the shopper sees no message either; otherwise the shopper sees "This item is no longer in your cart." while `userErrors` keeps Shopify's own message. A refusal resolves with the cart as it stands, which a partly made change has changed, and `userErrors`, once the page shows that cart. A network failure rejects and dispatches `shopify:cart:error`, then the page goes to `/cart`, unless the shopper is leaving the page. The change is sent with `keepalive`, so a quantity sent by the click that leaves the page, such as a click on a product link after typing, still reaches the cart. If the browser refuses it because the page's other `keepalive` requests, such as Shopify's analytics, already use the 64 KiB they share, the change is sent once more without `keepalive` |
| `shopify:cart:note-update` | The cart section (`[data-cart-section]`) holding the note last edited, once per save: edits finished within 250 ms of each other are one save, and a note being edited when the page is hidden is still saved | `dialog` or `cart` | Resolves with the saved cart. The save is sent with `keepalive`; if the browser refuses it at once (within 300 ms) because the page's other `keepalive` requests, such as Shopify's analytics, already use the 64 KiB they share, it is sent once more without `keepalive`. A save that fails later isn't sent again, since it may have reached the cart. A network failure rejects and dispatches `shopify:cart:error` |
| `shopify:cart:view` | The cart drawer, each time it opens: from the header, after an add, or from `Shopify.actions.openCart`. The cart page, once at load. A refresh while the drawer is open and a quantity change on the cart page dispatch nothing | `dialog`, `page` | None |
| `shopify:cart:error` | The element whose cart write got no answer. For an add, that's the `<tc-product-form>` on the page when the answer fails, which is a new one if a variant change re-rendered the buy box while the add was out. `document` if neither element is on the page any more | | None. `code` is `SERVICE_UNAVAILABLE` |
| `shopify:collection:view` | The Collection section, once at load | | None |
| `shopify:collection:update` | The filter and sort form on a collection, once per change, 200 ms after the last. `collection.productsCount` is the collection's count before filtering, and `productFilters` and `sortKey` follow the address | | Resolves with the number of products that match, once the results are shown. Rejects with an `AbortError` when a newer change overtakes it, and with the network error when the results can't be loaded; the page then loads the filtered address, unless the shopper is leaving the page, when it asks once more after 2 seconds and settles with that answer |
| `shopify:search:update` | The filter and sort form on the search page, once per change, and once when a search results page loads, already resolved. Typing in the predictive search dispatches nothing | | Resolves with the number of results. Rejects as `shopify:collection:update` does |

Techno doesn't dispatch `shopify:cart:attributes-update` or `shopify:cart:discount-update`: it has no cart attribute fields, and discount codes are entered at checkout. `Shopify.actions.updateCart` still emits both when an app passes attributes or codes. Kits, the Hero's featured unit, predictive search suggestions, the comparison page and Schematic hotspots don't dispatch `shopify:product:view`.

`layout/theme.liquid` configures two actions, in a script above `{{ content_for_header }}` so that its configuration is the one that counts:

* `Shopify.actions.updateCart(payload)` writes the cart through Shopify, then refreshes the cart drawer, the cart page and the header count in place, without a reload. An order note typed but not yet saved is kept, and keyboard focus stays on the control the shopper was on, such as a line's − or +, Remove, a product title or Checkout, as it does for the theme's own cart changes. Unless the result has `userErrors`, screen readers hear "Item added to cart." when the payload adds a line, and "Cart updated." otherwise, through the open drawer's live region or `#tc-live-region`. The result's `detail.handledBy` is `'techno'`, so a listener can skip a refresh of its own. The events the action emits come from the cart page section on `/cart` and from `#CartDrawer` everywhere else. If the refreshed sections can't be loaded, the page reloads, as Shopify's default does, unless the shopper is leaving the page.
* `Shopify.actions.openCart()` opens the cart drawer, closing any other drawer, with focus on its Close button; closing it returns focus as for any drawer. It does nothing when the drawer is already open.

`isDefault()` returns `false` for both. `getCart` isn't configurable and works as Shopify documents. If `techno.js` doesn't load, nothing is configured, and both actions keep Shopify's defaults: a page reload, and a visit to `/cart`. If the library can't load, the theme dispatches no standard events and works as it otherwise does.

`shopify theme dev` serves a development build of the library that checks each payload and logs any problem with the prefix `[Shopify Standard Events]`. It also logs "Promise validation failed" for a promise the theme rejects on purpose, such as a variant choice that a newer one overtook. Add `--standard-events-inspector` to watch the events in a panel.

## Section Rendering

Cart and variant updates use Shopify's [Section Rendering API](https://shopify.dev/docs/api/ajax/section-rendering). After an add or a quantity change, the theme requests the sections on the page marked `data-cart-section` and swaps their HTML. It does the same, after reading `/cart.js` for the header count, when the browser shows a page again from its back/forward cache (`pageshow` with `persisted` true), as Safari on iPhone and iPad does for storefront pages; a page with a `data-cart-section` element inside `<main>`, such as the cart page, is loaded again instead. Since Shopify subsets theme CSS by page, a response can carry the CSS of what it renders in a `<style data-section-stylesheet>` element. For the cart sections, product recommendations, the variant regions and the Quick order list's pages, which the theme inserts only in part, it moves that CSS into one `<style id="SectionStyles-SECTION_ID">` in `<head>` per section, replacing its text with each new response, so the cart drawer's lines after a first add and the recommendation cards are styled on a page that rendered neither when it loaded. A response without one leaves `<head>` alone. A filter change and another product of a combined listing fetch a whole page instead, whose compiled `styles.css` can hold CSS the current page's lacks, such as the product card's on a collection or search page that loaded with no results; the theme adds that page's stylesheet link when the current page doesn't link the same file, once, and moves any `<style data-section-stylesheet>` it carries into `<style id="SectionStyles-page">`. After a variant change, it re-renders the product section, asked for by `?variant=` or, for a variant past the first 250, by `?option_values=`, and replaces each element marked `data-variant-region`: `media`, `options`, `price`, `sku`, `inventory`, `buy`, `restock`, `specs`, `pickup` and, in the quick view, `details`. The `restock` region, the back-in-stock request, is hidden at every variant change and takes its visibility from the fresh render, with any email typed into it carried over. Choosing another product of a combined listing isn't a region swap: the Product section fetches that product's whole page and replaces `#MainContent`, because its recommendations and every other section change with the product, and other sections fetch themselves from that product's URL with `section_id`.

The comparison page renders its own section, the Compare section of `templates/page.compare.json`, against each chosen product: `/products/HANDLE?section_id=template--ID__main`, with the section id the page was rendered with. Shopify renders a section from any template against the requested product, with that section's saved settings and blocks, so each response is a hidden `[data-compare-column]` for that product: its price and stock in the shopper's currency and language, and the Spec table rows resolved to its metafields. Every `<style data-section-stylesheet>` that comes with the responses is copied into `<head>` once, skipping any whose text is already there, since the comparison page renders no price or stock of its own.

The quick view loads `sections/quick-view.liquid` from the product's own URL, `/products/HANDLE?section_id=quick-view`, which keeps a market or language subfolder. The section isn't in any template, so its blocks render with their default settings. Since Shopify subsets theme CSS by page, the response carries the section's CSS in a `<style data-section-stylesheet>` element, which the quick view inserts with the section.

> **Note:** Apps should change the cart with `Shopify.actions.updateCart`, which updates the cart drawer, cart page and header count in place (see [Standard events and actions](#standard-events-and-actions)). An app's own request to the cart, such as a `fetch` to `/cart/add.js`, still won't update them until the page reloads, because the theme doesn't watch for outside cart changes.

## Theme editor

The script responds to theme editor events:

* Selecting the Cart drawer section opens the drawer, and it stays open while the section reloads.
* Selecting a block inside a closed `<details>`, such as a Collapsible row, opens it.
* Selecting a Slideshow slide shows it at once and holds rotation until the slide is deselected.

## See also

* [Custom code](custom-code.md): where to add scripts and markup
* [Theme architecture](architecture.md): how the script is loaded
