2.0 Beta

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

What changed

Build the product page

The Shopify product on the product page of your Astro site. Covers title, price, gallery, options, add to cart and stock, and says what Smootify needs and what is up to your design.

v2.0 beta
Open .md
On this page
  1. How it fits together
  2. 1. Connect the page to the product
  3. 2. Title, description and product details
  4. Metafields
  5. 3. Price
  6. 4. Gallery
  7. 5. Options
  8. 6. Add to cart
  9. 7. Stock, sale and other states
  10. 8. Subscriptions
  11. 9. Search engines
  12. Before you publish

A product page in Astro is a page that renders one product entry, such as src/pages/products/[slug].astro with one page for each entry of the products content collection. Smootify loads the live product from Shopify into it: price, options, stock, images and the add-to-cart form. This guide goes through the page one part at a time. For each part it says what Smootify needs and what is up to your design.

Note

Before you start, Smootify for Astro must be installed (see Install) and the products content collection loaded (see Content), so that every entry has its shopifyId.

How it fits together

Everything Smootify fills goes inside one element, smootify-product. It holds one product, and the elements inside it read that product and no other.

What Smootify needs:

  • a smootify-product element with the product’s id;
  • inside it, the add-to-cart form, if the page sells.

What is up to you:

  • the layout, the sections, the order of the parts, the classes and the styles;
  • which parts you use. A page can show a price without a gallery, or options without a quantity field.

The smallest page that sells looks like this:

---
const p = entry.data;
---
<smootify-product data-id={p.shopifyId}>
  <h1 product="title">{p.name}</h1>
  <smootify-price><span data-prop="price">{p.priceFormatted}</span></smootify-price>
  <smootify-add-to-cart>
    <form>
      <button type="submit"><span data-state="default">Add to cart</span><span data-state="loading">Adding…</span></button>
    </form>
    <div form-fail hidden></div>
  </smootify-add-to-cart>
</smootify-product>

Smootify’s elements are plain HTML tags, and their parts are attributes on ordinary elements. How they are written in .astro files is in Write components.

1. Connect the page to the product

  1. In the product page, write a smootify-product element. It can wrap the whole page or only the product section.
  2. Set its data-id to the entry’s shopifyId: data-id={p.shopifyId}, where p is entry.data.

Everything else on this page goes inside that element.

  • Shared links to a variant. A link to the page with ?variant= and a variant id opens that variant. An id that is not one of the product’s variants, such as an old link to a variant you deleted, is ignored: the page opens on the first available variant. As the visitor picks options, the address follows the variant, so the link can be shared. This only works for the page’s own product: a smootify-product inside a card of a list, or inside the cart, ignores it.
  • Products Shopify does not return. A product that is unpublished or not sold in the visitor’s market leaves the product block hidden: Smootify sets hidden on it and marks it with data-smootify-hidden. Smootify does not redirect.
  • Other products on the page. The same element also loads related products, best sellers or a collection: see the data-id values in smootify-product.

2. Title, description and product details

Add an attribute to an element to fill it with a value of the product. What you write in the markup, such as the entry’s name, stays until the product loads.

AttributePut it onWhat it shows
product="title"a heading or any text elementthe title
product="description"an element that holds HTMLthe description, with its formatting
product="vendor"textthe vendor (brand)
product="type", product="category"textthe product type, the category
product="tags"textone copy per tag
product="collections"text or a linkone copy per collection
variant="sku", variant="barcode", variant="weight"textvalues of the selected variant

Tip

Also render the entry’s value inside the element, such as {p.name} for the title and <Fragment set:html={p.description} /> for the description. The built page then has the text before Smootify loads, which helps search engines and avoids a flash of empty elements. Smootify then writes the live value from Shopify over it.

The full lists are in the Reference: product values and variant values.

Metafields

metafield="material" shows the product metafield custom.material. Add namespace="…" when the namespace is not custom, and use variant-metafield for a variant’s metafield. Every metafield type renders, including lists, ratings, JSON and references to other products: see metafield types.

In Shopify, the metafield definition must have Storefront access turned on. Without it the value never reaches the page.

3. Price

Write a smootify-price element. Inside it, the elements with data-prop get the amounts, in the store’s money format:

  • data-prop="price": the price of the selected variant;
  • data-prop="compareAtPrice": the compare-at price, hidden when the variant is not on sale;
  • data-prop="total": the price times the quantity in the form.

The price follows the variant, the quantity and the selling plan. Put it anywhere inside smootify-product. Inside a swatch button, it shows the price of that button’s value instead.

