# Recently viewed products: free Shopify section

> Show each shopper the products they viewed, newest first. Handles stay in their browser; prices and stock come live from your store. No app, no tracking.

Recently viewed products is a free Shopify section that shows each shopper the products they looked at, newest first. On a product page it notes the product's handle in the shopper's own browser (localStorage); wherever the section sits it reads those handles and asks your store for each product's current title, image, price and availability, so a sold-out or discounted product shows as it is now. Nothing is sent to an app or a tracking service, deleted products drop out, a "Clear history" button empties the list, and the section stays hidden until there is something to show. It works in Online Store 2.0 themes without an app.

- **Category:** Product and collection discovery
- **Use on:** Product, Home, Collection, Cart
- **Reads:** Product handles in the shopper's browser (localStorage), current product data from the store
- **Size:** 34 KB in 3 files, with a small script
- **License:** MIT, © 2026 Lintel
- **Page:** https://lintelagency.com/resources/shopify-sections/recently-viewed-products
- **Live demo:** https://lintelagency.com/resources/shopify-sections/demo/lib-recently-viewed.html
- **Added:** 2026-11-02

## What it’s for

- Under the product details, so shoppers can jump back to the products they were comparing
- On the cart page or the home page, to bring back products a returning visitor looked at
- On a 404 or search page, as a short way back to products the shopper already knows

## Editor settings

In the theme editor the section is called “Lib · Recently viewed”. IDs, types, labels and defaults come from the section’s schema; the labels are what the theme editor shows.

### Content

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `heading` | `inline_richtext` | Heading | Recently viewed |  |
| `heading_tag` | `select` | Heading tag | h2 | `h1`, `h2`, `h3`, `p` |
| `heading_size` | `select` | Heading size | medium | `small`, `medium`, `large` |
| `text` | `richtext` | Intro text | – |  |
| `clear_label` | `text` | Clear history button | Clear history |  |
| `cleared_label` | `text` | Message after clearing | Your recently viewed list is now empty. |  |
| `show_on_product_pages` | `checkbox` | Show the list on product pages | `true` |  |

### Products

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `products_to_show` | `range` | Products to show | `4` | 2–12, step 1 |
| `image_ratio` | `select` | Image shape | portrait | `square`, `portrait`, `tall`, `landscape` |
| `card_style` | `select` | Card style | plain | `plain`, `card` |
| `card_radius` | `range` | Corner radius | `12` | 0–24 px, step 2 |
| `show_vendor` | `checkbox` | Show vendor | `false` |  |
| `sale_label` | `text` | Sale badge | Sale |  |
| `sold_out_label` | `text` | Sold out badge | Sold out |  |
| `sale_price_label` | `text` | Sale price label (screen readers) | Sale price |  |
| `regular_price_label` | `text` | Regular price label (screen readers) | Regular price |  |

### Layout

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `columns` | `range` | Columns on desktop | `4` | 2–6, step 1 |
| `columns_mobile` | `range` | Columns on mobile | `2` | 1–2, step 1 |
| `layout_mobile` | `select` | Layout on mobile | row | `grid`, `row` |
| `section_width` | `select` | Width | page | `page`, `full` |
| `text_alignment` | `select` | Heading alignment on desktop | left | `left`, `center` |
| `text_alignment_mobile` | `select` | Heading alignment on mobile | left | `left`, `center` |

### Colors

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `color_bg` | `color` | Background | #FFFFFF |  |
| `color_bg_gradient` | `color_background` | Background gradient | – |  |
| `color_text` | `color` | Text | #1F1B17 |  |
| `color_accent` | `color` | Accent (badges, focus) | #7A5A3A |  |
| `color_accent_text` | `color` | Text on accent | #FFFFFF |  |
| `color_card` | `color` | Card background | #FAF6F1 |  |

### Spacing and visibility

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `padding_top` | `range` | Top padding on desktop | `56` | 0–200 px, step 4 |
| `padding_bottom` | `range` | Bottom padding on desktop | `56` | 0–200 px, step 4 |
| `padding_top_mobile` | `range` | Top padding on mobile | `40` | 0–160 px, step 4 |
| `padding_bottom_mobile` | `range` | Bottom padding on mobile | `40` | 0–160 px, step 4 |
| `show_on` | `select` | Show on | all | `all`, `desktop`, `mobile` |
| `anchor_id` | `text` | Anchor ID | – |  |

### Motion

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `animate` | `checkbox` | Fade cards in as they load | `true` |  |

## Install

### In Shopify admin

1. Work on a copy, not your live theme: in Shopify admin go to **Online Store > Themes**, open the **…** menu on your current theme and choose **Duplicate**.
2. On the copy, open the **…** menu and choose **Edit code**.
3. In the `sections` folder, add a new file named `lib-recently-viewed.liquid` and paste the contents of `sections/lib-recently-viewed.liquid` from the source below. Save.
4. In the `snippets` folder, add `lib-product-card.liquid` and `lib-image.liquid` the same way. If a snippet is already there from another Lib section, it is the same file; keep one copy.
5. Open **Customize** on the copy, choose **Add section** and pick **Lib · Recently viewed**. Replace the sample content with your own and set the colors.
6. Preview the copy, then publish it when it looks right.

