# Shoppable image carousel: free Shopify section

> Swipeable row of lifestyle photos, each with up to three product hotspots that open a product card, plus plain product links under every photo.

Shoppable image carousel is a free Shopify section that shows several lifestyle photos in a swipeable row, each with up to three product hotspots. A hotspot opens a small card with the product's image, price and link, and the products in each photo are also listed as links under it, so they work without JavaScript. The row scrolls with touch, trackpad, keyboard or the previous and next buttons and never moves on its own. It works in Online Store 2.0 themes without an app.

- **Category:** Image and video
- **Use on:** Home, Collection, Landing pages, Lookbook
- **Reads:** Products you tag on each photo
- **Size:** 38 KB in 2 files, with a small script
- **License:** MIT, © 2026 Lintel
- **Page:** https://lintelagency.com/resources/shopify-sections/shoppable-image-carousel
- **Live demo:** https://lintelagency.com/resources/shopify-sections/demo/lib-shoppable-carousel.html
- **Added:** 2026-11-02

## What it’s for

- A room-by-room lookbook on the home page where every piece can be bought
- Seasonal campaign photos on a collection or landing page, tagged with the products in them
- Styled outfit or table-setting shots that lead straight to the product pages

## Editor settings

In the theme editor the section is called “Lib · Shoppable carousel”. 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 | `Shop the <em>rooms</em>` |  |
| `heading_tag` | `select` | Heading tag | h2 | `h1`, `h2`, `h3`, `p` |
| `heading_size` | `select` | Heading size | medium | `small`, `medium`, `large` |
| `text` | `richtext` | Text | `<p>Tap a dot to see what is in each photo.</p>` |  |

### Words

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `card_link_label` | `text` | Product card link | View product |  |
| `show_label` | `text` | Screen reader: before a hotspot's product | Show |  |
| `close_label` | `text` | Screen reader: close card | Close |  |
| `prev_label` | `text` | Screen reader: previous | Previous photo |  |
| `next_label` | `text` | Screen reader: next | Next photo |  |
| `carousel_label` | `text` | Screen reader: name of the row | Shoppable photos |  |
| `sale_label` | `text` | Screen reader: sale price | Sale price |  |
| `regular_label` | `text` | Screen reader: regular price | Regular price |  |

### Photos

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `slide_width` | `select` | Photo width on desktop | medium | `large`, `medium`, `small` |
| `image_ratio` | `select` | Photo shape on desktop | landscape | `landscape`, `wide`, `square`, `portrait` |
| `image_ratio_mobile` | `select` | Photo shape on mobile | portrait | `landscape`, `square`, `portrait` |
| `image_radius` | `range` | Photo corner radius | `14` | 0–40 px, step 2 |

### Hotspots

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `hotspot_style` | `select` | Hotspot style | plus | `dot`, `plus`, `number` |
| `hotspot_size` | `range` | Hotspot size | `32` | 24–48 px, step 2 |
| `color_hotspot` | `color` | Hotspot color | #FFFFFF |  |
| `color_hotspot_icon` | `color` | Hotspot icon color | #1C1917 |  |
| `pulse` | `checkbox` | Pulse the hotspots | `true` |  |

### Layout

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `section_width` | `select` | Width | page | `page`, `full` |
| `text_alignment` | `text_alignment` | Heading alignment on desktop | left |  |
| `text_alignment_mobile` | `select` | Heading alignment on mobile | left | `left`, `center`, `right` |

### Colors

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `color_bg` | `color` | Background | #F5F2EC |  |
| `color_bg_gradient` | `color_background` | Background gradient | – |  |
| `color_text` | `color` | Text | #1C1917 |  |
| `color_accent` | `color` | Accent (arrows, active product) | #1C1917 |  |
| `color_card` | `color` | Product card background | #FFFFFF |  |
| `color_card_text` | `color` | Product card text | #1C1917 |  |
| `color_sale` | `color` | Sale price | #B4432F |  |

### Spacing and visibility

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

## Blocks

Up to 8 blocks in total.

### Photo (`slide`)

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `image` | `image_picker` | Desktop image | – |  |
| `image_mobile` | `image_picker` | Mobile image | – |  |
| `caption` | `text` | Caption | – |  |
| `product_1` | `product` | Product | – |  |
| `x_1` | `range` | Horizontal position | `30` | 0–100 %, step 1 |
| `y_1` | `range` | Vertical position | `60` | 0–100 %, step 1 |
| `product_2` | `product` | Product | – |  |
| `x_2` | `range` | Horizontal position | `55` | 0–100 %, step 1 |
| `y_2` | `range` | Vertical position | `45` | 0–100 %, step 1 |
| `product_3` | `product` | Product | – |  |
| `x_3` | `range` | Horizontal position | `78` | 0–100 %, step 1 |
| `y_3` | `range` | Vertical position | `65` | 0–100 %, step 1 |
| `mobile_custom` | `checkbox` | Different hotspot positions on mobile | `false` |  |
| `xm_1` | `range` | Hotspot 1 horizontal | `30` | 0–100 %, step 1 |
| `ym_1` | `range` | Hotspot 1 vertical | `60` | 0–100 %, step 1 |
| `xm_2` | `range` | Hotspot 2 horizontal | `55` | 0–100 %, step 1 |
| `ym_2` | `range` | Hotspot 2 vertical | `45` | 0–100 %, step 1 |
| `xm_3` | `range` | Hotspot 3 horizontal | `78` | 0–100 %, step 1 |
| `ym_3` | `range` | Hotspot 3 vertical | `65` | 0–100 %, step 1 |

