2.0 Beta

Smootify 2.0 is in public beta. Your 1.x store keeps working. Move when you want.

What changed

Generic build

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

v2.0 beta
Open .md
On this page
  1. Attributes
  2. Structures
  3. Sliders
  4. Search & discovery
  5. Render event
  6. Seed

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.

AttributeOnWhat Smootify does
slider-trackthe 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-nextbuttons inside the sliderscroll the track by one slide; disabled + aria-disabled="true" at the ends
slider-dotsa container inside the slider, its first child the dot templateone dot per slide, with the slide’s data-id; the current dot gets aria-current="true"; a click goes to its slide
lightbox-itema 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 sliderSmootify writes aria-current="true" on it (a button option too)
aria-expandedthe [popovertarget] trigger of any popoverSmootify keeps it true / false with the popover’s state (the HTML popover API does not)
form-done, form-failelements next to the <form> in the form’s host, hidden until shownsuccess / error panel: the error text in the last child, role="alert"; an empty cart shows form-done
form-labela <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)
localesthe site’s language switcherits 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-hiddenwritten by Smootify onlymarks 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); 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.

PartIn 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
Listsdynamic-swiper takes the .sm-dyn-items and removes their parent only when it is left empty
Empty binding, product not foundThe 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
ConditionsThe 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
DropdownsPopover 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
PopoversThe *-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)
SlidersNative 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)
Swiperinit="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
TabsWAI-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"]
LightboxNo 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 radiosNative 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 switchera[hreflang] inside [locales] (the site’s language switcher), otherwise link[rel="alternate"][hreflang]; same translated handles and missing alternates
Shop policiesShop policy body written into any element; unknown or empty policies remove the element
File fieldsinput[type="file"][data-name]: the uploaded file url in data-value (Server upload add-on), else the file name
MenusA menu is a [popover] or a <dialog> with data-overflow-body; search & discovery’s body="no-scroll" reads the open popover/dialog
Deferred scriptsDeferred 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 iddata-smootify-site on <html> (the loader writes it from its data-site when missing): same 24-character id, same keys/<id>.json
Domains and licenceFree 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 pagehtml[lang] (fr-CA: language and country)
Product pagedata-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
WeglotWeglot: requests wait until Weglot has translated the page; relaxes the domain authorisation (language subdomains)
Weglot languagesettings.language_from → language base
Alternate linksLanguage base and initial locale; missing alternates are injected for translated product and collection pages
Build datedata-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.

ScriptContentAttributes
<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 absentdata-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].

    ↑ ↓ to moveEnter to openEsc to close