### With Shopify CLI

From your local theme folder, download the files and push only them to an unpublished theme (find its ID with `shopify theme list`):

```sh
curl -fsSL -o sections/lib-recently-viewed.liquid https://lintelagency.com/resources/shopify-sections/files/lib-recently-viewed.liquid
curl -fsSL -o snippets/lib-product-card.liquid https://lintelagency.com/resources/shopify-sections/files/lib-product-card.liquid
curl -fsSL -o snippets/lib-image.liquid https://lintelagency.com/resources/shopify-sections/files/lib-image.liquid
shopify theme push --theme <unpublished-theme-id> --only sections/lib-recently-viewed.liquid --only snippets/lib-product-card.liquid --only snippets/lib-image.liquid
```

Never push to the live theme: always pass the ID of an unpublished theme and never use `--live` or `--allow-live`. Then add the section in the theme editor as above.

## Files

- [sections/lib-recently-viewed.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-recently-viewed.liquid): goes in `sections/`
- [snippets/lib-product-card.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-product-card.liquid): goes in `snippets/`
- [snippets/lib-image.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-image.liquid): goes in `snippets/`

## Questions

### Where is the viewing history stored?

Only in the shopper's browser, in localStorage under one key, as a list of product handles (up to 24). No name, email, cookie ID or anything else is kept, and nothing is sent to Lintel or an app. Clearing site data or pressing the section's "Clear history" button removes it.

### Do I need to add it to the product template?

Yes. The section notes a product only on product pages, so add it to the product template, then to any other template where you want the list. If you don't want the list on product pages, turn off "Show the list on product pages": the product is still noted there.

### Are the prices and stock up to date?

Yes. For every handle the section asks your store for the product's current data (/products/<handle>.js), so prices, compare-at prices and sold-out state are what your store shows today. Products you deleted drop out of the list.

### Does it need a cookie banner or consent?

It keeps product handles on the device for the shopper's own use, not to track or profile them, which privacy rules usually treat as functional storage. Your obligations depend on your markets and your privacy policy, so check with your own advice if you're unsure.

### What do shoppers see before they have viewed anything?

Nothing: the section stays hidden until at least one product can be shown, and without JavaScript it never appears. In the theme editor it shows placeholder cards so you can style it.

## Source

### sections/lib-recently-viewed.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{%- comment -%}
  Recently viewed products: cards for the products this shopper looked at, newest first. On a
  product page the section notes the product's handle in the browser's localStorage (nothing else,
  and nothing leaves the device); wherever the section is placed it reads those handles, asks the
  store for each product's current details (/products/<handle>.js) and builds the cards. Products
  that were deleted drop out of the list. The section stays hidden until there is something to show,
  and a "clear history" button empties the list. Without JavaScript it renders nothing.
  Depends on: snippets/lib-product-card.liquid, snippets/lib-image.liquid
{%- endcomment -%}

{%- liquid
  assign s = section.settings
  assign heading_tag = s.heading_tag | default: 'h2'
  assign card_tag = 'h3'
  if heading_tag == 'h3' or heading_tag == 'p'
    assign card_tag = 'h4'
  endif
  assign cols = s.columns | plus: 0
  assign cols_m = s.columns_mobile | plus: 0
  assign img_vw = 100 | divided_by: cols
  assign img_sizes = '(min-width: 990px) ' | append: img_vw | append: 'vw, (min-width: 750px) 33vw, 50vw'
  assign current_handle = ''
  if product != blank
    assign current_handle = product.handle
  endif
  assign list_hidden = false
  if product != blank and s.show_on_product_pages == false
    assign list_hidden = true
  endif
-%}

<lib-recently-viewed
  class="lib-recent lib-recent--{{ s.section_width }}"
  {% if s.anchor_id != blank %}
    id="{{ s.anchor_id | handleize }}"
  {% endif %}
  data-show="{{ s.show_on }}"
  data-card="{{ s.card_style }}"
  data-layout-m="{{ s.layout_mobile }}"
  data-current="{{ current_handle | escape }}"
  data-limit="{{ s.products_to_show }}"
  data-root="{{ routes.root_url }}"
  data-currency="{{ cart.currency.iso_code }}"
  data-locale="{{ request.locale.iso_code }}"
  {% if list_hidden %}
    data-record-only
  {% endif %}
  {% if s.animate %}
    data-animate
  {% endif %}
  {% unless request.design_mode %}
    hidden
  {% endunless %}
  style="
    --lib-bg: {{ s.color_bg }};
    {% if s.color_bg_gradient != blank %}--lib-bg-image: {{ s.color_bg_gradient }};{% endif %}
    --lib-text: {{ s.color_text }};
    --lib-accent: {{ s.color_accent }};
    --lib-accent-text: {{ s.color_accent_text }};
    --lib-card: {{ s.color_card }};
    --lib-cols: {{ cols }};
    --lib-cols-m: {{ cols_m }};
    --lib-radius-card: {{ s.card_radius }}px;
    --lib-pt: {{ s.padding_top }}px;
    --lib-pb: {{ s.padding_bottom }}px;
    --lib-pt-m: {{ s.padding_top_mobile }}px;
    --lib-pb-m: {{ s.padding_bottom_mobile }}px;
  "