## 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-shoppable-carousel.liquid` and paste the contents of `sections/lib-shoppable-carousel.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 · Shoppable carousel**. 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-shoppable-carousel.liquid https://lintelagency.com/resources/shopify-sections/files/lib-shoppable-carousel.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-shoppable-carousel.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-shoppable-carousel.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-shoppable-carousel.liquid): goes in `sections/`
- [snippets/lib-image.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-image.liquid): goes in `snippets/`

## Questions

### How is this different from Shop the look?

Shop the look is one large photo with up to six hotspots and a product list beside it. This section is a row of photos, each with up to three hotspots, for when you have several scenes to show.

### How do I place the hotspots?

Each photo is a block with three products and a horizontal and vertical position in percent for each. If the mobile crop differs, turn on mobile positions and set them separately.

### Does it autoplay?

No. The row moves only when the shopper swipes, scrolls or uses the buttons. The hotspot pulse can be turned off and is always off for visitors who prefer reduced motion.

### Is it accessible?

Hotspots are real buttons with the product name as their label, the card closes with Escape and returns focus, the row can be scrolled with the keyboard, and every product is also a plain link under its photo. Every screen-reader word is a setting.

## Source

### sections/lib-shoppable-carousel.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{%- comment -%}
  Shoppable image carousel: a swipeable row of lifestyle photos, each with up to three product
  hotspots (the Shop the look pattern). A hotspot opens a small product card beside the dot; the
  products in each photo are also listed as plain links under it, so they stay reachable without
  JavaScript. Scroll-snap row with previous and next buttons; it never moves on its own.
  Separate desktop and mobile image per slide, optional mobile hotspot positions.
  Depends on: snippets/lib-image.liquid
{%- endcomment -%}

{%- liquid
  assign s = section.settings
  assign heading_tag = s.heading_tag | default: 'h2'
  assign uid = 'LibShc-' | append: section.id
  assign slide_total = section.blocks.size
-%}