<smootify-price skeleton="text">
  <span data-prop="price">{p.priceFormatted}</span>
  <span data-prop="compareAtPrice">{p.compareAtPriceFormatted}</span>
</smootify-price>
<div condition="on-sale"><span variant="discount-percentage"></span> off</div>

Attributes on smootify-price change what it shows:

  • no-quantity: the total ignores the quantity;
  • data-ignore="subscription-swatches": the one-time price, whatever selling plan is chosen;
  • data-remove="…": removes that text from the formatted price;
  • data-replace="from|to": replaces one piece of text with another.

variant="discount-percentage" shows 0% on a product that is not on sale. Put it inside an element with condition="on-sale", as above, so the badge only shows during a sale.

condition="not-on-sale" is the opposite: shown while the chosen variant is at full price, for a “regular price” label or a price style without the sale colour.

variant="discounted-amount" shows how much the variant saves, such as “€8.00”. Like the percentage, it comes only from the compare-at price: discount codes and automatic discounts never change it. The cart shows those. It shows €0.00 at full price, so put it inside condition="on-sale" too.

Price per unit. For products sold by weight, volume or length, the European pricing rules ask for the price per unit next to the price. Add variant="unit-price" to a text element: it shows the selected variant’s unit price and its reference unit, such as €1.50/ 100 g. The unit is in a span with the class reference-unit, so you can style it apart. data-format="{{price}} per {{unit}}" writes it your way. The element stays empty when the variant has no unit price.

The unit price comes from Shopify. On the product, in the pricing of each variant, fill in the unit price measurement: the variant’s total amount (such as 500 g) and the base unit to price it by (such as 100 g). Shopify then works out the price per unit. Shopify offers this setting to stores in some countries only.

Where the price format comes from. Smootify writes every amount with the store’s money format: the HTML without currency field of Shopify’s currency formatting (Settings › General, then Change currency formatting). Smootify keeps the format in the visitor’s browser and refreshes it in the background, so after a change, check it in a private window. The useBrowserFormatter option formats prices for each market with the browser’s own rules instead.

A sale that shows in one country and not in another can be Shopify hiding converted compare-at prices from European countries: see Markets.

Write an element with product="gallery" and put the parts you need inside it. Every part is optional.

  • gallery="slider" goes on an element that holds a [slider-track]. The children of the track are the slides. Smootify makes one slide per image, each a copy of the first slide, and moves between slides by scrolling the track. Put an <img> in the first slide. The other slides you write are removed. Optional [slider-prev] and [slider-next] buttons and a [slider-dots] element go inside the slider: see Write components.
  • gallery="thumbnails" goes on the element that repeats once per image, such as an <img>. Write one, and Smootify repeats it. The current one gets the class is-active and aria-current, and a click moves the slider.
  • gallery="lightbox" goes on an a[lightbox-item] link that opens every image. The link keeps what you put in it, such as a zoom icon.
  • gallery="lightboxes" goes on an a[lightbox-item] link that repeats once per image, with its picture.

Smootify writes the full-size media into the href of each a[lightbox-item] and groups the links of a gallery with data-lightbox-group. It does not draw the viewer: the site brings its own, such as a small <dialog> that reads those links. The example project has one, components/smootify/Lightbox.astro.

---
const first = p.images[0];
---
<div product="gallery" include-media>
  <div gallery="slider">
    <div slider-track>
      <div><img src={first.url} alt={first.altText || p.name} width={first.width} height={first.height} /></div>
    </div>
    <button type="button" slider-prev aria-label="Previous image">&lsaquo;</button>
    <button type="button" slider-next aria-label="Next image">&rsaquo;</button>
  </div>
  <img gallery="thumbnails" src={first.url} alt={first.altText || p.name} />
  <a href={first.url} gallery="lightbox" lightbox-item aria-label="Zoom">Zoom</a>
</div>

Render the first picture of the entry (p.images[0], or p.image and p.imageAlt) in the first slide and in the thumbnail, so the page shows the featured image before Smootify loads. Smootify replaces it with every image of the product. The arrows are disabled at the ends of the track.

Attributes on the product="gallery" element:

  • include-media adds videos, YouTube and Vimeo videos and 3D models. Without it, the gallery shows images only.
  • variant-images shows only the images of the variant the visitor picks: only the red t-shirt when they pick Red. How Smootify knows which images are the red ones is just below.
  • variant-images="alt" groups those images by their alt text instead. The tag after the last # names the variant: Oak chair #Oak, # Red / M. An image without a tag shows for every variant. The alt text is public, since screen readers and Google read it, so write it to make sense read aloud.