>
  <div class="lib-recent__inner">
    <header class="lib-recent__header" data-align="{{ s.text_alignment }}" data-align-m="{{ s.text_alignment_mobile }}">
      <div class="lib-recent__copy">
        {%- if s.heading != blank -%}
          <{{ heading_tag }} class="lib-recent__heading lib-recent__heading--{{ s.heading_size }}">
            {{- s.heading -}}
          </{{ heading_tag }}>
        {%- endif -%}
        {%- if s.text != blank -%}
          <div class="lib-recent__text">{{ s.text }}</div>
        {%- endif -%}
      </div>
      {%- if s.clear_label != blank -%}
        <button type="button" class="lib-recent__clear" data-clear>{{ s.clear_label | escape }}</button>
      {%- endif -%}
    </header>

    <ul class="lib-recent__list" role="list" data-list>
      {%- if request.design_mode -%}
        {%- comment -%} Editor only: shoppers see their own products, never these placeholders. {%- endcomment -%}
        {%- for i in (1..cols) -%}
          <li class="lib-recent__item" data-placeholder>
            {%- render 'lib-product-card', title_tag: card_tag, ratio: s.image_ratio -%}
          </li>
        {%- endfor -%}
      {%- endif -%}
    </ul>
    <p class="lib-recent__status" role="status" data-status></p>

    <template data-card-template>
      <li class="lib-recent__item">
        {%- render 'lib-product-card',
          template: true,
          title_tag: card_tag,
          ratio: s.image_ratio,
          show_vendor: s.show_vendor,
          sold_out_label: s.sold_out_label,
          sale_label: s.sale_label,
          sale_price_label: s.sale_price_label,
          regular_price_label: s.regular_price_label
        -%}
      </li>
    </template>
    <span hidden data-sizes="{{ img_sizes }}" data-cleared="{{ s.cleared_label | escape }}"></span>
  </div>
</lib-recently-viewed>

