2.0 Beta

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

What changed

Filters, sorting and pagination

Search & discovery lists products live from Shopify, with the filters of Shopify's Search & Discovery app, sorting and pagination written in your components. Covers the block, every filter type, sorting, the count and active filters, pagination with page numbers and links that keep the filters.

v2.0 betaAdd-on: Search & discovery
Open .md
On this page
  1. How it fits together
  2. 1. Add the block and the card
  3. 2. Add the filters
  4. Swatches
  5. Price
  6. Collection
  7. Text search
  8. Search inside a collection
  9. 3. Sorting
  10. Sort labels
  11. 4. Count, active filters and reset
  12. 5. Pagination
  13. Page numbers
  14. 6. Links, mobile panels and an apply button
  15. Other settings
  16. Before you publish

Search & discovery lists products straight from Shopify and lets the visitor filter, sort and page through them. The filters are the ones you set up in Shopify’s Search & Discovery app, such as colour, size, availability or price. You write each filter, the sort menu, the product card and the pagination as plain HTML in your components, and Smootify fills them. Each filter and sort element holds a <form>: Smootify keeps only its id and class, and blocks its submit.

Note

Before you start:

  • install Shopify’s free Search & Discovery app and create your filters in it (Filters, then Add filter). Note the label of each filter: you will write it in the markup;
  • turn on the Search & discovery add-on in the Smootify dashboard. The Lite and Server plans include it. On localhost and on your *.workers.dev address it works without it, so you can build and test first.

How it fits together

What Smootify needs:

  • a smootify-search-discovery element;
  • inside it, one product card: a smootify-product element. Smootify repeats it once per product. Without a card, the block is removed.

What is up to you:

  • the layout: where the filters, the sort menu, the count and the pagination sit, and how they look;
  • which filters you show, and with which kind of element: checkboxes, a dropdown, swatches…;
  • whether the filters sit inside the block or in a panel elsewhere on the page.
---
import { getCollection } from "astro:content";
const limit = 24;
const all = await getCollection("products");
---
<smootify-search-discovery limit={limit}>
  <div filter="count">{all.length}</div>
  <div>
    {all.slice(0, limit).map((p) => (
      <div class="sm-dyn-item">
        <smootify-product data-id={p.data.shopifyId}>
          <a product="url" href={`/products/${p.id}/`}>{p.data.name}</a>
          <smootify-price><div data-prop="price">{p.data.priceFormatted}</div></smootify-price>
        </smootify-product>
      </div>
    ))}
  </div>
</smootify-search-discovery>

1. Add the block and the card

  1. Add a smootify-search-discovery element.
  2. Inside it, add an element for the grid, and inside that render the real product cards: for each of the first products, an sm-dyn-item with a smootify-product whose data-id is the product’s shopifyId. Design it as a product card.

Smootify takes the first .sm-dyn-item as the template: each product of the results gets a copy of its smootify-product, and the whole list is rewritten from the results. So every element of the card needs a Smootify attribute, such as product="title" or variant="image". Text that comes only from the entry would show the first product’s text on every card. No data-id="filter" and no placeholder card is needed: Smootify writes the data-id itself.

Server cards for search engines. The cards you render stay as they are until the first results, so search engines see real products and the page keeps its height while it loads. When the results are the same products in the same order, as the first page served from the seed is, Smootify keeps the drawn cards and nothing flashes. Otherwise it replaces the list in one step. Render only as many as limit.

The first page of results comes from the page’s seed, with no request to Shopify, when the cards match it: the same limit on the element and in the code that picks the cards, the collection’s own order with no sort of your own, and the same unavailable-products as the element. A selected-value on any widget, data-wait, or ?q=, filters or ?page= in the address make a search instead. See First load.

Which products. Without other settings, the block lists the whole catalog. Three attributes narrow it, and the visitor cannot remove them, not even with the reset:

  • data-collection with the collection’s Shopify ID: the products of one collection. Use it on the collection page.
  • data-vendor with the vendor’s name, as written in Shopify: the products of one vendor. Use it on the vendor page. It needs the Vendor filter turned on in Shopify’s Search & Discovery app, as data-tag needs the Tag filter.
  • data-tag with a product tag, such as data-tag="summer": the products with that tag. Next to data-collection it works differently: see Search inside a collection.

How many per page. limit sets the number of products per page: 50 by default, 250 at most. Each card is a full product, so a lower number, such as 24, keeps the page fast, especially on phones.