Which images belong to which variant. In Shopify, each variant can have one image of its own, chosen on the variant’s page. That image is where the variant’s group starts: the group takes it and the images after it, in their Shopify order, up to the next image chosen for another variant. Photos placed before the first of these images belong to no variant, so the gallery never shows them once a variant is picked. Pick a colour and switch between the two ways:

Example: a t-shirt in three colors

1 The product's images in Shopify, in their order

1
—Cotton t-shirt
2
image of RedFront # Red
3
—Back # Red
4
—Fabric # Red
5
image of BlueFront # Blue
6
—Back # Blue
7
image of GreenFront # Green
8
—Back # Green

2 The visitor picks a colour

3 The gallery shows

To show a photo with every colour, such as the photo of the model, group the images by alt text (variant-images="alt"): an image without # in its alt text shows with every variant.

When the variant changes, the slider moves to its image. Clicking an image does not change the variant. More in gallery parts.

5. Options

A picker shows the values of one option, such as Colour or Size, and changes the variant. There are four, each built on a different plain HTML element:

TagBuilt onWhat goes inside
variant-swatchesbuttonsone <button>: the template of every value
variant-popovera [popover]a button with popovertarget, and the [popover] with one <button>: the template of every value
variant-selectorthe browser’s own <select>the <select> only: Smootify writes its options
variant-dropdownthe same as the popoverthe same parts as variant-popover, which is the form to prefer

A picker goes anywhere inside smootify-product, inside or outside the add-to-cart form. You can use more than one kind on the same page, such as swatches for Colour and a dropdown for Size.

One picker per option. A Shopify product has up to three options, such as Colour, Size and Material. Write one picker for each option of the entry (p.options), with data-option="1", "2" and "3". A picker also removes itself when the product does not have that option, so it is safe to write a picker for an option that only some products have. A product without options shows none.

Which option. Use data-option="1" for the first option, 2 for the second, or data-option-name="Color,Colour" for the first of those names that the product has, written exactly as in Shopify. The name is useful when products name their options differently, or in more than one language. Without either attribute, the picker shows the first option.

Design only the first value. The first button, or the first link, is the template. Smootify copies it for every value and removes the others you designed, so every attribute goes on the first one.

<smootify-add-to-cart>
  <form>
    <div has-option="0">
      <div><span variant="option1-name">Option</span>: <span variant="option1-value">Value</span></div>
      <variant-swatches data-option="1"><button type="button"><span option="bg-color"></span><span option="title">Value</span><span option="price-difference">+ $0</span></button></variant-swatches>
    </div>
    <div has-option="1">
      <div><span variant="option2-name">Option</span>: <span variant="option2-value">Value</span></div>
      <variant-swatches data-option="2"><button type="button"><span option="title">Value</span></button></variant-swatches>
    </div>
    <div has-option="2">
      <div><span variant="option3-name">Option</span>: <span variant="option3-value">Value</span></div>
      <variant-swatches data-option="3"><button type="button"><span option="title">Value</span></button></variant-swatches>
    </div>
    <button type="submit">Add to cart</button>
  </form>
</smootify-add-to-cart>

Each group holds the option’s name (variant="option1-name"), the value picked (variant="option1-value") and the picker. The group’s element has has-option, so it goes away together with its picker when the product does not have that option. Here the count starts at 0: has-option="0" is the first option, "1" the second, "2" the third. has-option works inside the add-to-cart form. Give the buttons of a picker type="button": inside the form, a button without a type also submits it.

Inside the value of swatches, dropdowns and popovers:

  • option="title": the value’s name;
  • option="bg-color" or option="color": the swatch colour, as a background or as the text colour;
  • option="image": the swatch image;
  • option="price-difference": how much more the value costs than the cheapest one, such as ”+ €180”. It is hidden on the cheapest value, and data-format="+ {{price}}" changes the text.

Swatch colours and images come from Shopify: the option’s own swatches, or the option metaobject in the product metafield custom.options. A native <select> can only hold text. On variant-selector, data-format="{{value}} {{difference}}" on the <select> adds the difference to each option’s text.

The selected value gets the class is-active. A value that cannot be bought gets is-disabled but stays clickable, because a sold-out variant can still be chosen. Style both in the component’s CSS. In a popover, the current value also gets aria-current="true".

Show the option and the chosen value. Inside the picker, selected-option="name" writes the option’s name and selected-option="title" the chosen value, for example in a label or a popover trigger that reads “Size: M”. The name comes from the picker’s data-option-name or data-option, so the label never needs to be typed again:

