# Store locator: free Shopify section

> Store cards with address, opening hours, phone and a directions link to Google Maps, Apple Maps or OpenStreetMap, and a city filter. No map API key.

Store locator is a free Shopify section that lists your shops, stockists or showrooms as cards with a photo, address, opening hours, phone, email, a short note and a "Get directions" link. The link opens the address in Google Maps, Apple Maps or OpenStreetMap, or uses a map link you paste, so there is no maps API, key or embedded map to pay for or load. With stores in more than one city, a city filter appears and announces how many stores it shows. It works in Online Store 2.0 themes without an app.

- **Category:** Navigation and utility
- **Use on:** Contact, Stockists, About
- **Reads:** Content only
- **Size:** 24 KB in 2 files, with a small script
- **License:** MIT, © 2026 Lintel
- **Page:** https://lintelagency.com/resources/shopify-sections/store-locator
- **Live demo:** https://lintelagency.com/resources/shopify-sections/demo/lib-store-locator.html
- **Added:** 2026-11-02

## What it’s for

- A Visit us or Stores page for a brand with a few shops
- A stockists page listing the shops that carry your products, by city
- Showroom or pickup locations with opening hours on a contact page

## Editor settings

In the theme editor the section is called “Lib · Store locator”. 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 | Visit us |  |
| `heading_tag` | `select` | Heading tag | h2 | `h1`, `h2`, `h3`, `p` |
| `heading_size` | `select` | Heading size | medium | `small`, `medium`, `large` |
| `text` | `richtext` | Intro text | – |  |

### City filter

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `show_filter` | `checkbox` | Show a city filter | `true` |  |
| `filter_label` | `text` | Filter label | City |  |
| `all_label` | `text` | All cities option | All cities |  |
| `count_label` | `text` | Result message | {count} of {total} stores |  |
| `show_city` | `checkbox` | Show the city on each card | `true` |  |

### Map links

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `map_provider` | `select` | Open addresses in | google | `google`, `apple`, `osm` |
| `map_label` | `text` | Map link text | Get directions |  |
| `map_new_tab` | `checkbox` | Open map links in a new tab | `true` |  |
| `new_tab_label` | `text` | New tab notice (screen readers) | opens in a new tab |  |