When a filter picks an option value, such as Colour: Red, each card shows its variant with that value, with its image and price.

2. Add the filters

Each filter element shows one filter of Shopify’s Search & Discovery app. Put it inside smootify-search-discovery and give it the attribute label with the filter’s label, written exactly as in the app, with its case, such as Color. Pick the element by how the filter should look. The name the visitor reads is yours: write it as a <span>, <p> or <legend> outside the list of values, and use custom-label for the name shown in the active filters.

ElementBuilt withLabelThe visitor picks
filter-checkboxa <form> with one <label> holding a checkboxlabel="Color"several values
filter-lista <form> with one <label> holding a radiolabel="Size"one value
filter-selecta <form> with a <select>label="Material"one value
filter-popovera <form> and a [popover] with links or buttonslabel="Material"one value
filter-swatchesa <form> and one <button>label="Color"one value, or several with multiple="true"
filter-pricea <form> with two number inputs and two range inputsnone: it reads no labela price range
filter-searcha <form> with a text inputnone: it reads no labelwords to search

Without its <form>, or without a label where it needs one, the element is removed.

<filter-checkbox label="Color" custom-label="Colour" show-count>
  <p>Colour</p>
  <form>
    <label>
      <input type="checkbox" name="Color">
      <span form-label>Value</span>
      <span class="filter-count-label"></span>
    </label>
  </form>
</filter-checkbox>

Write one value only. The first <label> of filter-checkbox and filter-list, the first a or button of the popover of filter-popover, and the button of filter-swatches is the template, and Smootify copies it for every value of the filter. Write only one: Smootify does not remove extra labels, and removes the extra buttons or links of the others. Write the popover’s option as <button type="button"> or <a>: a button with popovertarget is the trigger, not a value. In a <select>, the first option stays as the placeholder, and Smootify writes the values after it. With show-count, the number goes into the <span class="filter-count-label"> of the value row, and a value with no products gets the class empty-count.

Choices apply at once. A change reloads the products. For a panel that applies on a click, see the apply button in step 6.

Attributes on each filter element:

  • show-count adds the number of products next to each value, such as “Red (12)”; add hide-parenthesis for “Red 12”. A value with no products gets the class empty-count.
  • selected-value="Red" selects that value when the page opens. On filter-checkbox, list several with commas. The visitor cannot remove a preselected value unless you add allow-reset="true".
  • custom-label="Colour" changes the name shown in the active filters (see step 4).

A filter whose values are all gone hides itself. On smootify-search-discovery, hide-empty also hides the values with no products, and hide-if-single hides a filter that has only one value.

Style the states Smootify writes. Smootify writes aria-current="true" on the chosen option of a popover, checked on a radio or a checkbox, aria-pressed together with the classes active and is-active on a swatch, and empty-count on a value with no products. Style them with CSS: :checked, :has(:checked), [aria-current="true"]. The trigger of a popover gets aria-expanded.

Never write hidden on a filter or sort element. Smootify sets style.display on them itself, and hidden always wins over it. To hide the sample rows of the values until Smootify has filled them, use CSS on the block’s class: smootify-search-discovery:not(.inited) .filter-values { visibility: hidden; }, where filter-values is the class of your value list.

What the app decides. Each filter’s settings in Shopify’s Search & Discovery app apply on the site too. Values you group in the app show as one value, and the values keep the order you set there: Smootify does not sort them again. The behaviour you choose for a filter, such as whether two values combine with AND or OR, is applied by Shopify to the results. Give each filter of the app one element: to show the filters in a mobile panel, move them there with data-expand (step 6) rather than making a second copy.

Languages. Shopify translates the filter values, with these rules of its own:

  • the values of the Vendor and Tag filters are never translated: vendors always show in the store’s default language;
  • the Tag filter shows only to visitors browsing in the store’s default language;
  • translations follow the languages published in Shopify, not your markets.

Swatches

Inside the template button of filter-swatches, swatch="bg-color" paints the value’s colour as a background, swatch="color" as the text colour, swatch="image" shows its image, and swatch="title" its name. The colour or image is the value’s swatch in Shopify’s filter: see Swatch colours and images. The chosen button gets the class is-active. More in filter swatch parts.

Write the button as <button type="button" swatch="bg-color" aria-label="Color"> inside the form. The chosen button also gets aria-pressed="true".

filter-swatches keeps only its first button. Put any other button, such as an apply button, outside it.

Price