<variant-swatches data-option-name="Size">
  <div><span selected-option="name">Size</span>: <span selected-option="title">M</span></div>
  <button type="button"><span option="title">S</span></button>
</variant-swatches>

Anywhere else in smootify-product, use variant="option1-value" (or option2-value, option3-value) for the value and variant="option1-name" for the name, as in the groups above.

Two attributes on smootify-add-to-cart change how pickers behave. allow-unavailable lets the native select pick values that cannot be bought. avoid-option-update keeps the value picked in a dropdown or popover when the variant changes from another picker.

The page’s product loads all its variants, even above 250.

6. Add to cart

Write a smootify-add-to-cart element and put a <form> inside it. The form is required: without one, the element is removed.

<smootify-add-to-cart>
  <form>
    <quantity-input>
      <button type="button" data-action="minus">-</button>
      <input type="number" name="quantity" value="1" min="1">
      <button type="button" data-action="plus">+</button>
    </quantity-input>
    <button type="submit"><span data-state="default">Add to cart</span><span data-state="loading">Adding…</span></button>
    <button type="submit" name="buy-now">Buy now</button>
  </form>
  <div form-fail hidden></div>
</smootify-add-to-cart>

The buttons. The add button is a <button type="submit">. Anything can go inside it: the elements with data-state="default" and data-state="loading" swap while the product is added. Use a real button tag, because Smootify’s selectors need it. Buy now is optional, a second button next to it: <button type="submit" name="buy-now"> adds the product and goes to checkout.

Quantity. Write a quantity-input element with an <input name="quantity"> inside. The minus and plus buttons are optional: they are <button type="button"> with data-action="minus" or data-action="plus". Without a quantity field, the product is added once. The minus button is disabled and gets is-disabled at the lowest quantity, and the plus button at the highest: the stock left, or the maximum of a B2B quantity rule or of the purchase rules. data-disable-max on quantity-input removes that upper limit.

Extra fields. Any other field in the form, such as a text input, a <select> or a checkbox, is saved on the cart line. Its name is the label, and it shows at checkout: use it for an engraving or a gift message. A name that starts with _ is saved but hidden at checkout. A name that starts with ##ignore is not saved. Extra fields never change the price: for options that cost more, use add-ons or the product configurator.

The label comes from the field’s name: name="Engraving" and name="properties[Engraving]" both save a line property called “Engraving”. A checkbox saves its value when it is checked. Fields inside an element with the hidden attribute are not sent.

When the variant cannot be bought. If the variant is sold out, or the picked combination does not exist, every submit button in the form is disabled and gets the class is-disabled. Shopify’s errors, such as a quantity above the stock, appear in the [form-fail] element, in its last child, with role="alert". The form stays on screen, ready for the next product.

A button outside the form. A sticky bar with its own Add to cart button can sit anywhere on the page. Give the <form> an id, such as product-form, and write the button as <button type="submit" form="product-form">. It adds to the cart, shows its loading label and is disabled with the others. The id must be unique on the page, so use it on the product page, not in a list of cards. The example project has this bar: components/product/StickyAddToCart.astro.

7. Stock, sale and other states

An element with condition="…" inside smootify-product shows only while the product is in that state. The element stays on the page and Smootify shows or hides it. This is separate from the conditions that Astro renders on the server, such as {p.compareAtPrice > p.price && …}, which depend on the entry’s data. Each card shows a value of condition and the kind of message you would put in that element:

in-stock

The selected variant can be bought.

out-of-stock

The selected variant cannot be bought.

low-stock

5 or fewer left (0 counts too).

low-not-last-stock

2 to 5 left.

last-in-stock

Exactly one left.

many-in-stock

More than 5 left.

in-backorder

Out of stock but still sold.

on-sale

A compare-at price above the price.

disabled

The picked combination does not exist.

is-new

Published less than 30 days ago (data-new-days changes it).

subscription

A selling plan is chosen.

no-subscription

No selling plan is chosen.

Every condition is in product conditions.

  • Variants. condition="is-variable" shows an element when the product has more than one variant, such as the options block, and is-not-variable when it has only one.
  • Subscriptions. condition="subscription" follows the visitor’s choice: it shows while a selling plan is chosen in subscription-swatches, and no-subscription while the one-time purchase is. It does not tell whether the product offers subscriptions.
  • Tags. tagged="gift,new" keeps an element only when the product has every one of those tags. Add any="true" to keep it when the product has at least one. not-tagged does the opposite: see tag conditions.

smootify-product also carries classes for the same states, such as is-available, is-on-sale, low-stock, is-variable and loaded. You can style them from the component’s CSS: see state classes.