<div
  class="lib-shoppable lib-shoppable--{{ s.section_width }}"
  {% if s.anchor_id != blank %}
    id="{{ s.anchor_id | handleize }}"
  {% endif %}
  data-show="{{ s.show_on }}"
  data-width="{{ s.slide_width }}"
  data-hotspot="{{ s.hotspot_style }}"
  {% if s.pulse %}
    data-pulse
  {% 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-sale: {{ s.color_sale }};
    --lib-hotspot: {{ s.color_hotspot }};
    --lib-hotspot-icon: {{ s.color_hotspot_icon }};
    --lib-card: {{ s.color_card }};
    --lib-card-text: {{ s.color_card_text }};
    --lib-dot: {{ s.hotspot_size }}px;
    --lib-radius-img: {{ s.image_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;
  "
>
  <lib-shoppable-carousel class="lib-shoppable__inner">
    <div class="lib-shoppable__top">
      {%- if s.heading != blank or s.text != blank -%}
        <header class="lib-shoppable__header" data-align="{{ s.text_alignment }}" data-align-m="{{ s.text_alignment_mobile }}">
          {%- if s.heading != blank -%}
            <{{ heading_tag }} class="lib-shoppable__heading lib-shoppable__heading--{{ s.heading_size }}">
              {{- s.heading -}}
            </{{ heading_tag }}>
          {%- endif -%}
          {%- if s.text != blank -%}
            <div class="lib-shoppable__text">{{ s.text }}</div>
          {%- endif -%}
        </header>
      {%- endif -%}
      <div class="lib-shoppable__nav" hidden data-nav>
        <button type="button" class="lib-shoppable__arrow" data-prev aria-controls="{{ uid }}-track" aria-label="{{ s.prev_label | escape }}">
          <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false"><path d="M10 3.5 5.5 8l4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </button>
        <button type="button" class="lib-shoppable__arrow" data-next aria-controls="{{ uid }}-track" aria-label="{{ s.next_label | escape }}">
          <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
        </button>
      </div>
    </div>

    <ul
      class="lib-shoppable__track"
      id="{{ uid }}-track"
      role="list"
      aria-label="{{ s.carousel_label | escape }}"
      tabindex="0"
    >
      {%- for block in section.blocks -%}
        {%- assign b = block.settings -%}
        <li
          class="lib-shoppable__slide"
          aria-label="{{ forloop.index }} / {{ slide_total }}"
          data-slide
          {{ block.shopify_attributes }}
        >
          <div class="lib-shoppable__media">
            <div class="lib-shoppable__frame" data-ratio="{{ s.image_ratio }}" data-ratio-m="{{ s.image_ratio_mobile }}">
              {%- render 'lib-image',
                image: b.image,
                image_mobile: b.image_mobile,
                class: 'lib-shoppable__img',
                sizes: '(min-width: 990px) 70vw, 90vw',
                eager: false
              -%}
            </div>

            {%- assign n = 0 -%}
            {%- for i in (1..3) -%}
              {%- liquid
                assign key = 'product_' | append: i
                assign p = b[key]
                if p == blank
                  continue
                endif
                assign n = n | plus: 1
                assign kx = 'x_' | append: i
                assign ky = 'y_' | append: i
                assign x = b[kx]
                assign y = b[ky]
                assign xm = x
                assign ym = y
                if b.mobile_custom
                  assign kx = 'xm_' | append: i
                  assign ky = 'ym_' | append: i
                  assign xm = b[kx]
                  assign ym = b[ky]
                endif
                assign card_id = uid | append: '-' | append: block.id | append: '-' | append: i
              -%}
              <button
                type="button"
                class="lib-shoppable__dot"
                style="--x: {{ x }}%; --y: {{ y }}%; --xm: {{ xm }}%; --ym: {{ ym }}%;"
                aria-expanded="false"
                aria-controls="{{ card_id }}"
                aria-label="{{ s.show_label | escape }} {{ p.title | escape }}"
                hidden
                data-dot
              >
                {%- case s.hotspot_style -%}
                  {%- when 'number' -%}
                    <span class="lib-shoppable__dot-num" aria-hidden="true">{{ n }}</span>
                  {%- when 'plus' -%}
                    <svg class="lib-shoppable__dot-plus" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false">
                      <path d="M8 3v10M3 8h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
                    </svg>
                  {%- else -%}
                    <span class="lib-shoppable__dot-core" aria-hidden="true"></span>
                {%- endcase -%}
              </button>

              <div class="lib-shoppable__card" id="{{ card_id }}" role="group" aria-label="{{ p.title | escape }}" hidden>
                <div class="lib-shoppable__card-media">
                  {%- if p.featured_image != blank -%}
                    {{
                      p.featured_image
                      | image_url: width: 320
                      | image_tag: class: 'lib-shoppable__thumb', loading: 'lazy', alt: p.featured_image.alt, widths: '160, 320'
                    }}
                  {%- else -%}
                    {{ 'product-1' | placeholder_svg_tag: 'lib-shoppable__thumb' }}
                  {%- endif -%}
                </div>
                <div class="lib-shoppable__card-body">
                  <p class="lib-shoppable__card-title">{{ p.title | escape }}</p>
                  <p class="lib-shoppable__price">
                    {%- if p.compare_at_price > p.price -%}
                      <span class="lib-shoppable__sr">{{ s.sale_label | escape }}</span>
                      <span class="lib-shoppable__now is-sale">{{ p.price | money }}</span>
                      <span class="lib-shoppable__sr">{{ s.regular_label | escape }}</span>
                      <s class="lib-shoppable__was">{{ p.compare_at_price | money }}</s>
                    {%- else -%}
                      <span class="lib-shoppable__now">{{ p.price | money }}</span>
                    {%- endif -%}
                  </p>
                  <a class="lib-shoppable__card-link" href="{{ p.url }}">
                    {{- s.card_link_label | escape -}}
                    <span aria-hidden="true"> →</span>
                  </a>
                </div>
                <button type="button" class="lib-shoppable__close" data-close aria-label="{{ s.close_label | escape }}">
                  <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true" focusable="false">
                    <path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
                  </svg>
                </button>
              </div>
            {%- endfor -%}
          </div>

          {%- if b.caption != blank or n > 0 -%}
            <div class="lib-shoppable__below">
              {%- if b.caption != blank -%}
                <p class="lib-shoppable__caption">{{ b.caption | escape }}</p>
              {%- endif -%}
              {%- if n > 0 -%}
                <ul class="lib-shoppable__products" role="list">
                  {%- assign m = 0 -%}
                  {%- for i in (1..3) -%}
                    {%- assign key = 'product_' | append: i -%}
                    {%- assign p = b[key] -%}
                    {%- if p == blank -%}{%- continue -%}{%- endif -%}
                    {%- assign m = m | plus: 1 -%}
                    <li>
                      <a href="{{ p.url }}" data-for="{{ uid }}-{{ block.id }}-{{ i }}">
                        {%- if s.hotspot_style == 'number' -%}<span class="lib-shoppable__chip-num" aria-hidden="true">{{ m }}</span>{%- endif -%}
                        <span>{{ p.title | escape }}</span>
                        <span class="lib-shoppable__chip-price">{{ p.price | money }}</span>
                      </a>
                    </li>
                  {%- endfor -%}
                </ul>
              {%- endif -%}
            </div>
          {%- endif -%}
        </li>
      {%- endfor -%}
    </ul>
  </lib-shoppable-carousel>
</div>

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

  .lib-shoppable__top { display: flex; align-items: flex-end; justify-content: space-between; gap: 1.5rem; margin-bottom: clamp(1.25rem, 3vw, 2rem); }
  .lib-shoppable__header { flex: 1; min-width: 0; text-align: left; }
  .lib-shoppable__header[data-align='center'] { text-align: center; }
  .lib-shoppable__header[data-align='right'] { text-align: right; }
  @media (max-width: 749px) {
    .lib-shoppable__header[data-align-m='left'] { text-align: left; }
    .lib-shoppable__header[data-align-m='center'] { text-align: center; }
    .lib-shoppable__header[data-align-m='right'] { text-align: right; }
  }
  .lib-shoppable__heading { margin: 0; font-size: clamp(1.75rem, 3.4vw, 2.75rem); line-height: 1.1; letter-spacing: -0.02em; text-wrap: balance; }
  .lib-shoppable__heading--small { font-size: clamp(1.5rem, 2.4vw, 2rem); }
  .lib-shoppable__heading--large { font-size: clamp(2rem, 4.4vw, 3.5rem); }
  .lib-shoppable__text { max-width: 40rem; margin-top: 0.75rem; font-size: 1.0625rem; line-height: 1.6; opacity: 0.78; }
  .lib-shoppable__header[data-align='center'] .lib-shoppable__text { margin-inline: auto; }
  .lib-shoppable__text > :first-child { margin-top: 0; }
  .lib-shoppable__text > :last-child { margin-bottom: 0; }

  .lib-shoppable__nav { display: flex; gap: 0.5rem; flex: none; }
  .lib-shoppable__nav[hidden] { display: none; }
  .lib-shoppable__arrow {
    display: grid;
    place-items: center;
    width: 2.75rem;
    height: 2.75rem;
    padding: 0;
    border: 1px solid color-mix(in srgb, var(--lib-text) 30%, transparent);
    border-radius: 50%;
    background: transparent;
    color: var(--lib-accent);
    cursor: pointer;
  }
  .lib-shoppable__arrow:hover:not(:disabled) { background: color-mix(in srgb, var(--lib-text) 8%, transparent); }
  .lib-shoppable__arrow:disabled { opacity: 0.35; cursor: default; }
  .lib-shoppable__arrow:focus-visible { outline: 2px solid var(--lib-text); outline-offset: 2px; }

  /* The row: native scrolling with snap points, so touch, trackpad and keyboard all work. */
  .lib-shoppable__track {
    --lib-slide: 72%;
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: var(--lib-slide);
    gap: clamp(0.75rem, 2vw, 1.5rem);
    margin: 0;
    padding: 0 0 0.5rem;
    list-style: none;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    scroll-behavior: smooth;
    scrollbar-width: thin;
  }
  .lib-shoppable[data-width='large'] .lib-shoppable__track { --lib-slide: 86%; }
  .lib-shoppable[data-width='small'] .lib-shoppable__track { --lib-slide: calc((100% - 2 * clamp(0.75rem, 2vw, 1.5rem)) / 2.6); }
  @media (max-width: 749px) {
    .lib-shoppable .lib-shoppable__track,
    .lib-shoppable[data-width] .lib-shoppable__track { --lib-slide: 88%; }
  }
  @media (prefers-reduced-motion: reduce) {
    .lib-shoppable__track { scroll-behavior: auto; }
  }
  .lib-shoppable__track:focus-visible { outline: 2px solid var(--lib-text); outline-offset: 4px; border-radius: 4px; }
  .lib-shoppable__slide { scroll-snap-align: start; min-width: 0; }

  .lib-shoppable__media { position: relative; }
  .lib-shoppable__frame {
    position: relative;
    overflow: hidden;
    border-radius: var(--lib-radius-img);
    background: color-mix(in srgb, var(--lib-text) 6%, transparent);
    aspect-ratio: 4 / 3;
  }
  .lib-shoppable__frame[data-ratio='square'] { aspect-ratio: 1; }
  .lib-shoppable__frame[data-ratio='portrait'] { aspect-ratio: 4 / 5; }
  .lib-shoppable__frame[data-ratio='wide'] { aspect-ratio: 16 / 9; }
  @media (max-width: 749px) {
    .lib-shoppable__frame[data-ratio-m='landscape'] { aspect-ratio: 4 / 3; }
    .lib-shoppable__frame[data-ratio-m='square'] { aspect-ratio: 1; }
    .lib-shoppable__frame[data-ratio-m='portrait'] { aspect-ratio: 4 / 5; }
  }
  .lib-shoppable__img { display: block; width: 100%; height: 100%; object-fit: cover; }
  svg.lib-shoppable__img { fill: color-mix(in srgb, var(--lib-text) 22%, transparent); }

  .lib-shoppable__dot {
    position: absolute;
    z-index: 1;
    left: var(--x);
    top: var(--y);
    translate: -50% -50%;
    display: grid;
    place-items: center;
    width: var(--lib-dot);
    height: var(--lib-dot);
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--lib-hotspot);
    color: var(--lib-hotspot-icon);
    font: inherit;
    font-size: calc(var(--lib-dot) * 0.42);
    font-weight: 700;
    line-height: 1;
    cursor: pointer;
    box-shadow: 0 2px 10px rgb(0 0 0 / 0.28);
    transition: transform 0.2s ease;
  }
  .lib-shoppable__dot[hidden] { display: none; }
  @media (max-width: 749px) {
    .lib-shoppable__dot { left: var(--xm); top: var(--ym); }
  }
  .lib-shoppable__dot::after { content: ''; position: absolute; inset: -8px; border-radius: 50%; }
  .lib-shoppable[data-pulse] .lib-shoppable__dot::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    animation: lib-shoppable-pulse 2.2s infinite;
  }
  @media (prefers-reduced-motion: reduce) {
    .lib-shoppable[data-pulse] .lib-shoppable__dot::before { animation: none; }
    .lib-shoppable__card { animation: none; }
  }
  @keyframes lib-shoppable-pulse {
    0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--lib-hotspot) 70%, transparent); }
    70%, 100% { box-shadow: 0 0 0 12px transparent; }
  }
  .lib-shoppable__dot:hover,
  .lib-shoppable__dot.is-hint,
  .lib-shoppable__dot[aria-expanded='true'] { transform: scale(1.12); }
  .lib-shoppable__dot[aria-expanded='true'] { z-index: 3; }
  .lib-shoppable__dot:focus-visible { outline: 2px solid var(--lib-hotspot-icon); outline-offset: 2px; box-shadow: 0 0 0 6px var(--lib-hotspot); }
  .lib-shoppable__dot-core { width: 34%; height: 34%; border-radius: 50%; background: currentColor; }
  .lib-shoppable__dot-plus { transition: transform 0.2s ease; }
  .lib-shoppable__dot[aria-expanded='true'] .lib-shoppable__dot-plus { transform: rotate(45deg); }

  .lib-shoppable__card {
    position: absolute;
    z-index: 4;
    left: 0;
    top: 0;
    display: grid;
    grid-template-columns: 4.5rem minmax(0, 1fr);
    gap: 0.8rem;
    align-items: center;
    width: 16rem;
    max-width: calc(100% - 16px);
    padding: 0.65rem 2rem 0.65rem 0.65rem;
    border-radius: 12px;
    background: var(--lib-card);
    color: var(--lib-card-text);
    box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.35), 0 0 0 1px rgb(0 0 0 / 0.04);
    animation: lib-shoppable-in 0.18s ease-out;
  }
  .lib-shoppable__card[hidden] { display: none; }
  @keyframes lib-shoppable-in { from { opacity: 0; transform: translateY(4px) scale(0.98); } }
  .lib-shoppable__card-media { aspect-ratio: 1; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--lib-card-text) 6%, transparent); }
  .lib-shoppable__thumb { display: block; width: 100%; height: 100%; object-fit: cover; }
  .lib-shoppable__card-body { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
  .lib-shoppable__card-title { margin: 0; font-weight: 600; line-height: 1.3; }
  .lib-shoppable__price { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; margin: 0; font-size: 0.9375rem; }
  .lib-shoppable__now.is-sale { color: var(--lib-sale); font-weight: 600; }
  .lib-shoppable__was { opacity: 0.55; font-size: 0.875em; }
  .lib-shoppable__card-link { margin-top: 0.3rem; color: inherit; font-size: 0.875rem; font-weight: 600; text-decoration: underline; text-underline-offset: 0.25em; }
  .lib-shoppable__card-link:focus-visible,
  .lib-shoppable__close:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 4px; }
  .lib-shoppable__close {
    position: absolute;
    top: 0.4rem;
    right: 0.4rem;
    display: grid;
    place-items: center;
    width: 1.75rem;
    height: 1.75rem;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: inherit;
    cursor: pointer;
    opacity: 0.6;
  }
  .lib-shoppable__close:hover { opacity: 1; background: color-mix(in srgb, currentColor 8%, transparent); }

  .lib-shoppable__below { display: grid; gap: 0.5rem; margin-top: 0.85rem; }
  .lib-shoppable__caption { margin: 0; font-weight: 600; }
  .lib-shoppable__products { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0; padding: 0; list-style: none; }
  .lib-shoppable__products a {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    min-height: 2rem;
    padding: 0.3rem 0.75rem;
    border: 1px solid color-mix(in srgb, var(--lib-text) 22%, transparent);
    border-radius: 999px;
    color: inherit;
    font-size: 0.875rem;
    text-decoration: none;
  }
  .lib-shoppable__products a:hover,
  .lib-shoppable__products a.is-active { border-color: var(--lib-accent); background: color-mix(in srgb, var(--lib-accent) 8%, transparent); }
  .lib-shoppable__products a:focus-visible { outline: 2px solid var(--lib-text); outline-offset: 2px; }
  .lib-shoppable__chip-num {
    display: grid;
    place-items: center;
    width: 1.25rem;
    height: 1.25rem;
    border-radius: 50%;
    background: var(--lib-accent);
    color: var(--lib-bg);
    font-size: 0.7rem;
    font-weight: 700;
  }
  .lib-shoppable__chip-price { opacity: 0.7; }
  .lib-shoppable__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
{% endstylesheet %}