filter-price is always the price filter, so it needs no label. Inside it:

  • a <form> with two <input type="number">, for the minimum and the maximum, and two <input type="range">, the two range sliders;
  • an element with filter-price="progress": the bar between the two handles;
  • optionally, filter-price="from-label" and filter-price="to-label" on text, for the selected minimum and maximum.
<filter-price custom-label="Price">
  <form>
    <input type="number" aria-label="From"><input type="number" aria-label="To">
    <input type="range" aria-label="Minimum price"><input type="range" aria-label="Maximum price">
  </form>
  <div><div filter-price="progress"></div></div>
  <div><span filter-price="from-label">$0</span> – <span filter-price="to-label">$100</span></div>
</filter-price>

It needs exactly two number fields and two sliders: see price filter parts. The range starts from the prices of the first results. Shopify shows the price filter only in the shop’s default currency.

Style the sliders yourself. Smootify adds no style to them: write their CSS in the component, for example the size and colour of the handles with ::-webkit-slider-thumb and ::-moz-range-thumb. Smootify places filter-price="progress" with its left and right edges, as a percentage of the track, so give it an absolute position inside a parent with a relative position.

Collection

label="Collection" makes a filter of all your collections. It works with the single-choice elements (filter-list, filter-select, filter-dropdown, filter-popover, filter-swatches), not with filter-checkbox. While a collection is chosen, the text search looks inside it: see Search inside a collection.

filter-search holds a <form> with a text <input>. Typing searches after a short pause. If the form has a submit button, the search runs on submit instead. It reads no label.

Search inside a collection

The text search also works on a collection: with data-collection, or while the visitor has chosen a collection in a Collection filter. Shopify has no search inside a collection, so Smootify compares two lists and shows the products in both:

  • the results of the search, up to 1,000, Shopify’s limit;
  • the products of the collection, up to 2,000. In a bigger collection, only the first 2,000 are compared.

What changes compared with a plain search:

  • Order. Relevance and price follow the order of the search. Best selling, title and date follow the order of the collection.
  • Filters. Their values and counts come from the search, so they count the whole catalog, not only the collection.
  • Pages. Previous and next, and the page numbers, work as usual.

For an exact search, use a tag. On a large or important collection, give its products a tag in Shopify, such as summer, and add data-tag="summer" to the block next to data-collection:

  • without a search, the block still shows the collection, in its order;
  • with a search, Smootify searches only the products with that tag. The results are exact, and the filters count only those products.

data-tag also works without data-collection: the block then always lists the products with that tag.

Warning

data-tag needs the Tag filter turned on in Shopify’s Search & Discovery app. Without it, Shopify ignores the tag without an error, and the tag narrows nothing. The debugger tells you when this happens.

3. Sorting

A sort element goes inside smootify-search-discovery, like a filter, with a <form> inside it. There are three, and sort-popover is the one for a menu:

ElementBuilt withGood to know
sort-selecta <form> with a <select>the first option stays as the placeholder
sort-radioa <form> with one <label> holding a radiothe label is the template of every option
sort-popovera <form>, a trigger button with popovertarget, and a [popover] with buttons or linksthe trigger shows the current sort

The options come from Shopify, and depend on the list:

  • the whole catalog: relevance, and price low to high and high to low;
  • one collection (data-collection, or a chosen Collection filter): also best selling, title A to Z and Z to A, and oldest and newest first.

selected-value on the sort element picks an option when the page opens, by its label, such as selected-value="Price (Low to High)". It makes the first page a search instead of a seed, and the server cards would have to be in that order, so leave it out unless the design needs it. Without it the store’s own order stays, and the trigger keeps the text you wrote, such as “Featured”, until the visitor picks an option: Shopify does not tell which order a collection uses. A label of yours next to the chosen order, such as “Sort:”, stays as you wrote it: the trigger then reads “Sort: Price (Low to High)”. When the trigger holds other text, put the current sort in an element with data-selected.

Sort labels

Each option has an English label, and each list shows only some of them:

KeyLabelShown for
relevanceRelevanceevery list
price_ascPrice (Low to High)every list
price_descPrice (High to Low)every list
best_sellingBest Sellingone collection
title_ascTitle (A - Z)one collection
title_dscTitle (Z - A)one collection
created_ascOldest to newestone collection
created_dscNewest to oldestone collection

To change them, add this script to the head of your layout. Write only the labels you change: the others keep their English text. Write it as <script is:inline>, so that Astro leaves it as it is.

