Free code · No app required
A sticky add-to-cart bar is one section file. This one appears the moment your product page’s real button scrolls out of view, shows the variant the shopper actually selected with its live price, and adds to the cart without a page reload. It reads your theme’s own product form rather than guessing at class names, so it runs unchanged on Dawn, Horizon and anything else Online Store 2.0 — and because the bar is a genuine {% form 'product' %}, it still works with JavaScript switched off. Every file below passes Shopify’s own Theme Check.
The bar deliberately owns one job: get a shopper who has read to the bottom of a long product page back to a buy button without scrolling. Everything it refuses to do is listed on the right, because a sticky bar that quietly fails at checkout is worse than no bar.
| Handled here | Needs a different build |
|---|---|
| Reveals itself when the real Add to cart button leaves the viewport | Showing on collection or home pages — it reads the product form, which only exists on a product page |
| Adds the variant the shopper selected, on any Online Store 2.0 theme | Opening your theme's cart drawer afterwards — every theme names that differently, so the bar links to the cart |
| Still adds to the cart with JavaScript disabled or broken | Carrying line item properties — engraving, gift notes, uploads. The bar posts variant and quantity only |
| Live price and sold-out state as the shopper switches variants | Products with more than 100 variants, which show the button without a live price by design |
| Hidden means hidden: no keyboard or screen-reader access to an off-screen bar | Quantity breaks, stock-urgency counters, countdown timers or an upsell row inside the bar |
The right-hand column is not a list of things that are hard. It is a list of things that need state the bar does not have — your drawer's animation, your pricing rules, your per-market logic. That is the point where paying someone starts to make sense, and everything on the left is free and on this page.
One file. Shopify bundles the stylesheet and javascript blocks for you, so there is nothing to add to theme.liquid and no asset files to create.
sections/sticky-add-to-cart.liquidThe whole thing in one file — markup, styles and behaviour. Shopify bundles the {% stylesheet %} and {% javascript %} blocks for you, so there are no asset files to create and no edits to theme.liquid.
{%- liquid
assign current_variant = product.selected_or_first_available_variant
assign expose_variants = false
if section.settings.show_price and product.variants_count <= 100
assign expose_variants = true
endif
-%}
{%- if product != blank -%}
{%- capture form_id -%}StickyAtcForm-{{ section.id }}{%- endcapture -%}
<sticky-add-to-cart
id="StickyAtc-{{ section.id }}"
class="sticky-atc sticky-atc--{{ section.settings.position }} sticky-atc--on-{{ section.settings.visible_on }}"
data-cart-url="{{ routes.cart_url }}"
data-add-url="{{ routes.cart_add_url }}"
data-after-add="{{ section.settings.after_add }}"
data-added-label="{{ section.settings.added_label | escape }}"
data-sold-out-label="{{ section.settings.sold_out_label | escape }}"
data-button-label="{{ section.settings.button_label | escape }}"
data-error-label="{{ section.settings.error_label | escape }}"
style="--sticky-atc-bg: {{ section.settings.background }}; --sticky-atc-fg: {{ section.settings.text_color }}; --sticky-atc-button-bg: {{ section.settings.button_background }}; --sticky-atc-button-fg: {{ section.settings.button_text_color }};"
>
<div class="sticky-atc__inner">
{%- if section.settings.show_image and product.featured_image != blank -%}
{{
product.featured_image
| image_url: width: 128
| image_tag: width: 64, height: 64, loading: 'lazy', class: 'sticky-atc__image', alt: product.featured_image.alt
}}
{%- endif -%}
<div class="sticky-atc__info">
<p class="sticky-atc__title">{{ product.title }}</p>
{%- if section.settings.show_price -%}
<p class="sticky-atc__price" data-sticky-price>{{ current_variant.price | money | strip_html }}</p>
{%- endif -%}
</div>
{%- form 'product', product, id: form_id, class: 'sticky-atc__form', novalidate: 'novalidate' -%}
<input type="hidden" name="id" value="{{ current_variant.id }}" data-sticky-variant-id>
{%- if section.settings.show_quantity -%}
<label class="sticky-atc__visually-hidden" for="StickyAtcQty-{{ section.id }}">
{{ section.settings.quantity_label }}
</label>
<input
class="sticky-atc__quantity"
id="StickyAtcQty-{{ section.id }}"
type="number"
name="quantity"
value="1"
min="1"
step="1"
inputmode="numeric"
>
{%- endif -%}
<button
type="submit"
name="add"
class="sticky-atc__button"
data-sticky-submit
{% unless current_variant.available %}
disabled
{% endunless %}
>
<span data-sticky-button-label>
{%- if current_variant.available -%}
{{ section.settings.button_label }}
{%- else -%}
{{ section.settings.sold_out_label }}
{%- endif -%}
</span>
</button>
{%- endform -%}
</div>
<p class="sticky-atc__status" data-sticky-status role="status" aria-live="polite"></p>
{%- if expose_variants -%}
<script type="application/json" data-sticky-variants>
[
{%- for variant in product.variants -%}
{
"id": {{ variant.id }},
"available": {{ variant.available }},
"price": {{ variant.price }},
"money": {{ variant.price | money | strip_html | json }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
]
</script>
{%- endif -%}
</sticky-add-to-cart>
{%- endif -%}
{% stylesheet %}
.sticky-atc {
position: fixed;
left: 0;
right: 0;
z-index: 40;
display: block;
background: var(--sticky-atc-bg, #ffffff);
color: var(--sticky-atc-fg, #121212);
transform: translateY(110%);
visibility: hidden;
transition: transform 0.25s ease, visibility 0.25s ease;
}
.sticky-atc--bottom {
bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.14);
}
.sticky-atc--top {
top: 0;
transform: translateY(-110%);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.14);
}
.sticky-atc.is-revealed {
transform: translateY(0);
visibility: visible;
}
.sticky-atc--on-mobile {
display: none;
}
.sticky-atc--on-desktop {
display: none;
}
.sticky-atc__inner {
display: flex;
align-items: center;
gap: 0.75rem;
max-width: 1200px;
margin-inline: auto;
padding: 0.6rem 1rem;
}
.sticky-atc__image {
flex: none;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
}
.sticky-atc__info {
flex: 1 1 auto;
min-width: 0;
}
.sticky-atc__title {
margin: 0;
font-size: 0.9rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sticky-atc__price {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.35;
}
.sticky-atc__form {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
flex: none;
}
.sticky-atc__quantity {
width: 4rem;
min-height: 44px;
padding: 0.4rem 0.5rem;
color: inherit;
background: transparent;
border: 1px solid currentColor;
border-radius: 6px;
font: inherit;
text-align: center;
}
.sticky-atc__button {
min-height: 44px;
padding: 0.6rem 1.4rem;
background: var(--sticky-atc-button-bg, #121212);
color: var(--sticky-atc-button-fg, #ffffff);
border: 0;
border-radius: 6px;
font: inherit;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
.sticky-atc__button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.sticky-atc__button[aria-busy='true'] {
opacity: 0.75;
cursor: progress;
}
.sticky-atc__status {
margin: 0;
padding: 0 1rem 0.5rem;
font-size: 0.82rem;
text-align: center;
}
.sticky-atc__status:empty {
display: none;
}
.sticky-atc__visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media screen and (max-width: 749px) {
.sticky-atc--on-mobile,
.sticky-atc--on-both {
display: block;
}
.sticky-atc__title {
font-size: 0.82rem;
}
.sticky-atc__button {
padding: 0.6rem 1rem;
}
}
@media screen and (min-width: 750px) {
.sticky-atc--on-desktop,
.sticky-atc--on-both {
display: block;
}
.sticky-atc__image {
width: 56px;
height: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.sticky-atc {
transition: none;
}
}
{% endstylesheet %}
{% javascript %}
class StickyAddToCart extends HTMLElement {
connectedCallback() {
this.form = this.querySelector('.sticky-atc__form');
this.variantInput = this.querySelector('[data-sticky-variant-id]');
this.priceEl = this.querySelector('[data-sticky-price]');
this.labelEl = this.querySelector('[data-sticky-button-label]');
this.button = this.querySelector('[data-sticky-submit]');
this.statusEl = this.querySelector('[data-sticky-status]');
this.variants = this.readVariants();
this.sourceForm = this.findSourceForm();
this.watchSourceForm();
this.watchVisibility();
this.onSubmit = this.handleSubmit.bind(this);
this.form.addEventListener('submit', this.onSubmit);
}
disconnectedCallback() {
if (this.revealObserver) this.revealObserver.disconnect();
if (this.formObserver) this.formObserver.disconnect();
if (this.onSourceChange) document.removeEventListener('change', this.onSourceChange);
if (this.onSubmit) this.form.removeEventListener('submit', this.onSubmit);
}
readVariants() {
const node = this.querySelector('[data-sticky-variants]');
if (!node) return null;
try {
return JSON.parse(node.textContent);
} catch (error) {
return null;
}
}
// The product page's real add-to-cart form. Every theme builds it with the
// product form tag, which always renders action="/cart/add", so this finds
// it without knowing anything about the theme's class names.
findSourceForm() {
const forms = Array.from(document.querySelectorAll('form[action*="/cart/add"]'));
return forms.find((form) => !this.contains(form)) || null;
}
// Reveal the bar once the real button has scrolled out of view, so the two
// are never on screen at the same time. Falls back to a scroll threshold
// where there is no form to watch.
watchVisibility() {
const target = this.sourceForm || null;
if (!target || typeof IntersectionObserver === 'undefined') {
this.fallbackReveal();
return;
}
this.revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
this.classList.toggle('is-revealed', !entry.isIntersecting);
});
},
{ rootMargin: '0px', threshold: 0 }
);
this.revealObserver.observe(target);
}
fallbackReveal() {
const onScroll = () => {
this.classList.toggle('is-revealed', window.scrollY > window.innerHeight * 0.6);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
// Keep the hidden variant id in step with whatever the shopper picked in the
// real form. Three signals, because themes differ: a change event from the
// picker, a re-render of the form markup by the Section Rendering API, and
// the ?variant= parameter when the theme pushes history state.
watchSourceForm() {
if (!this.sourceForm) return;
this.onSourceChange = () => {
window.requestAnimationFrame(() => this.syncVariant());
};
document.addEventListener('change', this.onSourceChange);
window.addEventListener('popstate', this.onSourceChange);
this.formObserver = new MutationObserver(this.onSourceChange);
this.formObserver.observe(this.sourceForm, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['value'],
});
this.syncVariant();
}
currentSourceVariantId() {
const input = this.sourceForm && this.sourceForm.querySelector('[name="id"]');
if (input && input.value) return String(input.value);
const fromUrl = new URLSearchParams(window.location.search).get('variant');
return fromUrl ? String(fromUrl) : null;
}
syncVariant() {
const id = this.currentSourceVariantId();
if (!id || id === this.variantInput.value) return;
this.variantInput.value = id;
this.clearStatus();
if (!this.variants) return;
const match = this.variants.find((variant) => String(variant.id) === id);
if (!match) return;
if (this.priceEl) this.priceEl.textContent = match.money;
this.button.disabled = !match.available;
if (this.labelEl) {
this.labelEl.textContent = match.available
? this.dataset.buttonLabel
: this.dataset.soldOutLabel;
}
}
clearStatus() {
if (this.statusEl) this.statusEl.textContent = '';
}
setStatus(message) {
if (this.statusEl) this.statusEl.textContent = message;
}
async handleSubmit(event) {
event.preventDefault();
if (this.button.getAttribute('aria-busy') === 'true') return;
this.button.setAttribute('aria-busy', 'true');
this.clearStatus();
const body = new FormData(this.form);
try {
const response = await fetch(this.dataset.addUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
});
if (!response.ok) throw new Error(String(response.status));
if (this.dataset.afterAdd === 'cart') {
window.location.href = this.dataset.cartUrl;
return;
}
this.setStatus(this.dataset.addedLabel);
// Themes that refresh their drawer on a document event pick this up;
// the rest still have the cart link in the status message.
document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
} catch (error) {
// The form is a real product form, so letting the browser submit it is
// a working fallback rather than a dead end.
this.setStatus(this.dataset.errorLabel);
this.form.submit();
return;
} finally {
this.button.removeAttribute('aria-busy');
}
}
}
if (!customElements.get('sticky-add-to-cart')) {
customElements.define('sticky-add-to-cart', StickyAddToCart);
}
{% endjavascript %}
{% schema %}
{
"name": "Sticky add to cart",
"tag": "div",
"class": "sticky-atc-section",
"settings": [
{
"type": "paragraph",
"content": "Appears once the product page's own Add to cart button scrolls out of view. Works on any Online Store 2.0 theme."
},
{
"type": "select",
"id": "visible_on",
"label": "Show on",
"default": "both",
"options": [
{ "value": "both", "label": "Mobile and desktop" },
{ "value": "mobile", "label": "Mobile only" },
{ "value": "desktop", "label": "Desktop only" }
]
},
{
"type": "select",
"id": "position",
"label": "Position",
"default": "bottom",
"options": [
{ "value": "bottom", "label": "Bottom of the screen" },
{ "value": "top", "label": "Top of the screen" }
]
},
{
"type": "checkbox",
"id": "show_image",
"label": "Show product image",
"default": true
},
{
"type": "checkbox",
"id": "show_price",
"label": "Show price",
"default": true
},
{
"type": "checkbox",
"id": "show_quantity",
"label": "Show quantity field",
"default": false
},
{
"type": "header",
"content": "Labels"
},
{
"type": "text",
"id": "button_label",
"label": "Button label",
"default": "Add to cart"
},
{
"type": "text",
"id": "sold_out_label",
"label": "Sold out label",
"default": "Sold out"
},
{
"type": "text",
"id": "added_label",
"label": "Confirmation message",
"default": "Added to your cart."
},
{
"type": "text",
"id": "error_label",
"label": "Error message",
"default": "Could not add that. Taking you to the cart."
},
{
"type": "text",
"id": "quantity_label",
"label": "Quantity field label",
"default": "Quantity"
},
{
"type": "select",
"id": "after_add",
"label": "After adding to cart",
"default": "stay",
"options": [
{ "value": "stay", "label": "Stay on the page" },
{ "value": "cart", "label": "Go to the cart" }
]
},
{
"type": "header",
"content": "Colours"
},
{
"type": "color",
"id": "background",
"label": "Bar background",
"default": "#ffffff"
},
{
"type": "color",
"id": "text_color",
"label": "Bar text",
"default": "#121212"
},
{
"type": "color",
"id": "button_background",
"label": "Button background",
"default": "#121212"
},
{
"type": "color",
"id": "button_text_color",
"label": "Button text",
"default": "#ffffff"
}
],
"presets": [
{
"name": "Sticky add to cart"
}
],
"enabled_on": {
"templates": ["product"]
}
}
{% endschema %}
The file above, already in its correct theme folder, plus a README with the install steps. Unzip it straight into your theme instead of copying the block by hand.
One download with every file in its correct theme folder. We'll also email you when the next free guide ships — one click to unsubscribe, and we never pass your address on. The code above stays free whether you enter an email or not.
Online Store → Themes → the three dots on your live theme → Duplicate. Everything below is a new file rather than an edit to an existing one, so the risk is low, but a duplicate costs nothing and means a bad paste is one click away from being undone. Work on the duplicate and publish it once you are happy.
On the duplicated theme choose Edit code. Under Sections click Add a new section, name it sticky-add-to-cart, and replace everything Shopify puts in the new file with the section above. Save. Nothing is visible on your storefront yet — a section does nothing until a template uses it.
Open Customize, switch the dropdown at the top from Home page to Products → Default product, then click Add section at the bottom of the left panel. Sticky add to cart appears in the list because the section declares a preset. The schema also sets enabled_on templates product, so the section will not offer itself on pages where there is no product form for it to read.
Open a product with two or more variants and switch between them. The price and the sold-out state in the bar should follow your selection, because the bar reads the hidden variant input in your theme's own form. If the price never changes, your theme updates that input in a way the three watchers do not see — add the product's own form container to the bar's MutationObserver and it will.
The bar is position: fixed at z-index 40. If you already run a cookie banner, a chat bubble or a sticky announcement bar, open the page on a real phone and look for an overlap. Either raise the other element above 40 or switch Position to the top of the screen in the theme editor. Also confirm the bar clears the iOS home indicator — it reserves space with env(safe-area-inset-bottom).
Sometimes, and the honest answer depends on how long your product page is. The bar earns its place when the add-to-cart button sits above a long description, a size chart, reviews and related products — on those pages a shopper who has read to the bottom has to scroll back up to buy, and a share of them simply do not. On a short page where the button is almost always in view, the bar adds a permanent strip of chrome and wins nothing. Before installing it, open your own product page on a phone and scroll: if the button disappears for more than a screen or two of reading, the bar has a job to do. Treat any specific percentage you read online with suspicion. The uplift depends on your traffic, your page and your price point, and the only figure that means anything is one from your own test.
Yes, and on Dawn, Craft, Refresh, Sense and any other Online Store 2.0 theme. The section does not reference a single theme-specific class name. The one thing it needs from your theme is the product form, and that is not really optional: Shopify's product form tag always renders a form whose action is /cart/add containing a hidden input named id. The section finds that form, watches it for the variant the shopper selected, and mirrors it. Horizon's newer block-based architecture does not change this, because the form tag output is the same. Two practical notes. If your theme renders more than one add-to-cart form on a product page, as some quick-add grids do, the section uses the first one that is not its own. And if you have a fully custom product page that posts to the Cart AJAX API with no form element at all, the bar has nothing to read and stays hidden.
It reads it out of your theme's own form rather than tracking variant selection itself, which is what makes it theme-agnostic. Every Shopify product form contains a hidden input named id whose value is the currently selected variant. The section watches that input through three signals, because themes update it in three different ways: a change event from the variant picker, a wholesale replacement of the form markup when the theme re-renders through the Section Rendering API, and the ?variant= parameter when the theme pushes browser history. Whichever path your theme takes, the hidden input inside the sticky bar is kept in step. One limit follows from that design. The bar posts its own small form carrying the variant and the quantity, so it does not carry line item properties such as engraving text or a gift message. If your product collects those, leave the bar's quantity field off and let the main form handle the add.
Barely, and the places where it could have been slow are the places the code deliberately avoids. There is no library, no external request and no polling. The CSS and JavaScript live in the section's own stylesheet and javascript tags, which Shopify bundles into files your theme already loads, so installing it adds no new network request. The reveal uses an IntersectionObserver watching one element instead of a scroll handler running on every frame. The one real cost is the variant price list, printed into the page as JSON so the price can update without a round trip — and that is exactly why the section stops printing it above 100 variants. A product with 250 variants would add several kilobytes to every page view to save a single fetch, which is a bad trade. Past that limit the bar shows the button without a live price.
It can, and two collisions are worth checking. The first is vertical space: if you already run a sticky announcement bar, a cookie banner or a chat bubble pinned to the bottom of the screen, two fixed elements will sit on top of each other. The section uses z-index 40 and sits flush to the bottom, so either raise your other element above it or switch the bar to the top position. The second is the add itself. The bar posts to Shopify's /cart/add endpoint and then fires a cart:refresh event on the document. Themes in the Dawn family and several apps listen for that and refresh their drawer; themes that do not will show a stale cart count until the next page load. If that matters, set After adding to cart to Go to the cart — that always reflects reality, at the cost of leaving the page.
The section above is a normal sticky add-to-cart bar and it will serve most stores unchanged. Teams come to us when the bar has to do more than repeat one button: pick variants without scrolling back, show tiered or quantity-break pricing as the shopper changes quantity, open their own cart drawer with the right animation instead of linking away, or behave differently for a market or a logged-in wholesale customer.
We build Shopify themes and apps, and we have shipped three of our own to the Shopify App Store. Tell us what you are trying to do and our team will go through the requirement with you — what it actually involves, what you do not need, and the simplest way to get there. This is paid work, and we will set it up at the lowest cost it can be done properly for. You will know the price before anything starts.
Reply within a few hours · We go through the requirement with you first