### Layout

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `columns` | `range` | Columns on desktop | `3` | 1–4, step 1 |
| `card_style` | `select` | Card style | card | `plain`, `card` |
| `image_ratio` | `select` | Image shape | standard | `standard`, `landscape`, `square` |
| `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 (map links, cities) | #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 | `64` | 0–200 px, step 4 |
| `padding_bottom` | `range` | Bottom padding on desktop | `64` | 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 on scroll | `false` |  |

## Blocks

Up to 30 blocks in total.

### Store (`store`)

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `name` | `text` | Store name | Store name |  |
| `city` | `text` | City | City |  |
| `address` | `textarea` | Address | – |  |
| `hours` | `richtext` | Opening hours | `<p>Mon–Sat 10:00–18:00</p>` |  |
| `phone` | `text` | Phone | – |  |
| `email` | `text` | Email | – |  |
| `note` | `text` | Note | – |  |
| `map_url` | `url` | Map link | – |  |
| `image` | `image_picker` | Image | – |  |
| `image_mobile` | `image_picker` | Mobile image | – |  |

## 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-store-locator.liquid` and paste the contents of `sections/lib-store-locator.liquid` from the source below. Save.
4. In the `snippets` folder, add `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 · Store locator**. 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-store-locator.liquid https://lintelagency.com/resources/shopify-sections/files/lib-store-locator.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-store-locator.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-store-locator.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-store-locator.liquid): goes in `sections/`
- [snippets/lib-image.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-image.liquid): goes in `snippets/`

## Questions

### Does it need a Google Maps API key?

No. There is no embedded map. Each store's "Get directions" link opens a search for its name and address in Google Maps, Apple Maps or OpenStreetMap, or the share link you paste into the block, in the shopper's own maps app.

### How does the city filter work?

The cities come from the City field of your store blocks. With two or more different cities, a labelled drop-down appears; choosing one hides the other stores and a status line such as "2 of 3 stores" is read out. Without JavaScript every store shows.

### How many stores can I add?

Up to 30 blocks. For hundreds of stockists, a dedicated locator app with search by distance is the better tool.

### Can shoppers call or email a store from the card?

Yes. The phone number becomes a tel: link and the email a mailto: link, both shown only when you fill them in.

## Source

### sections/lib-store-locator.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{%- comment -%}
  Store locator: one card per shop, stockist or showroom with photo, address, opening hours, phone,
  email and a "get directions" link that opens the address in Google Maps, Apple Maps or
  OpenStreetMap. No maps API, no key, no embedded map: just links. With more than one city a small
  script adds a city filter; without it every store shows. All text is typed by the merchant.
  Depends on: snippets/lib-image.liquid
{%- endcomment -%}

{%- liquid
  assign s = section.settings
  assign heading_tag = s.heading_tag | default: 'h2'
  assign item_tag = 'h3'
  if heading_tag == 'h3' or heading_tag == 'p'
    assign item_tag = 'h4'
  endif
  assign cities = section.blocks | map: 'settings' | map: 'city' | compact | uniq
  assign show_filter = false
  if s.show_filter and cities.size > 1
    assign show_filter = true
  endif
  assign cols = s.columns | plus: 0
  assign img_vw = 100 | divided_by: cols
  assign img_sizes = '(min-width: 990px) ' | append: img_vw | append: 'vw, 100vw'
  assign filter_id = 'LibStoreFilter-' | append: section.id
-%}

<lib-store-locator
  class="lib-stores lib-stores--{{ s.section_width }}"
  {% if s.anchor_id != blank %}
    id="{{ s.anchor_id | handleize }}"
  {% endif %}
  data-show="{{ s.show_on }}"
  data-style="{{ s.card_style }}"
  {% if s.animate %}
    data-animate
  {% endif %}
  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-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-stores__inner">
    <header class="lib-stores__header" data-align="{{ s.text_alignment }}" data-align-m="{{ s.text_alignment_mobile }}">
      <div class="lib-stores__copy">
        {%- if s.heading != blank -%}
          <{{ heading_tag }} class="lib-stores__heading lib-stores__heading--{{ s.heading_size }}">
            {{- s.heading -}}
          </{{ heading_tag }}>
        {%- endif -%}
        {%- if s.text != blank -%}
          <div class="lib-stores__text">{{ s.text }}</div>
        {%- endif -%}
      </div>
      {%- if show_filter -%}
        <div class="lib-stores__filter" hidden data-filter>
          <label class="lib-stores__filter-label" for="{{ filter_id }}">{{ s.filter_label | escape }}</label>
          <select class="lib-stores__select" id="{{ filter_id }}">
            <option value="">{{ s.all_label | escape }}</option>
            {%- for city in cities -%}
              <option value="{{ city | handleize }}">{{ city | escape }}</option>
            {%- endfor -%}
          </select>
        </div>
      {%- endif -%}
    </header>
    <p class="lib-stores__count" role="status" data-count data-template="{{ s.count_label | escape }}"></p>

    {%- if section.blocks.size > 0 -%}
      <ul class="lib-stores__list" role="list">
        {%- for block in section.blocks -%}
          {%- liquid
            assign b = block.settings
            assign query = b.name | append: ', ' | append: b.address | newline_to_br | replace: '<br />', ', ' | strip_newlines | url_encode
            assign map_url = b.map_url
            if map_url == blank and b.address != blank
              case s.map_provider
                when 'apple'
                  assign map_url = 'https://maps.apple.com/?q=' | append: query
                when 'osm'
                  assign map_url = 'https://www.openstreetmap.org/search?query=' | append: query
                else
                  assign map_url = 'https://www.google.com/maps/search/?api=1&query=' | append: query
              endcase
            endif
            assign phone_digits = b.phone | remove: ' ' | remove: '(' | remove: ')' | remove: '-' | remove: '.'
          -%}
          <li class="lib-stores__item" data-city="{{ b.city | handleize }}" {{ block.shopify_attributes }}>
            <article class="lib-stores__card">
              {%- if b.image != blank or b.image_mobile != blank -%}
                <div class="lib-stores__media" data-ratio="{{ s.image_ratio }}">
                  {%- render 'lib-image', image: b.image, image_mobile: b.image_mobile, class: 'lib-stores__img', sizes: img_sizes -%}
                </div>
              {%- endif -%}
              <div class="lib-stores__body">
                {%- if b.city != blank and s.show_city -%}
                  <p class="lib-stores__city">{{ b.city | escape }}</p>
                {%- endif -%}
                <{{ item_tag }} class="lib-stores__name">{{ b.name | escape }}</{{ item_tag }}>
                {%- if b.address != blank -%}
                  <address class="lib-stores__address">{{ b.address | escape | newline_to_br }}</address>
                {%- endif -%}
                {%- if b.hours != blank -%}
                  <div class="lib-stores__hours">{{ b.hours }}</div>
                {%- endif -%}
                {%- if b.phone != blank or b.email != blank -%}
                  <ul class="lib-stores__contact" role="list">
                    {%- if b.phone != blank -%}
                      <li><a href="tel:{{ phone_digits }}">{{ b.phone | escape }}</a></li>
                    {%- endif -%}
                    {%- if b.email != blank -%}
                      <li><a href="mailto:{{ b.email | strip }}">{{ b.email | escape }}</a></li>
                    {%- endif -%}
                  </ul>
                {%- endif -%}
                {%- if b.note != blank -%}
                  <p class="lib-stores__note">{{ b.note | escape }}</p>
                {%- endif -%}
                {%- if map_url != blank and s.map_label != blank -%}
                  <a
                    class="lib-stores__map"
                    href="{{ map_url }}"
                    {% if s.map_new_tab %}
                      target="_blank" rel="noopener"
                    {% endif %}
                  >
                    <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z"/><circle cx="12" cy="10" r="2.4"/></svg>
                    <span>{{ s.map_label | escape }}</span>
                    <span class="lib-stores__sr">{{ b.name | escape }}{% if s.map_new_tab and s.new_tab_label != blank %} ({{ s.new_tab_label | escape }}){% endif %}</span>
                  </a>
                {%- endif -%}
              </div>
            </article>
          </li>
        {%- endfor -%}
      </ul>
    {%- endif -%}
  </div>
</lib-store-locator>

{% stylesheet %}
  .lib-stores {
    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-stores__inner { max-width: 1200px; margin: 0 auto; }
  .lib-stores--full .lib-stores__inner { max-width: none; }
  @media (max-width: 749px) {
    .lib-stores { padding-top: var(--lib-pt-m); padding-bottom: var(--lib-pb-m); }
    .lib-stores[data-show='desktop'] { display: none; }
  }
  @media (min-width: 750px) {
    .lib-stores[data-show='mobile'] { display: none; }
  }

  .lib-stores__header {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 1rem 2rem;
    margin-bottom: clamp(1.25rem, 3vw, 2rem);
  }
  .lib-stores__copy { min-width: 0; max-width: 40rem; }
  .lib-stores__heading {
    margin: 0;
    font-size: clamp(1.75rem, 3vw, 2.5rem);
    line-height: 1.15;
    letter-spacing: -0.02em;
    text-wrap: balance;
  }
  .lib-stores__heading--small { font-size: clamp(1.25rem, 1.8vw, 1.5rem); }
  .lib-stores__heading--large { font-size: clamp(2rem, 4vw, 3.25rem); }
  .lib-stores__text { margin-top: 0.6rem; font-size: 1rem; line-height: 1.6; opacity: 0.85; }
  .lib-stores__text > :first-child { margin-top: 0; }
  .lib-stores__text > :last-child { margin-bottom: 0; }
  .lib-stores__header[data-align='center'] { flex-direction: column; align-items: center; text-align: center; }
  @media (max-width: 749px) {
    .lib-stores__header[data-align-m='left'] { flex-direction: column; align-items: flex-start; text-align: left; }
    .lib-stores__header[data-align-m='center'] { flex-direction: column; align-items: center; text-align: center; }
  }

  .lib-stores__filter { display: flex; flex-direction: column; gap: 0.35rem; min-width: min(16rem, 100%); }
  .lib-stores__filter[hidden] { display: none; }
  .lib-stores__filter-label { font-size: 0.875rem; font-weight: 600; text-align: left; }
  .lib-stores__select {
    min-height: 2.75rem;
    padding: 0.5rem 2.25rem 0.5rem 0.875rem;
    border: 1.5px solid color-mix(in srgb, var(--lib-text) 35%, transparent);
    border-radius: 10px;
    background: var(--lib-card)
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' fill='none' stroke='%23777' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E")
      no-repeat right 0.75rem center / 1rem;
    color: var(--lib-text);
    font: inherit;
    font-size: 0.9375rem;
    appearance: none;
  }
  .lib-stores__select:focus-visible { outline: 2px solid var(--lib-accent); outline-offset: 2px; }
  .lib-stores__count { margin: 0 0 1rem; font-size: 0.875rem; opacity: 0.8; }
  .lib-stores__count:empty { display: none; }

  .lib-stores__list {
    display: grid;
    grid-template-columns: repeat(var(--lib-cols), minmax(0, 1fr));
    gap: clamp(1rem, 2.5vw, 1.75rem);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  @media (max-width: 989px) {
    .lib-stores__list { grid-template-columns: repeat(min(var(--lib-cols), 2), minmax(0, 1fr)); }
  }
  @media (max-width: 749px) {
    .lib-stores__list { grid-template-columns: 1fr; }
  }
  .lib-stores__item { display: flex; min-width: 0; }
  .lib-stores__item[hidden] { display: none; }
  .lib-stores__card { display: flex; flex: 1; flex-direction: column; min-width: 0; border-radius: 14px; overflow: hidden; }
  .lib-stores[data-style='card'] .lib-stores__card {
    background: var(--lib-card);
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lib-text) 10%, transparent);
  }
  .lib-stores__media { aspect-ratio: 4 / 3; overflow: hidden; background: color-mix(in srgb, var(--lib-text) 6%, transparent); }
  .lib-stores[data-style='plain'] .lib-stores__media { border-radius: 14px; }
  .lib-stores__media[data-ratio='landscape'] { aspect-ratio: 16 / 9; }
  .lib-stores__media[data-ratio='square'] { aspect-ratio: 1; }
  .lib-stores__img { display: block; width: 100%; height: 100%; object-fit: cover; }
  .lib-stores__body { display: flex; flex: 1; flex-direction: column; gap: 0.5rem; padding: 1rem 0 0; }
  .lib-stores[data-style='card'] .lib-stores__body { padding: 1.125rem 1.25rem 1.25rem; }
  .lib-stores__city { margin: 0; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lib-accent); }
  .lib-stores__name { margin: 0; font-size: 1.1875rem; line-height: 1.3; }
  .lib-stores__address { font-style: normal; font-size: 0.9375rem; line-height: 1.55; }
  .lib-stores__hours { font-size: 0.875rem; line-height: 1.55; opacity: 0.85; }
  .lib-stores__hours > * { margin: 0; }
  .lib-stores__contact { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0; padding: 0; list-style: none; font-size: 0.9375rem; }
  .lib-stores__contact a { display: inline-block; padding-block: 0.2rem; color: inherit; text-underline-offset: 0.2em; }
  .lib-stores__note { margin: 0; font-size: 0.875rem; opacity: 0.8; }
  .lib-stores__map {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    align-self: flex-start;
    min-height: 2.75rem;
    margin-top: auto;
    padding: 0.5rem 1rem;
    border-radius: 999px;
    background: var(--lib-accent);
    color: var(--lib-accent-text);
    font-size: 0.875rem;
    font-weight: 600;
    text-decoration: none;
  }
  .lib-stores__map:hover { filter: brightness(1.08); }
  .lib-stores__map:focus-visible,
  .lib-stores__contact a:focus-visible { outline: 2px solid var(--lib-accent); outline-offset: 2px; }
  .lib-stores__sr {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      .lib-stores[data-animate] .lib-stores__card {
        animation: lib-stores-rise linear both;
        animation-timeline: view();
        animation-range: entry 0% entry 50%;
      }
    }
  }
  @keyframes lib-stores-rise {
    from { opacity: 0; transform: translateY(18px); }
  }
{% endstylesheet %}

{% javascript %}
  (() => {
    if (customElements.get('lib-store-locator')) return;

    class LibStoreLocator extends HTMLElement {
      connectedCallback() {
        const filter = this.querySelector('[data-filter]');
        if (!filter) return;
        this.items = [...this.querySelectorAll('[data-city]')];
        this.count = this.querySelector('[data-count]');
        filter.hidden = false;
        filter.querySelector('select').addEventListener('change', (event) => this.apply(event.target.value));
      }

      apply(city) {
        let shown = 0;
        this.items.forEach((item) => {
          item.hidden = Boolean(city) && item.dataset.city !== city;
          if (!item.hidden) shown += 1;
        });
        const template = this.count.dataset.template || '';
        this.count.textContent = city ? template.replace('{count}', shown).replace('{total}', this.items.length) : '';
      }
    }
    customElements.define('lib-store-locator', LibStoreLocator);
  })();
{% endjavascript %}

{% schema %}
{
  "name": "Lib · Store locator",
  "tag": "section",
  "class": "lib-section",
  "max_blocks": 30,
  "settings": [
    { "type": "header", "content": "Content" },
    { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Visit us" },
    {
      "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": "header", "content": "City filter" },
    { "type": "checkbox", "id": "show_filter", "label": "Show a city filter", "info": "Only when the stores are in more than one city.", "default": true },
    { "type": "text", "id": "filter_label", "label": "Filter label", "default": "City" },
    { "type": "text", "id": "all_label", "label": "All cities option", "default": "All cities" },
    { "type": "text", "id": "count_label", "label": "Result message", "info": "{count} stores shown, {total} in all. Read out by screen readers too.", "default": "{count} of {total} stores" },
    { "type": "checkbox", "id": "show_city", "label": "Show the city on each card", "default": true },

    { "type": "header", "content": "Map links" },
    {
      "type": "select",
      "id": "map_provider",
      "label": "Open addresses in",
      "info": "A link built from the store's name and address; a block's own map link wins.",
      "options": [
        { "value": "google", "label": "Google Maps" },
        { "value": "apple", "label": "Apple Maps" },
        { "value": "osm", "label": "OpenStreetMap" }
      ],
      "default": "google"
    },
    { "type": "text", "id": "map_label", "label": "Map link text", "info": "Leave empty to hide map links.", "default": "Get directions" },
    { "type": "checkbox", "id": "map_new_tab", "label": "Open map links in a new tab", "default": true },
    { "type": "text", "id": "new_tab_label", "label": "New tab notice (screen readers)", "default": "opens in a new tab" },

    { "type": "header", "content": "Layout" },
    { "type": "range", "id": "columns", "label": "Columns on desktop", "info": "Mobile shows one column.", "min": 1, "max": 4, "step": 1, "default": 3 },
    {
      "type": "select",
      "id": "card_style",
      "label": "Card style",
      "options": [
        { "value": "plain", "label": "Plain" },
        { "value": "card", "label": "Card" }
      ],
      "default": "card"
    },
    {
      "type": "select",
      "id": "image_ratio",
      "label": "Image shape",
      "options": [
        { "value": "standard", "label": "4:3" },
        { "value": "landscape", "label": "16:9" },
        { "value": "square", "label": "Square" }
      ],
      "default": "standard"
    },
    {
      "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 (map links, cities)", "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": 64 },
    { "type": "range", "id": "padding_bottom", "label": "Bottom padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 64 },
    { "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 on scroll", "info": "Only in browsers that support scroll-driven animations; never when reduced motion is on.", "default": false }
  ],
  "blocks": [
    {
      "type": "store",
      "name": "Store",
      "settings": [
        { "type": "text", "id": "name", "label": "Store name", "default": "Store name" },
        { "type": "text", "id": "city", "label": "City", "info": "Used by the city filter.", "default": "City" },
        { "type": "textarea", "id": "address", "label": "Address", "info": "One line per address line." },
        { "type": "richtext", "id": "hours", "label": "Opening hours", "default": "<p>Mon–Sat 10:00–18:00</p>" },
        { "type": "text", "id": "phone", "label": "Phone" },
        { "type": "text", "id": "email", "label": "Email" },
        { "type": "text", "id": "note", "label": "Note", "info": "Optional, for example parking or step-free access." },
        { "type": "url", "id": "map_url", "label": "Map link", "info": "Optional. Paste a share link from your maps app to use it instead." },
        { "type": "image_picker", "id": "image", "label": "Image" },
        { "type": "image_picker", "id": "image_mobile", "label": "Mobile image", "info": "Optional. Below 750px." }
      ]
    }
  ],
  "presets": [
    {
      "name": "Lib · Store locator",
      "blocks": [{ "type": "store" }, { "type": "store" }]
    }
  ]
}
{% endschema %}
```

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