<script is:inline>
  window.SmootifySortLabels = {
    relevance: "Featured",
    price_asc: "Price, low to high",
    price_desc: "Price, high to low",
    best_selling: "Best sellers",
    title_asc: "Name, A to Z",
    title_dsc: "Name, Z to A",
    created_asc: "Oldest first",
    created_dsc: "Newest first",
  };
</script>

selected-value matches the label as shown: after a change, write the new label, such as selected-value="Price, low to high".

Several languages. On a site with locales, each page sets its language in <html lang>. Pick the labels from it, and keep the English ones for the other languages:

<script is:inline>
  window.SmootifySortLabels = {
    it: { relevance: "In evidenza", price_asc: "Prezzo crescente", price_desc: "Prezzo decrescente" },
    fr: { relevance: "Pertinence", price_asc: "Prix croissant", price_desc: "Prix décroissant" },
  }[document.documentElement.lang.slice(0, 2)] || {};
</script>

4. Count, active filters and reset

These elements go anywhere inside smootify-search-discovery. All are optional.

  • filter="count" on text shows the total number of results.
  • filter="active" is the template of one active filter: a <button type="button">, a div or a link. Smootify makes one copy per active filter, and a click on a copy removes that filter. Inside it, filter="active-label" shows “Colour: Red”.
  • filter="active-count" shows how many filters the visitor can remove.
  • A reset button: a <button type="reset">. It removes every filter the visitor can remove, and the search words. It is disabled and gets is-disabled when there is nothing to remove.
  • filter="empty-state" on any element shows only when no product matches.
<div>
  <button type="button" filter="active"><span filter="active-label">Colour: Red</span></button>
  <button type="reset">Clear all</button>
</div>
<div filter="empty-state">No products match these filters.</div>

Filters the visitor cannot remove, such as a value preselected without allow-reset, come first among the active filters, with the class preselected-filter, and a click on them does nothing.

The classes of the block. Smootify writes classes on smootify-search-discovery that you can style: inited after the first render, empty when no product matches, is-filtered when a filter, a search or a sort is active, has-pagination when there is a previous or next page, has-page-numbers when numbered pages show, and is-single-result when there is one result, to write “product” instead of “products”. Before it starts, the stylesheet already hides [filter="empty-state"] unless the block has empty. Hide and show the rest with CSS on these classes, never with hidden: the active filters with is-filtered, and the pagination buttons with inited and has-pagination. See state classes.

5. Pagination

Pagination buttons are <button type="button"> elements inside smootify-search-discovery. Their data-action decides how the visitor moves through the results:

data-action="previous"data-action="next"

Previous and next

Shows
One page at a time.
Good to know
The best choice for large catalogs.
data-action="page"

Page numbers

Shows
One page at a time, with the numbers to jump to, between previous and next.
Good to know
On a collection, it needs the Availability filter (see Page numbers).
data-action="load"

Load more

Shows
The next page, added below the products shown, on a click.
Good to know
Every page adds cards, which slows phones down on large catalogs.
data-action="load-in-view"

Load as the visitor scrolls

Shows
The next page, added below when the button scrolls into view.
Good to know
Every page adds cards, which slows phones down on large catalogs.

A button is hidden and disabled when there is no such page. Use previous and next, or one of the two load buttons, not all of them. Buttons inside a product card, such as the previous and next of a booking calendar, are not pagination buttons.

Smootify marks a button it hides with its own attribute. Before Smootify starts, and when there is only one page, hide the buttons with CSS on the block’s classes, for example smootify-search-discovery:not(.inited) button[data-action] and smootify-search-discovery.inited:not(.has-pagination) button[data-action].

When new results appear, the page scrolls to the block. disable-scroll on smootify-search-discovery turns this off, and disable-scroll-on-load-more turns it off for the load buttons only.

Page numbers

Next to previous and next, the block can show page numbers, such as 1 … 4 5 6 … 12:

<smootify-search-discovery limit="24" page-range="1">
  <div>
    <div class="sm-dyn-item">
      <smootify-product data-id="1234567890"><a product="url" href="#">Product name</a></smootify-product>
    </div>
  </div>
  <div>
    <button type="button" data-action="previous">Previous</button>
    <button type="button" data-action="page"><span filter="page-number">1</span></button>
    <div filter="page-ellipsis">…</div>
    <button type="button" data-action="next">Next</button>
  </div>
  <div>Page <span filter="page-current">1</span> of <span filter="page-total">12</span></div>