When the product is not available. Shopify does not return a product that is not published on the Headless channel, or not sold in the visitor’s market. The page’s smootify-product is then hidden, and the page gets the class product-unavailable. Two conditions work anywhere on the page, outside smootify-product too:

  • condition="product-unavailable" shows an element only in that case, such as a message with a country switcher;
  • condition="product-available" does the opposite: it hides an element in that case.
<div condition="product-unavailable">
  <div>This product is not available in your country.</div>
  <country-switcher>
    <button type="button" popovertarget="country-switcher"><span data-prop="name">Country</span></button>
    <div id="country-switcher" popover="auto"><button type="button"><span data-prop="name">Country</span></button></div>
  </country-switcher>
</div>

When the visitor picks a country where the product is sold, the class goes away, the message hides and the product shows. Only the page’s own product counts: cards in lists, cart lines and products inside other products are hidden as before, and do not turn the condition on. On a site with locales, use a [locales] element of links instead of the country-switcher: see Write components.

While it loads. skeleton="text", "button", "block" or "image" shows a placeholder shimmer on an element until Smootify fills it, and skeleton="hide" hides it until then. Only elements that have the attribute shimmer. See loading.

8. Subscriptions

To sell by subscription, the store needs a Shopify subscription app that creates the selling plans. Smootify shows and sells them.

Write a subscription-swatches element inside the form, with WAI-ARIA tabs in it: a [role="tablist"] of [role="tab"] elements, and a [role="tabpanel"] for each tab, paired with its tab by the same data-tab:

  • the first tab and its panel are the one-time purchase. They stay only when there are at least two choices, and are removed when the product can only be bought by subscription;
  • the second tab and its panel are the template for each selling plan group, repeated once per group. In the panel, a <label> with an <input type="radio"> is the template for each plan, and its text goes in an element with form-label;
  • subscription-group="name" shows the group’s name in the tab. A smootify-price inside a plan’s label shows that plan’s price.

Without a [role="tablist"] inside, the element removes itself.

<subscription-swatches>
  <div role="tablist">
    <button type="button" role="tab" data-tab="0">One-time purchase</button>
    <button type="button" role="tab" data-tab="1"><span subscription-group="name">Subscribe</span></button>
  </div>
  <div role="tabpanel" data-tab="0">Pay once</div>
  <div role="tabpanel" data-tab="1" hidden>
    <label><input type="radio"><span form-label>Plan</span><smootify-price><span data-prop="price">$0.00</span></smootify-price></label>
  </div>
</subscription-swatches>

Smootify runs the tabs: click, the arrow keys, Home and End, aria-selected and hidden on the panels. The tabs are keyed by data-tab.

On a product without selling plans, subscription-swatches is removed. The attributes that preselect a plan are in subscription-swatches.

9. Search engines

The Seo component adds the product’s structured data (JSON-LD) to the page at build time, so Google can show the product as a rich result. Use it on the product page with the entry: <Seo type="product" entry={entry} />, as in Content. It includes:

  • the name, description, images and brand;
  • the SKU and GTIN;
  • the offers: the price and the currency of the product, and whether it is available;
  • the rating, when the product has Shopify’s review metafields reviews.rating and reviews.rating_count, with a count above zero (see Product reviews).

It has no list of reviews. To add more objects, pass them to <Seo> in its jsonLd prop. There is no prop that turns the product’s JSON-LD off: <Seo type="other"> writes none.

For Google Shopping, the product feed of Shopify’s Google & YouTube app is still the reference.

Before you publish

In the dev server and on your *.workers.dev address, press Alt⇧ ShiftD (⌥ Option⇧ ShiftD on a Mac) to turn on Smootify’s debugger. On a phone, open the page with ?smootify-debug at the end of the address. Its button then shows on every page of the site, and its panel lists the elements that are out of place. The debugger explains the rest.

Check these first

  • Nothing loads. data-id is empty: it is not set to the entry’s shopifyId.
  • Attributes on a swatch do nothing. They are on the second button: only the first one is the template.
  • A picker disappears. The product does not have that option, or the picker is inside an element that has hidden when the page loads: a picker there does not mount.
  • A metafield stays empty. Its definition in Shopify needs Storefront access.
  • Videos are missing from the gallery. Add include-media to the product="gallery" element.
  • The gallery does not move. gallery="slider" has no [slider-track] in it, or the site’s CSS does not make the track scroll: it needs overflow-x: auto and scroll-snap-type.
  • Buy now does nothing. It is not a button with type="submit" and name="buy-now" inside the form (or with form="<id>").

    ↑ ↓ to moveEnter to openEsc to close