← Projects/Product page

Build a Shopify sticky add-to-cart bar

When the main buy button scrolls off screen, a slim sticky bar slides up from the bottom with the product, variant, and a buy button. Increases mobile conversion meaningfully with about 30 lines of code.

Beginner20 minutes4 stepsFree, no signup

Here's what you'll build

  • A slim bar at the bottom of the product page
  • Bar appears only after the main buy button scrolls off screen
  • Shows product image, title, variant, price, and a buy button
  • Reuses the existing variant selection so no state drift

Before you start

  • Comfortable editing a Shopify product template
  • Basic HTML/CSS
  • You've seen IntersectionObserver before (or willing to copy it)

On mobile, the product page is a long scroll: hero, gallery, description, reviews, related products. By the time a shopper hits 'I want this,' the buy button is somewhere 1,500 pixels above them. They have to scroll back to add to cart, which is the moment a meaningful chunk of them just bail. A sticky add-to-cart bar removes that friction entirely.

The trick is to NOT show the bar while the main buy button is in view (it would be redundant and visually noisy). We only show it once the main button has scrolled past, which is what IntersectionObserver was designed for.

northwindcoffee.myshopify.com
What you'll have at the end. Slim bar pinned to the bottom, appears only after the main buy button scrolls out of view.

Step 1

Add a sentinel element near the main buy button

We need something to observe. Add an empty <div> just below the main add-to-cart button on your product template:

sections/main-product.liquid (or your buy block)
<button type="submit" name="add" class="product-form__add">Add to cart</button> {% comment %} Sentinel for the sticky bar to track when the main button is in view. {% endcomment %}<div data-sticky-atc-sentinel aria-hidden="true"></div>

Step 2

Build the sticky bar snippet

The bar mirrors the main form's product/variant so they stay in sync. Render it once, near the bottom of the product template:

snippets/sticky-atc.liquid
{% liquid  assign current_variant = product.selected_or_first_available_variant%} <div class="sticky-atc" data-sticky-atc aria-hidden="true">  <a href="{{ product.url }}" class="sticky-atc__img" tabindex="-1">    {{ product.featured_image | image_url: width: 80 | image_tag: loading: 'lazy', widths: '40,80' }}  </a>  <div class="sticky-atc__meta">    <h3 class="sticky-atc__title">{{ product.title }}</h3>    <p class="sticky-atc__sub">      {% unless product.has_only_default_variant %}        <span data-sticky-atc-variant>{{ current_variant.title }}</span> ·      {% endunless %}      <span data-sticky-atc-price>{{ current_variant.price | money }}</span>    </p>  </div>  <form action="/cart/add" method="post" enctype="multipart/form-data" data-sticky-atc-form>    <input type="hidden" name="id" value="{{ current_variant.id }}" data-sticky-atc-id>    <button type="submit" class="sticky-atc__buy" {% unless current_variant.available %}disabled{% endunless %}>      {% if current_variant.available %}Add to cart{% else %}Sold out{% endif %}    </button>  </form></div> {% stylesheet %}  .sticky-atc {    position: fixed; bottom: 0; left: 0; right: 0;    background: rgb(var(--color-background, 255 255 255));    border-top: 1px solid rgb(var(--color-foreground, 0 0 0) / 0.08);    box-shadow: 0 -8px 20px rgb(0 0 0 / 0.06);    padding: 12px 16px;    display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center;    transform: translateY(100%);    transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);    z-index: 50;  }  .sticky-atc[aria-hidden="false"] { transform: translateY(0); }  .sticky-atc__img img { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; display: block; }  .sticky-atc__title { margin: 0; font-size: 13px; font-weight: 500; line-height: 1.2; }  .sticky-atc__sub { margin: 2px 0 0 0; font-size: 11.5px; color: rgb(var(--color-foreground, 0 0 0) / 0.6); }  .sticky-atc__buy {    background: rgb(var(--color-foreground, 0 0 0));    color: rgb(var(--color-background, 255 255 255));    border: 0; padding: 10px 16px; border-radius: 6px;    font-size: 13px; font-weight: 600; cursor: pointer;  }  .sticky-atc__buy:disabled { opacity: 0.5; cursor: not-allowed; }{% endstylesheet %}

