Shopify sticky add to cart: what it changes, and what it does not
On most themes the button is below the fold at 390 pixels wide. How to measure it in five minutes, the ten-minute fix, and the theme-code and app options.
12 min read
Contents — 7 sections
- The button is below the fold because the image is as wide as the phone
- Measure it at 390 pixels wide, on the product that takes the traffic
- What a sticky add to cart changes, and what it does not
- The ten-minute fix is above the button, not below it
- In theme code, the bar submits the form the page already has
- An app is an app embed block, and it costs what every app costs
- Fix it in this order, and it takes an afternoon at most
The button is below the fold because the image is as wide as the phone
On a phone, a product page is a stack. The theme puts the gallery first and the buying controls under it, because on a desktop the two sit side by side, and a stack is what that layout becomes when the viewport is 390 pixels wide instead of 1,440.
The arithmetic is unforgiving. At 390 pixels wide, a square product image is 390 pixels tall, and a portrait image at four by five is 488. Above it sits the header. Below it, in order, come the title, the price, a vendor line, a row of review stars, a variant picker with one or two options and a quantity stepper — and only then the add to cart button. Add those rows on top of a 390 or 488 pixel image, and the button starts below the bottom of an 844 pixel viewport before any of them has been generous with its padding.
It is not a theme defect. It is what a two-column layout does when it folds, and it is the finding we write down most often when we walk a store's mobile path: the one thing the page exists to make possible is not on the first screen, and once the reader has scrolled to the reviews to make up their mind, it is not on that screen either.
A real phone is worse than the number suggests. The 844 is the screen; the browser's toolbars take their share of it first, and they are showing when the page loads and only retract as the reader scrolls. That is the reason CSS grew the svh, lvh and dvh units — the small, large and dynamic viewports, which differ by the height of those toolbars, as web.dev's note on viewport units sets out.
Measure it at 390 pixels wide, on the product that takes the traffic
Open Chrome DevTools and press the device toolbar toggle. In the Dimensions list choose Responsive and type 390 into the width box and 844 into the height box — the device mode reference walks through the controls if the toolbar is new to you. Load your best-selling product page, not the homepage. The homepage is the page you have looked at most and the one your buyers look at least.
Two questions, both strict. Is the add to cart button visible without scrolling? Scroll down to the reviews and try to buy — can you, without scrolling back up?
Then get a number rather than an impression. In the Console:
document.querySelector('.shopify-product-form [type="submit"]').getBoundingClientRect().top
Every theme that builds its buy box with Liquid's form 'product' tag gets a form with the class shopify-product-form and an action of /cart/add — the form tag reference shows the exact markup it emits. The number that comes back is the distance in pixels from the top of the viewport to the top of the button, at the top of the page. Larger than 844 and the button is below the fold on an iPhone-class screen; larger than 844 by a few hundred and it is below the second screen too. If the line returns an error, the theme has built its form some other way, and the Elements panel will find the button in a minute.
Write the number down. Everything that follows is measured against it, and a fix you cannot measure is a fix you will argue about later.
What a sticky add to cart changes, and what it does not
What it changes is the distance between the place the decision happens and the place the button is. The content that settles a purchase sits low on a product page — shipping cost, the returns window, the size guide, the reviews — and a reader who has scrolled to the reviews and decided is at that moment as far from the button as the page allows. A bar pinned to the bottom of the screen, carrying the price and the button, means acting where the deciding happened.
What it does not change is longer.
- The first screen. A correctly built bar appears only once the primary button has scrolled out of view, so it does nothing for the reader who has not scrolled yet. The first screen is a separate fix, and it comes first in the next section.
- The price, and when the shipping cost appears. A button that is easier to reach does not make the offer easier to accept. If shipping is first revealed at checkout, that is the objection, and the bar has moved the button without moving the objection.
- Speed. A product page with a slow largest contentful paint is slow with a sticky bar on it. Google's threshold for a good LCP is 2.5 seconds or less, per web.dev, and no amount of button placement changes when the hero image arrives.
- A variant picker that needs three taps. The bar submits whatever the picker has chosen. If choosing is the hard part, the bar inherits it.
And one number we will not give you: a conversion lift. We have not read one that would survive its own footnotes, and the only figure that matters is yours — mobile add-to-cart rate on that product before and after, from your own analytics, over a period long enough to include a weekend. Anyone quoting a percentage without showing the test is selling the bar, not the result.
The ten-minute fix is above the button, not below it
Before you build or install anything, move the button up.
On an Online Store 2.0 theme the product section is made of blocks, and blocks are exactly the thing the theme editor lets a merchant add, remove and reorder without touching code — the sections reference is explicit about it. Online Store, Themes, Customize, open the product template, select the main product section. Drag the buy buttons block up so it sits directly under the price. Move the description, the collapsible rows, the share link, the SKU and the vendor line below it.
Then remove rather than shrink. A vendor line nobody reads, a share row nobody taps, a second badge strip — every row you delete above the button is that row's full height closer to the fold. Re-run the console line and write the new number next to the old one. On most themes this alone brings the button onto the first screen for a square gallery, and close to it for a portrait one.
If the editor will not let you reorder, the theme renders that section statically — the same reference notes that statically rendered sections cannot be removed or reordered by merchants — and the block order is a theme-code change. That is the next section either way.
In theme code, the bar submits the form the page already has
The mistake to avoid is building a second form. Two product forms are two opinions about which variant is selected. The variant picker writes its choice into the primary form's name="id" input — the variants guide shows the select doing exactly that — and a bar with its own copy of that input does not hear about the change. The symptom is specific and expensive: the reader picks Large, taps the bar, and finds Small in the cart.
HTML already has the fix. A button's form attribute associates it with a form anywhere in the document, not only one it sits inside, per the MDN button reference. So the bar carries a submit button that points at the primary form, and there is one source of truth for the variant. The form 'product' tag names its form product_form_ followed by the product id unless the theme passes its own id; read the real one off the rendered form in the Elements panel rather than guessing.
{%- comment -%} snippets/sticky-add-to-cart.liquid {%- endcomment -%}
{%- assign current = product.selected_or_first_available_variant -%}
<div class="sticky-atc" data-sticky-atc hidden>
<span class="sticky-atc__title">{{ product.title }}</span>
<span class="sticky-atc__price" data-sticky-price>{{ current.price | money }}</span>
<button type="submit" name="add" form="product_form_{{ product.id }}" class="button"{% unless current.available %} disabled{% endunless %}>
{%- if current.available -%}Add to cart{%- else -%}Sold out{%- endif -%}
</button>
</div>
Show it only when the primary button is off screen, so the reader never sees two. An IntersectionObserver reports when an element enters or leaves the viewport without a scroll listener — the MDN reference covers the interface — and four lines are enough:
const primary = document.querySelector('.shopify-product-form [type="submit"]');
const bar = document.querySelector('[data-sticky-atc]');
if (primary && bar) {
new IntersectionObserver(([entry]) => { bar.hidden = entry.isIntersecting; }).observe(primary);
}
Three details decide whether it works on a phone rather than in DevTools.
The bottom edge. Pin it with position: fixed; bottom: 0, and pad the bottom with env(safe-area-inset-bottom). On an iPhone that value is the strip along the bottom edge that the hardware would otherwise obscure; on a desktop it is zero, as the MDN env() reference states, so the same rule serves both. Do not size anything in the bar with 100vh; the toolbars make that number wrong on the device that matters.
Layout shift. A fixed bar overlaid on the page moves nothing, and cumulative layout shift counts only existing elements changing their start position — web.dev's CLS article is precise on that. So the bar itself is free. What is not free is pushing the page up after load so the footer's last link is not permanently covered: set padding-bottom on the body in the stylesheet, at the bar's height, from the first paint rather than from a script. If the bar slides in, move it with transform: translateY() and not by animating bottom, for the same reason. The threshold is 0.1, and a bar that costs you it has cost more than it earns.
The button itself. Make it a full 48 pixels tall — the web.dev tap target guidance puts the recommended minimum at 48 device-independent pixels, and the hard floor of 24 by 24 CSS pixels is both WCAG 2.5.8 and a Theme Store requirement. Give it visible text, "Add to cart", not an icon. Disable it and change the label when the selected variant is unavailable, which is how the same requirements describe the primary button. And keep the price in the bar honest: the requirements also expect a callback that updates the price for the selected variant, so listen for the primary form's change event, re-read the selected variant and rewrite the price element, and a reader who switched to a dearer variant is not shown the old price at the moment of tapping.
Put a checkbox in the section schema so the bar can be switched off in the theme editor without a deploy. A developer who knows the theme needs an afternoon for all of this; the variant sync and the safe area are where the time goes, and they are the two things an untested bar gets wrong.
An app is an app embed block, and it costs what every app costs
An app does the same job by a mechanism Shopify defines for it. Apps that add floating or overlaid elements extend a theme through app embed blocks, which Shopify injects before the closing head and body tags of the pages they target — the theme app extension configuration reference sets out both the mechanism and the rule that embeds are switched off by default after install. You turn one on in the theme editor under Theme settings, App embeds. If a sticky bar app is installed and nothing has appeared, that switch is usually why.
The cost is the cost of any app. Shopify's own performance guidance states it plainly: JavaScript from installed apps runs on the main thread and competes with the browser's ability to render content and respond to input, and the instruction is to audit every script, apps included, and remove anything that is not earning its performance cost. So measure it. Open the Network panel on the product page, filter to JS, and note the app's file and its size before and after the embed is switched on. Then load the homepage and check whether the same file loads there, where a sticky add to cart has nothing to do. The removal order for scripts that fail that test is in why your Shopify store is slow.
Test an app's bar exactly as you would test your own: pick a variant that is not the default, tap the bar, open the cart and read the line item. Pick a sold-out variant and check the bar disables. Load the page on the phone and check the bar clears the home indicator. An app has to read the theme's variant picker from the outside, and whether it does so correctly on your theme is not something its listing can tell you.
Removing one is two steps: switch the embed off, then uninstall the app. The theme's settings_data.json keeps a record of the embed with disabled set to true, which the same configuration reference documents and which is harmless. Compare the Network panel again to confirm the script is gone, because an app that leaves a script behind is the commonest way a store ends up carrying code for things it no longer uses.
Which to choose is not close. If a developer owns the theme, the code path gives you one form, no extra script and a bar that matches the design. If nobody does, an app with a clean Network panel is a reasonable trade, provided you ran the checks above. Either way, re-run PageSpeed Insights on the product page afterwards. The score should not fall, layout shift should stay under 0.1, and the tap on the bar's button — the interaction that opens the cart drawer — should come in under the 200 millisecond threshold for a good interaction to next paint that web.dev sets for INP. If the drawer needs fresh markup, the sections parameter on /cart/add.js returns it in the same request rather than a second one, per the Ajax cart reference, which is the difference between one round trip and two on a phone.
Fix it in this order, and it takes an afternoon at most
First, measure: 390 by 844, the console line, the number written down. Five minutes.
Second, reorder: the buy buttons block up under the price, the rows above it removed, the number measured again. Ten minutes, no code, and on most themes it does more for the first screen than the bar will.
Third, the bar: an hour with an app, an afternoon in theme code. Test the variant, the sold-out state, the safe area and the layout shift before anyone else sees it.
Fourth, PageSpeed Insights on the same product page, mobile: CLS under 0.1, INP under 200 milliseconds on the tap, and no drop in the score you started with.
This is one line in the mobile buying path of the store audit checklist. It has a page of its own because it is the line that fails most often. If you would rather have the whole mobile path walked and ranked against the rest of the store, that is the Store Teardown: €750, five days, and the document is yours whatever you decide to do with it.