{% javascript %}
  if (!customElements.get('lib-shoppable-carousel')) {
    customElements.define(
      'lib-shoppable-carousel',
      class extends HTMLElement {
        connectedCallback() {
          if (this.ready) return;
          this.ready = true;
          this.track = this.querySelector('.lib-shoppable__track');
          this.slides = [...this.querySelectorAll('[data-slide]')];
          this.dots = [...this.querySelectorAll('[data-dot]')];
          this.prev = this.querySelector('[data-prev]');
          this.next = this.querySelector('[data-next]');
          this.current = null;
          if (!this.track) return;

          // Hotspots need JavaScript; without it the product links under each photo do the job.
          this.dots.forEach((dot) => {
            dot.hidden = false;
            dot.addEventListener('click', () => this.toggle(dot));
          });
          this.querySelectorAll('[data-close]').forEach((btn) => btn.addEventListener('click', () => this.close(true)));
          this.querySelectorAll('[data-for]').forEach((link) => {
            const dot = this.dots.find((d) => d.getAttribute('aria-controls') === link.dataset.for);
            if (!dot) return;
            const on = () => dot.classList.add('is-hint');
            const off = () => dot.classList.remove('is-hint');
            link.addEventListener('pointerenter', on);
            link.addEventListener('pointerleave', off);
            link.addEventListener('focus', on);
            link.addEventListener('blur', off);
          });

          this.prev?.addEventListener('click', () => this.step(-1));
          this.next?.addEventListener('click', () => this.step(1));
          this.onScroll = () => {
            window.cancelAnimationFrame(this.frame);
            this.frame = window.requestAnimationFrame(() => this.updateNav());
          };
          this.track.addEventListener('scroll', this.onScroll, {passive: true});
          this.onDocClick = (e) => {
            if (!this.current) return;
            const {dot, card} = this.current;
            if (!card.contains(e.target) && !dot.contains(e.target)) this.close(false);
          };
          this.onKey = (e) => {
            if (e.key === 'Escape' && this.current) this.close(true);
          };
          this.onResize = () => {
            this.updateNav();
            if (this.current) this.place(this.current.dot, this.current.card);
          };
          document.addEventListener('click', this.onDocClick);
          document.addEventListener('keydown', this.onKey);
          window.addEventListener('resize', this.onResize);
          // In the theme editor, selecting a slide block scrolls it into view.
          this.addEventListener('shopify:block:select', (e) => e.target.scrollIntoView?.({block: 'nearest', inline: 'start'}));
          this.updateNav();
        }

        disconnectedCallback() {
          document.removeEventListener('click', this.onDocClick);
          document.removeEventListener('keydown', this.onKey);
          window.removeEventListener('resize', this.onResize);
          this.ready = false;
        }

        step(direction) {
          const slide = this.slides[0];
          if (!slide) return;
          const gap = parseFloat(getComputedStyle(this.track).columnGap) || 0;
          this.close(false);
          // 'auto' follows the row's CSS scroll-behavior: smooth, or instant under reduced motion.
          this.track.scrollBy({left: direction * (slide.offsetWidth + gap), behavior: 'auto'});
        }

        updateNav() {
          const nav = this.querySelector('[data-nav]');
          const max = this.track.scrollWidth - this.track.clientWidth;
          if (nav) nav.hidden = max <= 2;
          if (this.prev) this.prev.disabled = this.track.scrollLeft <= 2;
          if (this.next) this.next.disabled = this.track.scrollLeft >= max - 2;
          // A card left open on a slide that scrolled away would float over the next one.
          if (this.current) {
            const t = this.track.getBoundingClientRect();
            const d = this.current.dot.getBoundingClientRect();
            if (d.right < t.left || d.left > t.right) this.close(false);
          }
        }

        toggle(dot) {
          const wasOpen = this.current && this.current.dot === dot;
          this.close(false);
          if (!wasOpen) this.open(dot);
        }

        open(dot) {
          const id = dot.getAttribute('aria-controls');
          const card = id ? document.getElementById(id) : null;
          if (!card) return;
          card.hidden = false;
          dot.setAttribute('aria-expanded', 'true');
          this.current = {dot, card};
          this.place(dot, card);
          this.querySelectorAll('[data-for]').forEach((link) => link.classList.toggle('is-active', link.dataset.for === id));
        }

        close(returnFocus) {
          if (!this.current) return;
          const {dot, card} = this.current;
          card.hidden = true;
          dot.setAttribute('aria-expanded', 'false');
          this.current = null;
          this.querySelectorAll('[data-for].is-active').forEach((link) => link.classList.remove('is-active'));
          if (returnFocus) dot.focus();
        }

        // Same placement as Shop the look: beside the dot on the side with room, else below or
        // above it, always inside the photo.
        place(dot, card) {
          const media = dot.closest('.lib-shoppable__media');
          const m = media.getBoundingClientRect();
          const d = dot.getBoundingClientRect();
          const pad = 8;
          const gap = d.width / 2 + 10;
          const cx = d.left + d.width / 2 - m.left;
          const cy = d.top + d.height / 2 - m.top;
          const w = card.offsetWidth;
          const h = card.offsetHeight;
          const fitsRight = cx + gap + w <= m.width - pad;
          const fitsLeft = cx - gap - w >= pad;
          const clamp = (v, max) => Math.max(pad, Math.min(v, Math.max(pad, max)));
          let x;
          let y;
          if (fitsRight || fitsLeft) {
            x = fitsRight && (cx <= m.width / 2 || !fitsLeft) ? cx + gap : cx - gap - w;
            y = clamp(cy - h / 2, m.height - h - pad);
          } else if (cy + gap + h <= m.height - pad) {
            x = cx - w / 2;
            y = cy + gap;
          } else {
            x = cx - w / 2;
            y = clamp(cy - gap - h, m.height - h - pad);
          }
          card.style.left = `${clamp(x, m.width - w - pad)}px`;
          card.style.top = `${Math.round(y)}px`;
        }
      }
    );
  }
{% endjavascript %}