Step 3

Hook up the show/hide observer

assets/sticky-atc.js
(() => {  const bar = document.querySelector('[data-sticky-atc]');  const sentinel = document.querySelector('[data-sticky-atc-sentinel]');  if (!bar || !sentinel) return;   const observer = new IntersectionObserver(    (entries) => {      const inView = entries[0].isIntersecting;      // Show the bar when the main buy button has SCROLLED PAST (not in view).      bar.setAttribute('aria-hidden', inView ? 'true' : 'false');    },    { rootMargin: '0px 0px -10% 0px' },  );  observer.observe(sentinel);   // Keep the sticky form's variant id in sync if the main form  // changes variants (e.g. user picks 'Beans (whole)' vs 'Ground').  document.addEventListener('variant:change', (e) => {    const variant = e.detail?.variant;    if (!variant) return;    bar.querySelector('[data-sticky-atc-id]').value = variant.id;    bar.querySelector('[data-sticky-atc-price]').textContent = variant.price_formatted;    const variantEl = bar.querySelector('[data-sticky-atc-variant]');    if (variantEl) variantEl.textContent = variant.title;    const buy = bar.querySelector('.sticky-atc__buy');    buy.disabled = !variant.available;    buy.textContent = variant.available ? 'Add to cart' : 'Sold out';  });})();

About the `variant:change` event

Horizon's variant-picker dispatches a custom event when the user picks a variant. Older themes don't, so check what your theme exposes. Worst case: subscribe to the change event on the main form's variant <input> and read the new id from there.

Step 4

Render it on the product template

sections/main-product.liquid (near the bottom)
{% render 'sticky-atc', product: product %} <script src="{{ 'sticky-atc.js' | asset_url }}" defer></script>

Step 5

Test on mobile

  • Scroll a product page on a real phone. The bar should slide up after the main buy button leaves the viewport, and slide back down if you scroll up enough to bring the main button back in view.
  • Change the variant: the bar's price and add-to-cart button state should update immediately.
  • Tap the bar's buy button: should add to cart (and, if you have the cart drawer from the other project, open the drawer).
  • Make sure the bar isn't covering anything critical on the product page. Add padding-bottom: 80px to the product template so the last content is reachable.

Don't enable this on desktop without thinking about it

On desktop, the buy button is usually visible because the layout is two-column. Showing a sticky bar AND a visible main button is redundant. Wrap the snippet render in a {% if request.design_mode or template == 'product' %} check that also reads a viewport hint, OR hide the bar via CSS at min-width: 900px. I default to mobile-only.

Learn this properly

This project shows you the working code. To actually internalize the patterns behind it (so you can build the next feature without copying), take the matching lessons in the platform. Free during the beta.

  • Lesson

    Render product pricing the way production themes do

    You can render product data in a theme template using the right Liquid objects and filters: the same patterns Shopify's stock themes use, the same patterns a code reviewer would expect.

FAQ

Does this work on Online Store 2.0 / Horizon?

Yes. The IntersectionObserver pattern is theme-agnostic. The only theme-specific bit is the `variant:change` event in step 3, Horizon dispatches it, older themes don't. If your theme doesn't, listen on the variant select's `change` event instead.

Should I add the sticky bar to every PDP layout?

Yes. If a merchant has multiple product templates (e.g. one for normal products, one for subscriptions), render the snippet in each. Or render it in the layout file so it's global; just guard it with `{% if template contains 'product' %}` so it doesn't show on non-product pages.

Why use a sentinel element instead of observing the buy button directly?

You can observe the buy button directly. The sentinel is just a separate div for clarity, makes it obvious to a future developer reading the template what's going on. Observing the button works fine too.

Found a bug, a better pattern, or want to suggest the next project? Email hello@learnshopify.dev.