</smootify-search-discovery>
  • One number button. A <button type="button" data-action="page"> is the template of every number: Smootify copies it once per number shown. Inside it, filter="page-number" holds the number; without it, the number becomes the button’s text. The current page gets the class is-active, aria-current="page" and disabled, so it cannot be clicked: style them.
  • The gap. filter="page-ellipsis" is the template of the gaps between numbers, such as …. It is optional. When the gap is a single page, that page’s number shows instead.
  • How many numbers. page-range on smootify-search-discovery sets how many pages show on each side of the current one, 1 by default. The first and the last page always show.
  • “Page 3 of 12”. filter="page-current" and filter="page-total" on text. The total stays empty until Smootify knows it.
  • The class has-page-numbers is on smootify-search-discovery while there is more than one page to number: use it to hide the number bar otherwise.
  • In the address. With sync-query-params, the address also gets the page from the second one on, such as ?page=3, and a link with ?page=3 opens that page.

Note

On a collection, page numbers need the Availability filter turned on in Shopify’s Search & Discovery app. Shopify does not give the number of products in a collection, so Smootify adds it up from that filter. Without it the numbers do not show (previous and next still work), filter="count" shows only the products of the page, and on the dev server the debugger says why. A search counts at most 1,000 results, a Shopify limit.

Opening a page far from the current one takes a moment longer: Shopify gives the pages one after the other, so Smootify first reads the pages before it, with small requests. The pages already seen, and the next and previous ones, open at once.

Filters in the address. With sync-query-params on smootify-search-discovery, the address follows the filters, such as ?Color=Red, so a filtered page can be shared. Locked filters stay out of it. Even without it, a link to the page with a filter’s label and a value, such as /shop?Color=Red, opens it with that filter on, as long as the page has a filter element with that label. ?q= followed by words opens it with that search.

Filters outside the block. Filters and sort elements can sit in a panel elsewhere on the page, such as a side panel on mobile. Give the panel an id, and add data-expand="#filters" (with that id) to smootify-search-discovery. Inside the block they need nothing more: a [popover] drawer inside it works as it is. See Popovers.

Filters in a popover. With auto-close-details="true" on smootify-search-discovery, a popover of filters inside it closes once the results change. See Popovers.

An apply button. A <button type="submit">, inside the block but outside the filters’ forms, changes how filters apply: a choice updates the filters only, and the products reload when the visitor clicks the button.

Other settings

On smootify-search-discovery:

  • animation (fade by default, slide, scale, fadeBlurred, slideBlurred or none), duration, easing and stagger set how new cards appear;
  • unavailable-products sets where products that cannot be bought go: LAST (the default), HIDE or SHOW. It does not apply inside a collection;
  • smootify:search_discovery:rendered is an event on document, sent after the results are drawn. Listen to it to wire your own script on the new cards: see Events.

Every attribute is in smootify-search-discovery and search & discovery attributes.

While it loads. Add skeleton to the filters, the count and the card elements that should shimmer until the first results. Smootify removes it when it writes them. See loading.

Before you publish

Shopify’s own limits apply: a collection with more than 5,000 products, or a search with more than 100,000 results, shows no filters, and a filter shows at most 100 values. When a filter has more, group similar values in the app: a group holds at most 200 values, and a store can have at most 1,000 groups.

Check these first

  • A filter never shows. Its label is not written exactly as in Shopify’s Search & Discovery app, or none of the listed products has a value for it.
  • A filter element disappears. It has no <form> inside, or it sits outside smootify-search-discovery and outside the data-expand panel.
  • A filter, the empty state or Load more never shows. It has hidden. Smootify cannot remove it: take it off, and hide with CSS on the classes of the block.
  • Every card shows the same name or image. Those elements have no Smootify attribute, so the first card’s text is copied. Add product="title", variant="image" and the other attributes.
  • The list reshuffles at load. The server cards are not in the collection’s own order, or there are more of them than limit.
  • Only three sort options. Best selling, title and date exist only inside a collection.
  • data-tag changes nothing. The Tag filter is off in Shopify’s Search & Discovery app. Turn it on.
  • data-vendor changes nothing. The Vendor filter is off in Shopify’s Search & Discovery app. Turn it on.
  • A search in a big collection misses products. Only the first 2,000 products of the collection are compared with the search. Give the collection’s products a tag and use data-tag.
  • The count shows only the products of the page, and no page numbers appear. On a collection, the total comes from the Availability filter. Turn it on in Shopify’s Search & Discovery app.
  • The page is slow on phones. Lower limit, and use previous and next instead of loading more.

    ↑ ↓ to moveEnter to openEsc to close