# 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.

Status: Ready (Smootify 2.0)  
Add-on: Search & discovery  
URL: https://www.smootify.io/docs/astro/build/filters  
Updated: 2026-10-21

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.

```astro
---
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](https://www.smootify.io/docs/astro/build/product-cards).

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](https://www.smootify.io/docs/astro/start/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](https://www.smootify.io/docs/astro/build/collection-page).
- `data-vendor` with the vendor’s name, as written in Shopify: the products of one vendor. Use it on the [vendor page](https://www.smootify.io/docs/astro/build/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](#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.

| Element | Built with | Label | The visitor picks |
|---|---|---|---|
| `filter-checkbox` | a `<form>` with one `<label>` holding a checkbox | `label="Color"` | several values |
| `filter-list` | a `<form>` with one `<label>` holding a radio | `label="Size"` | one value |
| `filter-select` | a `<form>` with a `<select>` | `label="Material"` | one value |
| `filter-popover` | a `<form>` and a `[popover]` with links or buttons | `label="Material"` | one value |
| `filter-swatches` | a `<form>` and one `<button>` | `label="Color"` | one value, or several with `multiple="true"` |
| `filter-price` | a `<form>` with two number inputs and two range inputs | none: it reads no label | a price range |
| `filter-search` | a `<form>` with a text input | none: it reads no label | words to search |

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

```html
<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](https://www.smootify.io/docs/astro/build/swatches). The chosen button gets the class `is-active`. More in [filter swatch parts](https://www.smootify.io/docs/astro/reference/attributes/filter-swatch).

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.

```html
<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](https://www.smootify.io/docs/astro/reference/attributes/filter-price). 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](#search-inside-a-collection).

### Text search

`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:

| Element | Built with | Good to know |
|---|---|---|
| `sort-select` | a `<form>` with a `<select>` | the first option stays as the placeholder |
| `sort-radio` | a `<form>` with one `<label>` holding a radio | the label is the template of every option |
| `sort-popover` | a `<form>`, a trigger `button` with `popovertarget`, and a `[popover]` with buttons or links | the 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:

| Key | Label | Shown for |
|---|---|---|
| `relevance` | Relevance | every list |
| `price_asc` | Price (Low to High) | every list |
| `price_desc` | Price (High to Low) | every list |
| `best_selling` | Best Selling | one collection |
| `title_asc` | Title (A - Z) | one collection |
| `title_dsc` | Title (Z - A) | one collection |
| `created_asc` | Oldest to newest | one collection |
| `created_dsc` | Newest to oldest | one 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.

```astro
<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:

```astro
<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.

```html
<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](https://www.smootify.io/docs/astro/reference/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:

| Way | `data-action` | Shows | Good to know |
|---|---|---|---|
| Previous and next | `previous`, `next` | one page at a time | the best choice for large catalogs |
| Page numbers | `page` | one page at a time, with the numbers to jump to, between previous and next | on a collection, it needs the Availability filter (see Page numbers) |
| Load more | `load` | the next page, added below the products shown, on a click | every page adds cards, which slows phones down on large catalogs |
| Load as the visitor scrolls | `load-in-view` | the next page, added below when the button scrolls into view | 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`:

```html
<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.

## 6. Links, mobile panels and an apply button

**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](https://www.smootify.io/docs/astro/build/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](https://www.smootify.io/docs/astro/build/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](https://www.smootify.io/docs/astro/developers/events).

Every attribute is in [`smootify-search-discovery`](https://www.smootify.io/docs/astro/reference/elements/smootify-search-discovery) and [search & discovery attributes](https://www.smootify.io/docs/astro/reference/attributes/search-discovery).

**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](https://www.smootify.io/docs/astro/reference/attributes/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.

> [!STUCK]
> - **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.