{% stylesheet %}
  .lib-recent {
    display: block;
    background-color: var(--lib-bg);
    background-image: var(--lib-bg-image, none);
    color: var(--lib-text);
    padding: var(--lib-pt) 1rem var(--lib-pb);
  }
  .lib-recent[hidden] { display: none; }
  .lib-recent__inner { max-width: 1200px; margin: 0 auto; }
  .lib-recent--full .lib-recent__inner { max-width: none; }
  @media (max-width: 749px) {
    .lib-recent { padding-top: var(--lib-pt-m); padding-bottom: var(--lib-pb-m); }
    .lib-recent[data-show='desktop'] { display: none; }
  }
  @media (min-width: 750px) {
    .lib-recent[data-show='mobile'] { display: none; }
  }

  .lib-recent__header {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 0.75rem 1.5rem;
    margin-bottom: clamp(1.25rem, 3vw, 2rem);
  }
  .lib-recent__copy { min-width: 0; max-width: 40rem; }
  .lib-recent__heading {
    margin: 0;
    font-size: clamp(1.5rem, 2.6vw, 2.125rem);
    line-height: 1.15;
    letter-spacing: -0.02em;
    text-wrap: balance;
  }
  .lib-recent__heading--small { font-size: clamp(1.125rem, 1.6vw, 1.375rem); }
  .lib-recent__heading--large { font-size: clamp(2rem, 4vw, 3rem); }
  .lib-recent__text { margin-top: 0.5rem; font-size: 1rem; line-height: 1.6; opacity: 0.85; }
  .lib-recent__text > :first-child { margin-top: 0; }
  .lib-recent__text > :last-child { margin-bottom: 0; }
  .lib-recent__header[data-align='center'] { flex-direction: column; align-items: center; text-align: center; }
  @media (max-width: 749px) {
    .lib-recent__header[data-align-m='left'] { flex-direction: row; align-items: flex-end; text-align: left; }
    .lib-recent__header[data-align-m='center'] { flex-direction: column; align-items: center; text-align: center; }
  }

  .lib-recent__clear {
    min-height: 2.75rem;
    padding: 0.5rem 0.25rem;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.875rem;
    text-decoration: underline;
    text-underline-offset: 0.2em;
    cursor: pointer;
  }
  .lib-recent__clear:focus-visible { outline: 2px solid var(--lib-accent); outline-offset: 2px; border-radius: 4px; }

  .lib-recent__list {
    display: grid;
    grid-template-columns: repeat(var(--lib-cols), minmax(0, 1fr));
    gap: clamp(1rem, 2vw, 1.5rem);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .lib-recent__item { display: flex; min-width: 0; }
  @media (max-width: 749px) {
    .lib-recent__list { grid-template-columns: repeat(var(--lib-cols-m), minmax(0, 1fr)); }
    /* Row: one swipeable line with a peek of the next card. */
    .lib-recent[data-layout-m='row'] .lib-recent__list {
      grid-auto-flow: column;
      grid-template-columns: none;
      grid-auto-columns: calc((100% - 1rem) / var(--lib-cols-m) - 2.5rem);
      overflow-x: auto;
      scroll-snap-type: x mandatory;
      scrollbar-width: none;
      padding-bottom: 0.25rem;
    }
    .lib-recent[data-layout-m='row'] .lib-recent__item { scroll-snap-align: start; }
  }
  .lib-recent__status:empty { display: none; }
  .lib-recent__status { margin: 0; font-size: 0.9375rem; opacity: 0.85; }

  @media (prefers-reduced-motion: no-preference) {
    .lib-recent[data-animate] .lib-recent__item[data-new] { animation: lib-recent-in 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
  }
  @keyframes lib-recent-in {
    from { opacity: 0; transform: translateY(12px); }
  }
{% endstylesheet %}

{% javascript %}
  (() => {
    if (customElements.get('lib-recently-viewed')) return;
    // One list per store, newest first. Only product handles are kept, never personal data.
    const STORAGE_KEY = 'lib-recently-viewed';
    const MAX_STORED = 24;
    const IMAGE_WIDTHS = [300, 600, 900];

    const readHandles = () => {
      try {
        const value = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
        return Array.isArray(value) ? value.filter((h) => typeof h === 'string') : [];
      } catch (error) {
        // Storage blocked (private mode, policy) or a damaged value: start from nothing.
        return [];
      }
    };
    const writeHandles = (handles) => {
      try {
        if (handles.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(handles.slice(0, MAX_STORED)));
        else localStorage.removeItem(STORAGE_KEY);
      } catch (error) {
        // Storage full or blocked: the list simply isn't kept on this device.
      }
    };
    const sized = (url, width) => `${url}${url.includes('?') ? '&' : '?'}width=${width}`;

    class LibRecentlyViewed extends HTMLElement {
      connectedCallback() {
        this.list = this.querySelector('[data-list]');
        this.status = this.querySelector('[data-status]');
        this.template = this.querySelector('[data-card-template]');
        const meta = this.querySelector('[data-sizes]');
        this.sizes = meta?.dataset.sizes || '50vw';
        this.clearedText = meta?.dataset.cleared || '';
        this.querySelector('[data-clear]')?.addEventListener('click', () => this.clear());

        const current = this.dataset.current;
        const handles = readHandles();
        if (current) writeHandles([current, ...handles.filter((h) => h !== current)]);
        if (this.hasAttribute('data-record-only')) return;

        const wanted = handles.filter((h) => h !== current).slice(0, Number(this.dataset.limit) || 4);
        if (wanted.length) this.load(wanted);
      }

      async load(handles) {
        const root = this.dataset.root || '/';
        const results = await Promise.allSettled(
          handles.map(async (handle) => {
            const response = await fetch(`${root}products/${encodeURIComponent(handle)}.js`, {headers: {Accept: 'application/json'}});
            if (response.status === 404) return {handle, gone: true};
            if (!response.ok) throw new Error(`product ${handle}: ${response.status}`);
            return {handle, product: await response.json()};
          }),
        );
        const gone = results.filter((r) => r.status === 'fulfilled' && r.value.gone).map((r) => r.value.handle);
        if (gone.length) writeHandles(readHandles().filter((h) => !gone.includes(h)));
        results
          .filter((r) => r.status === 'rejected')
          .forEach((r) => console.warn('Recently viewed: a product could not be loaded.', r.reason));

        const products = results.filter((r) => r.status === 'fulfilled' && r.value.product).map((r) => r.value.product);
        if (!products.length) return;
        this.list.querySelectorAll('[data-placeholder]').forEach((el) => el.remove());
        this.list.append(...products.map((product) => this.card(product)));
        this.hidden = false;
      }

      price(cents) {
        try {
          return new Intl.NumberFormat(this.dataset.locale || undefined, {style: 'currency', currency: this.dataset.currency || 'USD'}).format(cents / 100);
        } catch (error) {
          // Unknown locale or currency code: a plain number still reads correctly.
          return (cents / 100).toFixed(2);
        }
      }

      card(product) {
        const item = this.template.content.firstElementChild.cloneNode(true);
        const slot = (name) => item.querySelector(`[data-slot="${name}"]`);
        const root = this.dataset.root || '/';
        const link = slot('link');
        link.href = `${root}products/${product.handle}`;
        link.textContent = product.title;
        const vendor = slot('vendor');
        if (vendor) vendor.textContent = product.vendor || '';

        const image = slot('image');
        const src = product.featured_image;
        if (src) {
          image.src = sized(src, IMAGE_WIDTHS[1]);
          image.srcset = IMAGE_WIDTHS.map((w) => `${sized(src, w)} ${w}w`).join(', ');
          image.sizes = this.sizes;
          image.alt = product.media?.[0]?.alt || product.title;
          image.hidden = false;
        }

        const onSale = product.compare_at_price > product.price;
        slot('price').textContent = this.price(product.price);
        if (onSale) {
          slot('price').classList.add('lib-card__price-now--sale');
          slot('compare').textContent = this.price(product.compare_at_price);
          ['compare', 'sale-label', 'regular-label'].forEach((name) => (slot(name).hidden = false));
        }
        const card = item.querySelector('.lib-card');
        if (!product.available) {
          card.classList.add('is-sold-out');
          if (slot('sold-out').textContent.trim()) slot('sold-out').hidden = false;
        } else if (onSale && slot('sale').textContent.trim()) {
          slot('sale').hidden = false;
        }
        item.toggleAttribute('data-new', true);
        return item;
      }

      clear() {
        writeHandles([]);
        this.list.replaceChildren();
        this.querySelector('[data-clear]')?.remove();
        this.status.textContent = this.clearedText;
        if (!this.clearedText) this.hidden = true;
      }
    }
    customElements.define('lib-recently-viewed', LibRecentlyViewed);
  })();
{% endjavascript %}

{% schema %}
{
  "name": "Lib · Recently viewed",
  "tag": "section",
  "class": "lib-section",
  "settings": [
    { "type": "paragraph", "content": "Add this section to the product template so viewed products are noted, then anywhere else you want the list. Only product handles are kept, in the shopper's browser." },
    { "type": "header", "content": "Content" },
    { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Recently viewed" },
    {
      "type": "select",
      "id": "heading_tag",
      "label": "Heading tag",
      "info": "Use H1 only when this is the page's main heading.",
      "options": [
        { "value": "h1", "label": "H1" },
        { "value": "h2", "label": "H2" },
        { "value": "h3", "label": "H3" },
        { "value": "p", "label": "Paragraph" }
      ],
      "default": "h2"
    },
    {
      "type": "select",
      "id": "heading_size",
      "label": "Heading size",
      "options": [
        { "value": "small", "label": "Small" },
        { "value": "medium", "label": "Medium" },
        { "value": "large", "label": "Large" }
      ],
      "default": "medium"
    },
    { "type": "richtext", "id": "text", "label": "Intro text" },
    { "type": "text", "id": "clear_label", "label": "Clear history button", "info": "Leave empty to hide the button.", "default": "Clear history" },
    { "type": "text", "id": "cleared_label", "label": "Message after clearing", "default": "Your recently viewed list is now empty." },
    { "type": "checkbox", "id": "show_on_product_pages", "label": "Show the list on product pages", "info": "When off, product pages only note the product; the list shows on other pages.", "default": true },

    { "type": "header", "content": "Products" },
    { "type": "range", "id": "products_to_show", "label": "Products to show", "min": 2, "max": 12, "step": 1, "default": 4 },
    {
      "type": "select",
      "id": "image_ratio",
      "label": "Image shape",
      "options": [
        { "value": "square", "label": "Square" },
        { "value": "portrait", "label": "Portrait" },
        { "value": "tall", "label": "Tall" },
        { "value": "landscape", "label": "Landscape" }
      ],
      "default": "portrait"
    },
    {
      "type": "select",
      "id": "card_style",
      "label": "Card style",
      "options": [
        { "value": "plain", "label": "Plain" },
        { "value": "card", "label": "Card" }
      ],
      "default": "plain"
    },
    { "type": "range", "id": "card_radius", "label": "Corner radius", "min": 0, "max": 24, "step": 2, "unit": "px", "default": 12 },
    { "type": "checkbox", "id": "show_vendor", "label": "Show vendor", "default": false },
    { "type": "text", "id": "sale_label", "label": "Sale badge", "default": "Sale" },
    { "type": "text", "id": "sold_out_label", "label": "Sold out badge", "default": "Sold out" },
    { "type": "text", "id": "sale_price_label", "label": "Sale price label (screen readers)", "default": "Sale price" },
    { "type": "text", "id": "regular_price_label", "label": "Regular price label (screen readers)", "default": "Regular price" },

    { "type": "header", "content": "Layout" },
    { "type": "range", "id": "columns", "label": "Columns on desktop", "min": 2, "max": 6, "step": 1, "default": 4 },
    { "type": "range", "id": "columns_mobile", "label": "Columns on mobile", "min": 1, "max": 2, "step": 1, "default": 2 },
    {
      "type": "select",
      "id": "layout_mobile",
      "label": "Layout on mobile",
      "options": [
        { "value": "grid", "label": "Grid" },
        { "value": "row", "label": "Swipeable row" }
      ],
      "default": "row"
    },
    {
      "type": "select",
      "id": "section_width",
      "label": "Width",
      "options": [
        { "value": "page", "label": "Page width" },
        { "value": "full", "label": "Full width" }
      ],
      "default": "page"
    },
    {
      "type": "select",
      "id": "text_alignment",
      "label": "Heading alignment on desktop",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" }
      ],
      "default": "left"
    },
    {
      "type": "select",
      "id": "text_alignment_mobile",
      "label": "Heading alignment on mobile",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" }
      ],
      "default": "left"
    },

    { "type": "header", "content": "Colors" },
    { "type": "color", "id": "color_bg", "label": "Background", "default": "#FFFFFF" },
    { "type": "color_background", "id": "color_bg_gradient", "label": "Background gradient", "info": "Optional. Covers the background color." },
    { "type": "color", "id": "color_text", "label": "Text", "default": "#1F1B17" },
    { "type": "color", "id": "color_accent", "label": "Accent (badges, focus)", "default": "#7A5A3A" },
    { "type": "color", "id": "color_accent_text", "label": "Text on accent", "default": "#FFFFFF" },
    { "type": "color", "id": "color_card", "label": "Card background", "default": "#FAF6F1" },

    { "type": "header", "content": "Spacing and visibility" },
    { "type": "range", "id": "padding_top", "label": "Top padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 56 },
    { "type": "range", "id": "padding_bottom", "label": "Bottom padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 56 },
    { "type": "range", "id": "padding_top_mobile", "label": "Top padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 40 },
    { "type": "range", "id": "padding_bottom_mobile", "label": "Bottom padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 40 },
    {
      "type": "select",
      "id": "show_on",
      "label": "Show on",
      "options": [
        { "value": "all", "label": "All devices" },
        { "value": "desktop", "label": "Desktop only" },
        { "value": "mobile", "label": "Mobile only" }
      ],
      "default": "all"
    },
    { "type": "text", "id": "anchor_id", "label": "Anchor ID", "info": "Optional. Link to this section with #your-id." },

    { "type": "header", "content": "Motion" },
    { "type": "checkbox", "id": "animate", "label": "Fade cards in as they load", "info": "Never when reduced motion is on.", "default": true }
  ],
  "presets": [{ "name": "Lib · Recently viewed" }]
}
{% endschema %}
```

### snippets/lib-product-card.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{% doc %}
  Product card shared by the library's product sections: image (second image on hover), optional
  vendor, price with compare-at price, sale and sold-out badges, and an optional quick add. Quick add
  is a plain Shopify product form that posts the only variant to /cart/add; products with options
  link to the product page. Colors come from the parent section's --lib-* custom properties.
  With `template: true` it prints empty slots (data-slot) for a script to fill from product JSON,
  so cards built in the browser look exactly like cards built in Liquid.
  @param {product} [product] - The product; blank prints a placeholder card (theme editor only)
  @param {boolean} [template] - Print empty slots for a script instead of a product
  @param {string} [title_tag] - Tag for the product title (default 'h3')
  @param {string} [ratio] - Image shape: 'square' (default), 'portrait', 'tall' or 'landscape'
  @param {string} [sizes] - sizes attribute for the image
  @param {boolean} [eager] - Load the image immediately
  @param {boolean} [show_vendor] - Show the vendor above the title
  @param {boolean} [show_second_image] - Show the second image on hover
  @param {boolean} [show_quick_add] - Show the quick add button or the choose options link
  @param {string} [quick_add_label] - Quick add button text
  @param {string} [choose_label] - Link text for products with options
  @param {string} [sold_out_label] - Badge and button text for sold-out products
  @param {string} [sale_label] - Badge text for products on sale
  @param {string} [sale_price_label] - Screen-reader label before the sale price
  @param {string} [regular_price_label] - Screen-reader label before the compare-at price
  @param {string} [id_prefix] - Unique prefix for the title id (the quick add button points at it)
  @example
  {% render 'lib-product-card', product: product, ratio: 'portrait', show_quick_add: true, quick_add_label: 'Add to cart', id_prefix: section.id %}
{% enddoc %}
{%- liquid
  assign tag = title_tag | default: 'h3'
  assign shape = ratio | default: 'square'
  assign title_id = id_prefix | append: '-' | append: product.id
  assign on_sale = false
  if product.compare_at_price > product.price
    assign on_sale = true
  endif
  assign has_img2 = false
  if show_second_image and product.media.size > 1 and product.media[1].preview_image != blank
    assign has_img2 = true
  endif
-%}

{%- if template -%}
  <article class="lib-card">
    <div class="lib-card__media" data-ratio="{{ shape }}">
      <img class="lib-card__img" data-slot="image" alt="" loading="lazy" width="600" height="600" hidden>
      <span class="lib-card__badge lib-card__badge--sold-out" data-slot="sold-out" hidden>{{ sold_out_label | escape }}</span>
      <span class="lib-card__badge" data-slot="sale" hidden>{{ sale_label | escape }}</span>
    </div>
    <div class="lib-card__body">
      {%- if show_vendor -%}
        <p class="lib-card__vendor" data-slot="vendor"></p>
      {%- endif -%}
      <{{ tag }} class="lib-card__title"><a class="lib-card__link" data-slot="link" href=""></a></{{ tag }}>
      <p class="lib-card__price">
        <span class="lib-card__sr" data-slot="sale-label" hidden>{{ sale_price_label | escape }}</span>
        <span class="lib-card__price-now" data-slot="price"></span>
        <span class="lib-card__sr" data-slot="regular-label" hidden>{{ regular_price_label | escape }}</span>
        <s class="lib-card__price-was" data-slot="compare" hidden></s>
      </p>
    </div>
  </article>
{%- elsif product != blank -%}
  <article class="lib-card{% if product.available == false %} is-sold-out{% endif %}">
    <div class="lib-card__media" data-ratio="{{ shape }}">
      {%- render 'lib-image', image: product.featured_image, class: 'lib-card__img', sizes: sizes, eager: eager, placeholder: 'product-1' -%}
      {%- if has_img2 -%}
        <div class="lib-card__img2" aria-hidden="true">
          {%- render 'lib-image', image: product.media[1].preview_image, class: 'lib-card__img', sizes: sizes -%}
        </div>
      {%- endif -%}
      {%- if product.available == false and sold_out_label != blank -%}
        <span class="lib-card__badge lib-card__badge--sold-out">{{ sold_out_label | escape }}</span>
      {%- elsif on_sale and sale_label != blank -%}
        <span class="lib-card__badge">{{ sale_label | escape }}</span>
      {%- endif -%}
    </div>

    <div class="lib-card__body">
      {%- if show_vendor and product.vendor != blank -%}
        <p class="lib-card__vendor">{{ product.vendor | escape }}</p>
      {%- endif -%}
      <{{ tag }} class="lib-card__title" id="{{ title_id }}">
        <a class="lib-card__link" href="{{ product.url }}">{{ product.title | escape }}</a>
      </{{ tag }}>
      <p class="lib-card__price">
        {%- if on_sale -%}
          <span class="lib-card__sr">{{ sale_price_label | escape }}</span>
          <span class="lib-card__price-now lib-card__price-now--sale">{{ product.price | money }}</span>
          <span class="lib-card__sr">{{ regular_price_label | escape }}</span>
          <s class="lib-card__price-was">{{ product.compare_at_price | money }}</s>
        {%- else -%}
          <span class="lib-card__price-now">{{ product.price | money }}</span>
        {%- endif -%}
      </p>

      {%- if show_quick_add -%}
        <div class="lib-card__cta">
          {%- if product.has_only_default_variant -%}
            {%- form 'product', product, class: 'lib-card__form' -%}
              <input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
              <input type="hidden" name="quantity" value="1">
              <button
                type="submit"
                name="add"
                class="lib-card__add"
                aria-describedby="{{ title_id }}"
                {% if product.available == false %}
                  disabled
                {% endif %}
              >
                {%- if product.available -%}
                  {{- quick_add_label | escape -}}
                {%- else -%}
                  {{- sold_out_label | default: quick_add_label | escape -}}
                {%- endif -%}
              </button>
            {%- endform -%}
          {%- else -%}
            <a class="lib-card__add" href="{{ product.url }}" aria-describedby="{{ title_id }}">
              {{- choose_label | escape -}}
            </a>
          {%- endif -%}
        </div>
      {%- endif -%}
    </div>
  </article>
{%- else -%}
  <article class="lib-card">
    <div class="lib-card__media" data-ratio="{{ shape }}">
      {{ 'product-1' | placeholder_svg_tag: 'lib-card__img' }}
    </div>
    <div class="lib-card__body">
      <{{ tag }} class="lib-card__title">Example product title</{{ tag }}>
      <p class="lib-card__price"><span class="lib-card__price-now">{{ 4800 | money }}</span></p>
    </div>
  </article>
{%- endif -%}

{% stylesheet %}
  .lib-card {
    position: relative;
    display: flex;
    flex: 1;
    flex-direction: column;
    min-width: 0;
    border-radius: var(--lib-radius-card, 12px);
    color: var(--lib-text, #1f1b17);
  }
  [data-card='card'] .lib-card { background: var(--lib-card, #faf6f1); }
  .lib-card__media {
    position: relative;
    overflow: hidden;
    aspect-ratio: 1;
    border-radius: var(--lib-radius-card, 12px);
    background: color-mix(in srgb, var(--lib-text, #1f1b17) 6%, transparent);
  }
  .lib-card__media[data-ratio='portrait'] { aspect-ratio: 4 / 5; }
  .lib-card__media[data-ratio='tall'] { aspect-ratio: 2 / 3; }
  .lib-card__media[data-ratio='landscape'] { aspect-ratio: 4 / 3; }
  [data-card='card'] .lib-card__media { border-end-start-radius: 0; border-end-end-radius: 0; }
  .lib-card__img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
  }
  svg.lib-card__img { fill: color-mix(in srgb, var(--lib-text, #1f1b17) 22%, transparent); }
  .lib-card__img2 { position: absolute; inset: 0; opacity: 0; transition: opacity 0.4s ease; }
  @media (hover: hover) {
    .lib-card:hover .lib-card__img2 { opacity: 1; }
    .lib-card:hover .lib-card__img { transform: scale(1.03); }
  }
  .lib-card:focus-within .lib-card__img2 { opacity: 1; }
  .lib-card.is-sold-out .lib-card__media img { opacity: 0.6; }

  .lib-card__badge {
    position: absolute;
    z-index: 2;
    top: 0.75rem;
    left: 0.75rem;
    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    background: var(--lib-accent, #1f1b17);
    color: var(--lib-accent-text, #fff);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.03em;
    line-height: 1.2;
    pointer-events: none;
  }
  .lib-card__badge[hidden] { display: none; }
  .lib-card__badge--sold-out {
    background: var(--lib-bg, #fff);
    color: var(--lib-text, #1f1b17);
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lib-text, #1f1b17) 20%, transparent);
  }

  .lib-card__body { display: flex; flex: 1; flex-direction: column; gap: 0.3rem; padding-top: 0.9rem; }
  [data-card='card'] .lib-card__body { padding: 0.9rem 1rem 1rem; }
  .lib-card__vendor { margin: 0; font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.7; }
  .lib-card__vendor:empty { display: none; }
  .lib-card__title { margin: 0; font-size: 1rem; font-weight: 600; line-height: 1.3; }
  .lib-card__link { color: inherit; text-decoration: none; }
  /* The title link covers the whole card; the quick add button sits above it. */
  .lib-card__link::after { content: ''; position: absolute; inset: 0; z-index: 1; border-radius: var(--lib-radius-card, 12px); }
  .lib-card__link:hover { text-decoration: underline; text-underline-offset: 0.2em; }
  .lib-card__link:focus-visible { outline: none; }
  .lib-card__link:focus-visible::after { outline: 2px solid var(--lib-accent, #1f1b17); outline-offset: 3px; }
  .lib-card__price { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; margin: 0; font-size: 0.9375rem; }
  .lib-card__price-now--sale { font-weight: 600; }
  .lib-card__price-was { opacity: 0.7; font-size: 0.875rem; }
  .lib-card__price-was[hidden] { display: none; }
  .lib-card__sr {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .lib-card__cta { position: relative; z-index: 2; margin-top: auto; padding-top: 0.75rem; }
  .lib-card__form { margin: 0; }
  .lib-card__add {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 2.75rem;
    padding: 0.6rem 1rem;
    border: 1.5px solid var(--lib-text, #1f1b17);
    border-radius: min(var(--lib-radius-card, 12px), 999px);
    background: transparent;
    color: var(--lib-text, #1f1b17);
    font: inherit;
    font-size: 0.875rem;
    font-weight: 600;
    line-height: 1.2;
    text-align: center;
    text-decoration: none;
    cursor: pointer;
    transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
  }
  .lib-card__add:hover:not(:disabled) {
    background: var(--lib-accent, #1f1b17);
    border-color: var(--lib-accent, #1f1b17);
    color: var(--lib-accent-text, #fff);
  }
  .lib-card__add:focus-visible { outline: 2px solid var(--lib-accent, #1f1b17); outline-offset: 2px; }
  .lib-card__add:disabled { opacity: 0.45; cursor: default; }

  @media (prefers-reduced-motion: reduce) {
    .lib-card__img,
    .lib-card__img2,
    .lib-card__add { transition: none; }
    .lib-card:hover .lib-card__img { transform: none; }
  }
{% endstylesheet %}
```

### snippets/lib-image.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{% doc %}
  Responsive image with optional art direction: a separate mobile image (below 750px),
  each honouring its own focal point from the theme editor. Falls back to a placeholder.

  @param {image} [image] - Desktop image (also used on mobile when no mobile image is set)
  @param {image} [image_mobile] - Mobile image, below 750px
  @param {string} [class] - Class for the img element
  @param {string} [sizes] - sizes attribute for the desktop image (default 100vw)
  @param {boolean} [eager] - Load immediately with high fetch priority (use for the first sections on a page)
  @param {string} [placeholder] - placeholder_svg_tag name when no image is set (default 'lifestyle-1')

  @example
  {% render 'lib-image', image: section.settings.image, image_mobile: section.settings.image_mobile, class: 'lib-hero__img', sizes: '(min-width: 990px) 50vw, 100vw', eager: true %}
{% enddoc %}

{%- liquid
  assign desktop = image
  if desktop == blank
    assign desktop = image_mobile
  endif
  assign img_sizes = sizes | default: '100vw'
  assign img_loading = 'lazy'
  assign img_priority = 'auto'
  if eager
    assign img_loading = 'eager'
    assign img_priority = 'high'
  endif
  assign art = false
  if image != blank and image_mobile != blank
    assign art = true
  endif
-%}

{%- if desktop != blank -%}
  <picture
    class="lib-picture{% if art %} lib-picture--art{% endif %}"
    {% if art %}
      style="--lib-pos-m: {{ image_mobile.presentation.focal_point | default: '50% 50%' }};"
    {% endif %}
  >
    {%- if art -%}
      <source
        media="(max-width: 749px)"
        srcset="
          {{ image_mobile | image_url: width: 375 }} 375w,
          {{ image_mobile | image_url: width: 750 }} 750w,
          {{ image_mobile | image_url: width: 1100 }} 1100w,
          {{ image_mobile | image_url: width: 1500 }} 1500w
        "
        sizes="100vw"
        width="{{ image_mobile.width }}"
        height="{{ image_mobile.height }}"
      >
    {%- endif -%}
    {{
      desktop
      | image_url: width: 2400
      | image_tag:
        class: class,
        loading: img_loading,
        fetchpriority: img_priority,
        sizes: img_sizes,
        widths: '600, 900, 1200, 1600, 2000, 2400'
    }}
  </picture>
{%- else -%}
  {{ placeholder | default: 'lifestyle-1' | placeholder_svg_tag: class }}
{%- endif -%}

{% stylesheet %}
  .lib-picture { display: contents; }
  @media (max-width: 749px) {
    /* image_tag sets the desktop focal point inline; the mobile image needs its own. */
    .lib-picture--art img { object-position: var(--lib-pos-m) !important; }
  }
{% endstylesheet %}
```

## License

MIT License, © 2026 Lintel. Free to use, change and ship in your own store or in client work; keep the license line at the top of each file.