{% schema %}
{
  "name": "Lib · Shoppable carousel",
  "tag": "section",
  "class": "lib-section",
  "max_blocks": 8,
  "settings": [
    { "type": "header", "content": "Content" },
    { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Shop the <em>rooms</em>" },
    {
      "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": "Text", "default": "<p>Tap a dot to see what is in each photo.</p>" },

    { "type": "header", "content": "Words" },
    { "type": "text", "id": "card_link_label", "label": "Product card link", "default": "View product" },
    { "type": "text", "id": "show_label", "label": "Screen reader: before a hotspot's product", "default": "Show" },
    { "type": "text", "id": "close_label", "label": "Screen reader: close card", "default": "Close" },
    { "type": "text", "id": "prev_label", "label": "Screen reader: previous", "default": "Previous photo" },
    { "type": "text", "id": "next_label", "label": "Screen reader: next", "default": "Next photo" },
    { "type": "text", "id": "carousel_label", "label": "Screen reader: name of the row", "default": "Shoppable photos" },
    { "type": "text", "id": "sale_label", "label": "Screen reader: sale price", "default": "Sale price" },
    { "type": "text", "id": "regular_label", "label": "Screen reader: regular price", "default": "Regular price" },

    { "type": "header", "content": "Photos" },
    {
      "type": "select",
      "id": "slide_width",
      "label": "Photo width on desktop",
      "info": "On mobile each photo takes most of the screen, with the next one peeking in.",
      "options": [
        { "value": "large", "label": "Large (one photo)" },
        { "value": "medium", "label": "Medium (next one peeks in)" },
        { "value": "small", "label": "Small (two and a half)" }
      ],
      "default": "medium"
    },
    {
      "type": "select",
      "id": "image_ratio",
      "label": "Photo shape on desktop",
      "info": "Hotspot positions are measured on the photo as shown.",
      "options": [
        { "value": "landscape", "label": "Landscape (4:3)" },
        { "value": "wide", "label": "Wide (16:9)" },
        { "value": "square", "label": "Square" },
        { "value": "portrait", "label": "Portrait (4:5)" }
      ],
      "default": "landscape"
    },
    {
      "type": "select",
      "id": "image_ratio_mobile",
      "label": "Photo shape on mobile",
      "options": [
        { "value": "landscape", "label": "Landscape (4:3)" },
        { "value": "square", "label": "Square" },
        { "value": "portrait", "label": "Portrait (4:5)" }
      ],
      "default": "portrait"
    },
    { "type": "range", "id": "image_radius", "label": "Photo corner radius", "min": 0, "max": 40, "step": 2, "unit": "px", "default": 14 },

    { "type": "header", "content": "Hotspots" },
    {
      "type": "select",
      "id": "hotspot_style",
      "label": "Hotspot style",
      "options": [
        { "value": "dot", "label": "Dot" },
        { "value": "plus", "label": "Plus" },
        { "value": "number", "label": "Number (also shown in the product links)" }
      ],
      "default": "plus"
    },
    { "type": "range", "id": "hotspot_size", "label": "Hotspot size", "min": 24, "max": 48, "step": 2, "unit": "px", "default": 32 },
    { "type": "color", "id": "color_hotspot", "label": "Hotspot color", "default": "#FFFFFF" },
    { "type": "color", "id": "color_hotspot_icon", "label": "Hotspot icon color", "default": "#1C1917" },
    { "type": "checkbox", "id": "pulse", "label": "Pulse the hotspots", "info": "Always off for visitors who prefer reduced motion.", "default": true },

    { "type": "header", "content": "Layout" },
    {
      "type": "select",
      "id": "section_width",
      "label": "Width",
      "options": [
        { "value": "page", "label": "Page width" },
        { "value": "full", "label": "Full width" }
      ],
      "default": "page"
    },
    { "type": "text_alignment", "id": "text_alignment", "label": "Heading alignment on desktop", "default": "left" },
    {
      "type": "select",
      "id": "text_alignment_mobile",
      "label": "Heading alignment on mobile",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" },
        { "value": "right", "label": "Right" }
      ],
      "default": "left"
    },

    { "type": "header", "content": "Colors" },
    { "type": "color", "id": "color_bg", "label": "Background", "default": "#F5F2EC" },
    { "type": "color_background", "id": "color_bg_gradient", "label": "Background gradient", "info": "Optional. Covers the background color." },
    { "type": "color", "id": "color_text", "label": "Text", "default": "#1C1917" },
    { "type": "color", "id": "color_accent", "label": "Accent (arrows, active product)", "default": "#1C1917" },
    { "type": "color", "id": "color_card", "label": "Product card background", "default": "#FFFFFF" },
    { "type": "color", "id": "color_card_text", "label": "Product card text", "default": "#1C1917" },
    { "type": "color", "id": "color_sale", "label": "Sale price", "default": "#B4432F" },

    { "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": 80 },
    { "type": "range", "id": "padding_bottom", "label": "Bottom padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 80 },
    { "type": "range", "id": "padding_top_mobile", "label": "Top padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 48 },
    { "type": "range", "id": "padding_bottom_mobile", "label": "Bottom padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 48 },
    {
      "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." }
  ],
  "blocks": [
    {
      "type": "slide",
      "name": "Photo",
      "settings": [
        { "type": "image_picker", "id": "image", "label": "Desktop image" },
        { "type": "image_picker", "id": "image_mobile", "label": "Mobile image", "info": "Optional. If it is cropped differently, set mobile hotspot positions below." },
        { "type": "text", "id": "caption", "label": "Caption", "info": "Optional. Shown under the photo." },
        { "type": "header", "content": "Hotspot 1" },
        { "type": "product", "id": "product_1", "label": "Product" },
        { "type": "range", "id": "x_1", "label": "Horizontal position", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 30 },
        { "type": "range", "id": "y_1", "label": "Vertical position", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 60 },
        { "type": "header", "content": "Hotspot 2" },
        { "type": "product", "id": "product_2", "label": "Product" },
        { "type": "range", "id": "x_2", "label": "Horizontal position", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 55 },
        { "type": "range", "id": "y_2", "label": "Vertical position", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 45 },
        { "type": "header", "content": "Hotspot 3" },
        { "type": "product", "id": "product_3", "label": "Product" },
        { "type": "range", "id": "x_3", "label": "Horizontal position", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 78 },
        { "type": "range", "id": "y_3", "label": "Vertical position", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 65 },
        { "type": "header", "content": "Mobile" },
        { "type": "checkbox", "id": "mobile_custom", "label": "Different hotspot positions on mobile", "default": false },
        { "type": "range", "id": "xm_1", "label": "Hotspot 1 horizontal", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 30, "visible_if": "{{ block.settings.mobile_custom }}" },
        { "type": "range", "id": "ym_1", "label": "Hotspot 1 vertical", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 60, "visible_if": "{{ block.settings.mobile_custom }}" },
        { "type": "range", "id": "xm_2", "label": "Hotspot 2 horizontal", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 55, "visible_if": "{{ block.settings.mobile_custom }}" },
        { "type": "range", "id": "ym_2", "label": "Hotspot 2 vertical", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 45, "visible_if": "{{ block.settings.mobile_custom }}" },
        { "type": "range", "id": "xm_3", "label": "Hotspot 3 horizontal", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 78, "visible_if": "{{ block.settings.mobile_custom }}" },
        { "type": "range", "id": "ym_3", "label": "Hotspot 3 vertical", "min": 0, "max": 100, "step": 1, "unit": "%", "default": 65, "visible_if": "{{ block.settings.mobile_custom }}" }
      ]
    }
  ],
  "presets": [
    {
      "name": "Lib · Shoppable carousel",
      "blocks": [
        { "type": "slide" },
        { "type": "slide" },
        { "type": "slide" }
      ]
    }
  ]
}
{% 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.
