Every Shopify store I audit has the same mobile problem: the Add to Cart button is buried 3-5 scrolls below the product images.
On a recent CRO sprint for a used furniture store, Microsoft Clarity scroll data showed that 21% of mobile visitors dropped off before reaching the ATC button at 30% scroll depth. By 50% scroll depth, a third of visitors were gone. By 80%, half had left. The ATC button was visible only to people who scrolled past product images, trust icons, description, and accordion tabs.
The store’s mobile conversion rate was 0.11%. The ATC rate was 1.6% against a 5-10% benchmark.
This is not unusual. I have seen the same pattern on four different Shopify stores across four different industries in the last 90 days. Long mobile PDPs with the purchase action buried below the fold are the single most common conversion killer on mobile Shopify.
The fix is a sticky bottom bar that appears when the main ATC button scrolls out of view. One implementation. Immediate impact.
The Scroll Depth Problem (Real Data)
Here is the actual scroll data from a mobile PDP audit. 76 sessions, tracked via Microsoft Clarity:
| Scroll % | Visitors Remaining | Drop-off | What’s at This Depth |
|---|---|---|---|
| 5% | 76 (100%) | 0% | Header + announcement bar |
| 15% | 69 (91%) | 9% | Product images |
| 30% | 60 (79%) | 21% | ATC button area |
| 50% | 50 (66%) | 34% | Trust icons + description |
| 70% | 40 (53%) | 47% | Accordion tabs |
| 80% | 38 (50%) | 50% | Related products |
| 85% | 11 (14%) | 86% | Contact form + footer |
By the time mobile visitors reach the ATC button, one in five has already left. And this is a store selling 10,000+ NOK items (roughly $1,000 USD) where customers need to read details before purchasing. The page has to be long. You cannot just “move ATC up” because the product information above it is necessary for a considered purchase.
The sticky bar solves this by keeping the purchase action visible while letting the page stay as long as it needs to be.
I have seen nearly identical patterns on:
- A home improvement store with a complex product builder (62% mobile-to-desktop ATC gap, ATC buried below configurator options)
- An automotive accessories store selling $400-700 products (no sticky ATC on mobile, long PDPs with fitment info and reviews)
- A DTC water bottle brand where the desktop PDP had no sticky bar and the cart drawer was losing 74.6% of add-to-cart users
Different industries, same structural problem.
The Implementation
Three parts: HTML (Liquid), CSS, and JavaScript (IntersectionObserver).
Part 1: Liquid Markup
Add this to the bottom of your product template, after the main product section. In OS 2.0 themes, this goes in sections/main-product.liquid just before {% endschema %}, or in a separate snippet rendered from the product template.
{% comment %} Sticky ATC bar - mobile only {% endcomment %}
<div class="sticky-atc-bar" id="sticky-atc-bar" aria-hidden="true">
<div class="sticky-atc-inner">
<div class="sticky-atc-info">
<span class="sticky-atc-title">{{ product.title | truncate: 30 }}</span>
<span class="sticky-atc-price" id="sticky-atc-price">
{{ product.selected_or_first_available_variant.price | money }}
</span>
</div>
<button
type="button"
class="sticky-atc-button"
id="sticky-atc-button"
aria-label="Add to cart"
>
Add to Cart
</button>
</div>
</div>
Key decisions:
type="button"nottype="submit". The sticky bar does not contain a form. It delegates the click to the main product form’s submit button. This avoids duplicating form logic, variant IDs, and cart-handling JavaScript.aria-hidden="true"by default. Screen readers should not announce the bar until it is visible. The JavaScript toggles this attribute when the bar shows/hides.- Truncated title. Mobile sticky bars have limited space. 30 characters prevents the title from wrapping or overflowing.
Part 2: CSS
.sticky-atc-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 4;
background: #fff;
border-top: 1px solid #e5e5e5;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
transform: translateY(100%);
transition: transform 0.3s ease;
padding: 0.75rem 1rem;
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
}
.sticky-atc-bar.is-visible {
transform: translateY(0);
}
.sticky-atc-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
max-width: 480px;
margin: 0 auto;
}
.sticky-atc-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.sticky-atc-title {
font-size: 0.8rem;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sticky-atc-price {
font-size: 1rem;
font-weight: 700;
color: #1a1a1a;
}
.sticky-atc-button {
flex-shrink: 0;
background: #1a1a1a;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
min-height: 44px;
min-width: 44px;
}
.sticky-atc-button:active {
background: #333;
}
/* Hide on desktop */
@media (min-width: 750px) {
.sticky-atc-bar {
display: none;
}
}
Key decisions:
transform: translateY(100%)for hiding. Slides the bar off-screen below the viewport. Nodisplay: nonetoggle, which avoids layout recalculation and CLS.env(safe-area-inset-bottom)padding. Accounts for the home indicator bar on iPhones with notches. Without this, the ATC button sits behind the gesture bar on iPhone X and later.z-index: 4. High enough to sit above product content but below modals and cart drawers (which typically use z-index 10+). Adjust if your theme uses a different z-index scale.min-height: 44pxon the button. Apple’s Human Interface Guidelines require 44px minimum touch targets. This is not optional.- Hidden above 750px. Shopify’s default mobile breakpoint. Desktop product pages typically have a sidebar or sticky column that keeps ATC visible.
Part 3: JavaScript (IntersectionObserver)
(function() {
var mainButton = document.querySelector('[name="add"]');
var stickyBar = document.getElementById('sticky-atc-bar');
var stickyButton = document.getElementById('sticky-atc-button');
if (!mainButton || !stickyBar || !stickyButton) return;
// Show sticky bar when main ATC scrolls out of view
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
stickyBar.classList.remove('is-visible');
stickyBar.setAttribute('aria-hidden', 'true');
} else {
stickyBar.classList.add('is-visible');
stickyBar.setAttribute('aria-hidden', 'false');
}
});
}, { threshold: 0 });
observer.observe(mainButton);
// Delegate click to main form's submit button
stickyButton.addEventListener('click', function() {
mainButton.click();
});
// Sync price when variant changes
var priceEl = document.getElementById('sticky-atc-price');
if (priceEl) {
var mainPrice = document.querySelector('.price__regular .price-item--regular');
if (!mainPrice) mainPrice = document.querySelector('.price .price-item');
if (mainPrice) {
var priceObserver = new MutationObserver(function() {
priceEl.textContent = mainPrice.textContent;
});
priceObserver.observe(mainPrice, { childList: true, characterData: true, subtree: true });
}
}
})();
Key decisions:
- IntersectionObserver, not scroll events. Scroll listeners fire on every pixel of scroll, blocking the main thread. IntersectionObserver runs asynchronously and only fires when the target element crosses the viewport boundary. Near-zero performance cost.
threshold: 0means the callback fires the moment any part of the main ATC button enters or leaves the viewport. The sticky bar appears the instant the real button scrolls out of sight.mainButton.click()delegation. The sticky bar does not submit a form. It clicks the real ATC button, which triggers whatever cart logic the theme already has (AJAX cart, cart drawer, redirect to cart page, etc.). This means you do not need to duplicate variant selection, quantity handling, or cart JavaScript.- MutationObserver for price sync. When the customer selects a different variant using the main product form, the price element in the DOM updates. The MutationObserver watches for that change and mirrors it to the sticky bar. This works regardless of whether the theme uses PubSub, custom events, or direct DOM manipulation for variant switching.
For Themes Using PubSub (Dawn and Similar)
If your OS 2.0 theme uses Shopify’s PubSub pattern for variant changes, you can subscribe directly instead of using MutationObserver:
// Only if your theme has a PubSub module
if (typeof subscribe === 'function') {
subscribe('variant:change', function(event) {
var variant = event.detail.variant;
if (variant && priceEl) {
priceEl.textContent = Shopify.formatMoney(variant.price);
}
});
}
The MutationObserver approach in the main code block works universally across all themes, so use that if you are unsure whether your theme supports PubSub.
Handling Edge Cases
Products That Are Sold Out
Disable the sticky bar button when the product is unavailable:
// Check if main button is disabled (sold out)
if (mainButton.disabled) {
stickyButton.disabled = true;
stickyButton.textContent = 'Sold Out';
}
For dynamic availability (variants going in and out of stock), extend the MutationObserver to also watch the main button’s disabled attribute.
Single-Unit Inventory Items
Some stores (especially used/secondhand goods) sell one-of-a-kind items. The quantity selector on the PDP is unnecessary when only 1 unit exists. Hiding it on the main PDP saves roughly 80px of vertical space on mobile, which pushes the main ATC button higher and reduces the scroll depth problem:
{% if product.selected_or_first_available_variant.inventory_quantity <= 1 %}
<style>
.product-form__quantity { display: none; }
</style>
{% endif %}
This is a small change with meaningful impact on stores selling unique items.
Omnisend, Klaviyo, or Chat Widget Bottom Bars
Many stores have persistent bottom bars from email popup apps or chat widgets. If your store has one, the sticky ATC bar will overlap with it.
Fix with a bottom offset:
.sticky-atc-bar {
bottom: 60px; /* Height of the existing bottom bar */
}
Or better: remove the persistent bottom bar from product pages entirely. On the used furniture store I mentioned, Clarity data showed that a persistent email popup bar was consuming 14% of all mobile taps. Users were dismissing it instead of shopping. That bar was actively hurting conversions, and removing it on product pages was the single biggest quick win of the entire sprint.
Cart Drawer Overlap
When the customer taps the sticky ATC button and the cart drawer opens, the sticky bar should hide so it does not sit underneath the drawer. Listen for the cart drawer opening:
// For Dawn-based themes
document.addEventListener('cart:open', function() {
stickyBar.classList.remove('is-visible');
});
document.addEventListener('cart:close', function() {
// Re-check if main button is in view
var rect = mainButton.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) {
stickyBar.classList.add('is-visible');
}
});
The exact event names depend on your theme. Check your theme’s cart drawer JavaScript for the event it dispatches on open/close.
What Not to Put in the Sticky Bar
Every element you add to the sticky bar reduces the tap target size of the ATC button and adds visual noise.
Do not include:
- Quantity selector (customers rarely buy more than 1 from mobile PDP)
- Wishlist button
- Share button
- Compare-at price / savings percentage (the main PDP already shows this)
- Variant selector (let the customer use the full-size selectors in the main PDP)
Do include:
- Product title (truncated)
- Current price (synced with variant selection)
- Add to Cart button
That is it. The bar has one purpose: convert the scroll into a purchase.
Measuring the Impact
After deploying, set up these measurements:
Microsoft Clarity or Hotjar. Compare mobile scroll depth before and after. The sticky bar will not change scroll behavior itself, but it should increase the percentage of sessions that result in an ATC event at shallower scroll depths.
GA4 custom event. Fire a separate event when the sticky bar button is clicked (vs the main ATC button) so you can measure how much of your add-to-cart volume comes from the sticky bar:
stickyButton.addEventListener('click', function() {
if (typeof gtag === 'function') {
gtag('event', 'sticky_atc_click', {
event_category: 'CRO',
event_label: 'mobile_sticky_bar'
});
}
mainButton.click();
});
Shopify Analytics. Compare mobile ATC rate and conversion rate in the 2 weeks before vs 2 weeks after deployment.
Target benchmarks:
- Mobile ATC rate increase: 8-15%
- Mobile conversion rate increase: 5-10%
- Sticky bar click share: 20-40% of all mobile ATCs (meaning it is capturing sales that would otherwise have been lost)
Sticky ATC Is Not Enough on Its Own
The sticky bar solves the visibility problem. But if the rest of your mobile PDP has friction, the bar just makes a broken experience slightly more accessible.
Common issues that compound with the sticky bar problem:
Product images taking up 70%+ of mobile viewport. Constrain the first image height on mobile. I use max-height: min(55vw, 280px) with object-fit: contain to keep images proportional but not overwhelming. On a 390px wide iPhone, this saves roughly 220px of scroll depth.
Cookie consent modals covering 60% of the screen. Switch to a small bottom bar notice instead of a full-screen modal. On one store, Clarity showed that 56% of ALL mobile taps were spent dismissing cookie consent and email popups instead of shopping.
No trust signals before the ATC button. If the only trust content is below the fold, customers scroll past the ATC without confidence. Move 2-3 key trust signals (shipping info, return policy, payment security) above the main ATC button.
Dual equally-weighted CTAs. Some themes show both “Add to Cart” and “Buy Now” as identical black buttons. For considered purchases (anything above $100), dual CTAs cause decision paralysis. Demote one. A single clear primary CTA always outperforms two competing buttons.
For the complete mobile CRO playbook covering all of these issues, read my Shopify Mobile CRO guide. For checkout-specific optimizations after the ATC, see the Shopify Checkout Optimization guide.