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.
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:
<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:
{% 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
(() => { 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
{% 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: 80pxto 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.