# Generic build

> The HTML the generic build reads and writes: attributes, sliders, tabs, popovers, forms, lightbox, the render event and the seed.

Status: Ready (Smootify 2.0)  
URL: https://www.smootify.io/docs/astro/reference/generic-build  
Updated: 2026-10-21

The generic build (the runtime `@smootify/astro` loads) reads HTML first: `hidden`, `[popover]`, `<dialog>`, WAI-ARIA tabs, native controls; Smootify attributes only where HTML has nothing. Its stylesheet hides `[hidden]` with `display: none !important` (except `hidden="until-found"`), so a class with a display does not show a hidden element.

## Attributes

Attributes of the generic build that are not a binding vocabulary. `data-smootify-site`, `data-smootify-build` and `data-smootify-item-slug` are written by the integration and the layout, `data-smootify-hidden` only by Smootify: never write them by hand.

| Attribute | On | What Smootify does |
|---|---|---|
| `slider-track` | the scroll-snap container inside a slider (`[gallery="slider"]`, Magic Box navigation) | its children are the slides; Smootify clones the first one per media and goes to a slide by scrolling the track (the site’s CSS gives it `overflow-x: auto` and `scroll-snap-type`) |
| `slider-prev`, `slider-next` | buttons inside the slider | scroll the track by one slide; `disabled` + `aria-disabled="true"` at the ends |
| `slider-dots` | a container inside the slider, its first child the dot template | one dot per slide, with the slide’s `data-id`; the current dot gets `aria-current="true"`; a click goes to its slide |
| `lightbox-item` | a link (`<a>`) inside a slide, or the `[gallery="lightbox"]` / `[gallery="lightboxes"]` link itself (also the `[product="images"]` / `[product="media"]` element in lightbox mode) | Smootify writes, on that link: `href` = the full-size media (an image: its url, without the size of `max-width`; a video, an external video or a 3D model: its preview image), `data-media-type` = `image` / `video` / `external-video` / `model`, and `data-lightbox-group` = the gallery it pages through: one value per gallery (`smootify-gallery-N`, or `smootify-images-N` / `smootify-media-N`), the same on every slide link and every `[gallery="lightboxes"]` link of it. A `[gallery="lightbox"]` link (one link that opens every media) takes the first media and gets one hidden `<a lightbox-item data-smootify-lightbox-extra>` after it per other media, all in the group `<gallery group>-all`; they are rewritten on every render. With `variant-images` / `showOnlyActiveImages`, links of media that are not shown lose `data-lightbox-group`. No JSON, no viewer: the site’s (a library or a `<dialog>`) reads `a[lightbox-item]` grouped by `data-lightbox-group`, and starts again on `smootify:render` (below) |
| `aria-current="true"` | the current option of a picker (`variant-popover`, `filter-popover`, `sort-popover`, country and location switchers…), and the current dot of a slider | Smootify writes `aria-current="true"` on it (a button option too) |
| `aria-expanded` | the `[popovertarget]` trigger of any popover | Smootify keeps it `true` / `false` with the popover’s state (the HTML popover API does not) |
| `form-done`, `form-fail` | elements next to the `<form>` in the form’s host, `hidden` until shown | success / error panel: the error text in the last child, `role="alert"`; an empty cart shows `form-done` |
| `form-label` | a `<span>` in the `<label>` of a native checkbox or radio that Smootify clones (facets, sort, add-ons, dynamic properties, plans, configurator) | receives the option’s text (else the label’s last text) |
| `locales` | the site’s language switcher | its `a[hreflang]` are the site’s locales (else `link[rel="alternate"][hreflang]`) |
| `data-tab` | `[role="tab"]` and `[role="tabpanel"]` of WAI-ARIA tabs Smootify fills (`subscription-swatches`, Magic Box categories) | the key that pairs a tab with its panel (else `aria-controls`); Smootify runs the tabs: click, ArrowLeft/Right/Up/Down, Home, End (the tab with focus is shown), `aria-selected`, roving `tabindex`, `aria-controls` / `aria-labelledby` (ids written when missing or repeated), `hidden` on the panels not shown. Plans are native radios in a `<label>`, their text in `[form-label]` |
| `data-smootify-hidden` | written by Smootify only | marks the `hidden` Smootify set (empty binding, a condition, a product not found), so it removes only its own |
| `data-smootify-site` | `<html>` (the loader writes it from its `data-site`) | the site id: licence `keys/<id>.json` |
| `data-smootify-build` | `<html>` | the build date: licence cache-buster, persistent cache expiry |
| `data-smootify-item-slug` | `<html>` | the product page’s handle |
| `data-smootify-products`, `data-smootify-collection`, `data-smootify-seed` | `<script type="application/json">` | the seed ([seed](https://www.smootify.io/docs/astro/reference/generic-build#seed)); with `data-country`, `data-language` and `data-primary-market` (the seed’s country is the shop’s primary market, which a visitor with no country fits) |

## Structures

What the generic build reads and writes for each part of a page.

| Part | In the generic build |
|---|---|
| Card boundary | `.sm-dyn-item` on the card: same boundary; a product not found and `hide-if-product-in-cart` set `hidden` on it. Without one, the host itself |
| Lists | `dynamic-swiper` takes the `.sm-dyn-item`s and removes their parent only when it is left empty |
| Empty binding, product not found | The `hidden` attribute, set on the card of a product not found and on an element whose binding is empty, removed when content is bound. Smootify removes only the `hidden` it set (it marks it with `data-smootify-hidden`), never one the site wrote; the debugger reads the mark |
| Conditions | The `hidden` attribute (same mark), the hide toggle of the option, metafield, reference, cart-item and facet bindings. Nothing is removed at boot: Astro renders its conditions on the server. Pickers and add-ons inside an element with `hidden` at boot do not mount |
| Dropdowns | **Popover only**: the `*-dropdown` pickers read the popover’s parts like the `*-popover` ones; the current option gets `aria-current="true"`, the trigger `aria-expanded`. The cart in dropdown mode is a `[popover]` cart, the predictive search calls `showPopover()`. Each `*-dropdown` tag (`variant-dropdown`, `addon-dropdown`, `sort-dropdown`, `filter-dropdown`, `configurator-dropdown`, and the `country-switcher`, `location-switcher` and `dynamic-property` Dropdown field) reads the `[popover]` it holds; only a tag without one removes itself, as its `*-popover` twin does. The `*-popover` tag is the preferred form |
| Popovers | The `*-popover` pickers and the cart overlay use the HTML popover API (native trigger, Escape, click outside); a popover keeps the transitions it was designed with (no gsap animation). CSS on every popover of the page, out of the design’s way: no backdrop (`--smootify-backdrop-color` on the panel’s class gives one, not to the popovers inside it; `store-availability` and the market dialog keep their light one); the browser’s padding and border reset at zero specificity, so the panel’s class wins; a closed panel stays hidden even with a display on its class, except where its class sets `--smootify-closed-display` (e.g. `flex` on desktop for filters that are a drawer on mobile; not inherited). `data-overflow-body` on a popover or a `dialog` stops the page scrolling under it while it is open, keeping the scrollbar’s space (a cart drawer, a menu; an anchored popover leaves it off; `="false"` turns it off) |
| Sliders | Native scroll-snap slider, inside the element that already says it is a slider (`[gallery="slider"]`, Magic Box navigation): slides = the children of its `[slider-track]` (the site’s CSS gives it `overflow-x: auto; scroll-snap-type`), optional `[slider-prev]` / `[slider-next]` buttons and a `[slider-dots]` whose first child is the dot template. Smootify clones the slides and dots, goes to a slide by scrolling the track, marks the current dot `aria-current="true"` (dots keep their slide’s `data-id`) and gives the arrows `disabled` + `aria-disabled` at the ends. Magic Box navigation uses the same parts. `product-slider` does nothing in the generic build (an Astro list is rendered into the track) |
| Swiper | `init="false"` first, Swiper’s element bundle loaded in its own chunk, slides built from `.sm-dyn-item`, params read from the JSON script (then removed), `initialize()`. A `dynamic-swiper` without `swiper-container` is removed |
| Tabs | WAI-ARIA tabs: `[role="tablist"]` of `[role="tab"]` (`aria-controls` → the pane’s `id`, `aria-selected`) and `[role="tabpanel"]` (`hidden` when not selected), keyed by `data-tab`. The generic build runs the tabs itself (click, arrow keys): no other script needed. Plans are native `input[type="radio"]` |
| Lightbox | No viewer in the generic build: the site brings its own (the Astro scaffold has a small `<dialog>` one). An `a[lightbox-item]` (inside a slide, or the `[gallery="lightbox"]` / `[gallery="lightboxes"]` links themselves) gets `href` = the bound full-size media, `data-lightbox-group` for a gallery, and the DOM event `smootify:render` tells the site’s lightbox (a library or a `<dialog>`) to read them again |
| Form success and error | `[form-done]` / `[form-fail]` next to the `<form>` in the form’s host, with `hidden` until shown: the same error text in the last child and `role="alert"`; an empty cart hides its form and shows `[form-done]`; an empty quote hides it (CSS) |
| Checkboxes and radios | Native controls: `<label><input type="checkbox\|radio"><span form-label>…</span></label>`. The cloned control’s text goes into `[form-label]` (else the label’s last text); its look comes from `:checked` / `:has(:checked)` in the site’s CSS: Smootify only sets `checked` and dispatches `change`, no class to sync |
| Language switcher | `a[hreflang]` inside `[locales]` (the site’s language switcher), otherwise `link[rel="alternate"][hreflang]`; same translated handles and missing alternates |
| Shop policies | Shop policy body written into any element; unknown or empty policies remove the element |
| File fields | `input[type="file"][data-name]`: the uploaded file url in `data-value` (Server upload add-on), else the file name |
| Menus | A menu is a `[popover]` or a `<dialog>` with `data-overflow-body`; search & discovery’s `body="no-scroll"` reads the open popover/dialog |
| Deferred scripts | Deferred script, run after `smootify:loaded`: a new `script` with the same attributes, `type` = its `data-type` (default `text/javascript`), `defer`/`async` removed, inserted after the original, which is removed |
| Site id | `data-smootify-site` on `<html>` (the loader writes it from its `data-site` when missing): same 24-character id, same `keys/<id>.json` |
| Domains and licence | Free domains with the badge = `localhost` and the Cloudflare preview domain (`*.workers.dev`; Astro sites are hosted on Cloudflare only); a paid plan on the registered domain. `w` is the key of the licence (`cdn.smootify.io/keys/<id>.json`, set from the dashboard) that holds the site’s production domain(s), `\|`-separated, whatever the platform. It is matched exactly, with `www.` normalised on both sides: the scheme and a trailing `/` are dropped, the apex and `www.` are the same host (`example.com` authorises `example.com` and `www.example.com`), and any other subdomain (`shop.example.com`, a language subdomain) needs its own entry; no wildcard. The server extensions compare `w` with the bare hostname |
| Language and country of the page | `html[lang]` (`fr-CA`: language and country) |
| Product page | `data-smootify-item-slug` on `<html>` (the product page’s handle) |
| `html[lang]` | Language base and market language; `zh-hans` / `zh-hant` special-cased; `Shopify.locale` / `Shopify.country` |
| Weglot | Weglot: requests wait until Weglot has translated the page; relaxes the domain authorisation (language subdomains) |
| Weglot language | `settings.language_from` → language base |
| Alternate links | Language base and initial locale; missing alternates are injected for translated product and collection pages |
| Build date | `data-smootify-build` on `<html>` (the build date the Astro integration writes) |
| Written on `<html>` | `--scrollbar-width`: the scrollbar width as a CSS variable, at boot. |

## Sliders

The slider’s element holds `[slider-track]` (the slides are its children; Smootify clones the first one), the arrows and `[slider-dots]` (a dot is cloned from the first child per slide, and gets `aria-label` “Go to slide N” when it has none, `role="button"` + `tabindex` when it is not a `<button>`). A slide shown by a call (variant change, thumbnail click) scrolls the track (`behavior: "smooth"`, `auto` with `prefers-reduced-motion`) without telling the product; the visitor moving it (dot, arrow, swipe) makes the current media follow (`imageChanged`, thumbnails `is-active` / `aria-current`). The current slide is the one at the start of the visible part of the track (its `scroll-snap-align` is read: start, center or end); the arrows are `disabled` + `aria-disabled="true"` on the first / last slide and with fewer than two slides. Horizontal only.

## Search & discovery

The server-rendered cards stay in the page, untouched, until the first results replace them in one step (the first card is not taken out as the template: Smootify clones it), so the list does not shift at boot; the `data-id="filter"` Smootify writes on them at boot only keeps them inert (their text and images stay). Never put `hidden` on the empty state (`[filter="empty-state"]`) or on Load more: the generic `[hidden]` rule is `display: none !important`, so no class of the site shows them again, and Smootify toggles its own `hidden` (`data-smootify-hidden`) only on what it hides itself. To hide a part until the block has started, use CSS on `smootify-search-discovery:not(.inited) …` (the block takes `inited` after its first render).

## Render event

The DOM event `smootify:render` on `document` (`detail.nodes` is always an empty array: query the page), debounced 400 ms and one event for a burst of renders, after the renders that create nodes (gallery, cart, search results, addresses, metaobjects) and after a lightbox link was written; a site script re-reads what it wires itself (a lightbox, its own animations). Sliders, tabs and popovers need nothing from it.

Rebinding on the nodes Smootify creates or rewrites (product cards of a list, search results, cart lines, gallery slides): listen to `smootify:render` on `document` and query the page for what is not bound yet, marking each bound node (a data attribute or a `WeakSet`), because the event fires again at every render and carries no node (`document.addEventListener("smootify:render", () => bind(document.querySelectorAll(".sm-dyn-item:not([data-bound])")))`, and once on load for what the server rendered). For one list, the document events that name it fire in the same render and need no debounce: `smootify:search_discovery:rendered` (no detail, after the results are drawn) and `smootify:product_loaded` (`{ product, id }`, once per product); neither tells which node, so they too are followed by a query.

On search & discovery and the search page, `avoid-webflow-restart` keeps its meaning: the results render of that `smootify-search-discovery` / `smootify-search-page` fires no `smootify:render`; the other renders (product, gallery, cart, metaobjects, addresses) still can, and the 400 ms debounce merges them. There is no generic-named twin.

## Seed

After `astro build`, the `@smootify/astro` integration writes three JSON scripts into every page, so the first products of a page do not need Shopify. Never write them by hand: the seed is built from the `smootify-product[data-id]` of the page.

| Script | Content | Attributes |
|---|---|---|
| `<script type="application/json" data-smootify-products>` | `{ "<product gid>": SmootifySeedProduct }`: a node of the `ProductsById` query with the base requirements. | `data-country`, `data-language`: the country and language the build asked in (`IT`, `it`); `data-primary-market` (boolean): that country is the shop’s primary market, which a visitor with no country is in. Without them: the shop’s default context. `data-requirements`: `productRequirementsHash` of the page the build scanned; without it, the base requirements |
| `<script type="application/json" data-smootify-collection="<handle>">` | `["gid://shopify/Product/1", …]`: the product ids of the collection in its own order (the first `smootify-product[data-id="specific-collection"]` page) | the same three, and `data-complete` when the list is the whole collection |
| `<script type="application/json" data-smootify-seed>` | `{ "<operationName>:<hash>": <data of the response> }`: the answer to any query of the allow-list below. The key is `seedKey(operationName, variables)`: the hash is of the variables the document asks with, without the `@inContext` ones, in any key order, a `null`/`undefined` value counting as absent | `data-country`, `data-primary-market`, `data-language`, as above |

A seeded product replaces the request only when all of this holds, otherwise the page asks Shopify as without the script:

- the page’s requirements (`[metafield]`, `[variant-metafield]`, dynamic properties, option metaobjects, magic-box combinations, store availability, components, price breaks) hash to the script’s `data-requirements`; a script without it was built for a page that asks for nothing extra (the hash of an empty page);
- the visitor’s language is the script’s, and so is his country (compared in upper case; a missing attribute equals “none”, i.e. a visitor with no country) or he has none and the script has `data-primary-market`: a first-time visitor is in the shop’s primary market. A visitor who picked or was given another country (the country switcher, a `lang` with a region, the browser’s language) does not fit. And there is no logged-in buyer or pickup location;
- the product is complete: the product of the product page needs all its variants, so a seed with variants beyond the first page is used only when the build merged them (`hasNextPage: false`);
- the entry is a product of the seed shape under its own id (`id`, `handle`, `options`, `variants.nodes` and `variants.pageInfo`); an invalid script or entry is skipped with a console warning.

**`data-smootify-seed`.** The Storefront client asks the seed before sending a query of the generic build, and sends it when the page holds no answer. An answer is used only when the operation is on the allow-list, the visitor’s country and language fit the script’s (the rule above; a document without `@inContext` has no market) and there is no logged-in buyer, company location or pickup location. The allow-list: `BootData` (shop and localization; keyed by `withShop` and `withLocalization` only, and a request with the stored cart is never answered), `ShopInfo`, `Locations`, `ProductIds`, `CollectionProductIds`, `ProductRecommendationIds`, `ProductMetafieldValues`, `SearchProductsForward`, `CollectionProductsForward`, `CollectionsList`, `MetaobjectById`, `MetaobjectByHandle`, `MetaobjectsForward`, `MetaobjectsByIds`, `ProductMetaobjectsForward`, `MagicBoxByHandle`, the eight `ShopPolicy*`, and the locale-link lookups `ProductHandleByHandle`, `ProductHandleById`, `CollectionHandleByHandle`, `CollectionHandleById`. Mutations, carts, customers, predictive search, `LocationsNear`, `VariantStoreAvailability`, gift rules and anything else always ask Shopify. A listing seeded for its default state is answered only for it: a filter, a sort, another page or a query changes the variables, hence the key, and goes to Shopify. The answer to `BootData` makes the page skip the idle revalidation of the shop and the localization.

The generic build also, by default: does not preload the catalogue, does not prefetch product links on a page with a seed, does not ask the page’s status with a `HEAD` request in Safari (a page is a 404 only with `<html data-smootify-404>`), and does not look up the translated handles when the page carries `link[rel="alternate"][hreflang]`.
