Shopify Tutorial · Dawn Theme · No App

Sticky Add to Cart Bar in Shopify — without any app

Want a sticky add-to-cart bar that slides up when the main button scrolls out of view — with animated trust badges, variant sync, and a bag icon — without paying for an app? In this tutorial I'll show you exactly how to do it using Liquid, CSS, and a tiny bit of JavaScript. Tested on the Dawn theme.

Why this matters for your store

A sticky ATC bar is one of the highest-impact product page changes you can make.

Video Tutorial
Step-by-step guide
01
Create snippet: sticky-add-to-cart.liquid

Create another snippet named sticky-add-to-cart. This is the main file — it contains all the CSS, the Liquid inventory check, the HTML bar, and the JavaScript for visibility and variant sync.

snippets/sticky-add-to-cart.liquid — CSS
<style>
  .sticky-add-to-cart {
    background: #fff;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 1000;
    padding: 10px 0;
    border-top: 0.5px solid rgba(0,0,0,0.1);
    box-shadow: 0 -4px 24px rgba(0,0,0,0.07);
    border-radius: 12px 12px 0 0;
    transform: translateY(110%);
    opacity: 0;
    pointer-events: none;
    transition: transform 0.38s cubic-bezier(0.32, 0.72, 0, 1),
                opacity 0.25s ease;
  }

  .sticky-add-to-cart.is-visible {
    transform: translateY(0);
    opacity: 1;
    pointer-events: auto;
  }

  .sticky-add-to-cart-content {
    display: flex;
    align-items: center;
    gap: 16px;
  }

  .sticky-add-to-cart__img {
    width: 48px;
    height: 48px;
    border-radius: 8px;
    border: 0.5px solid rgba(0,0,0,0.08);
    object-fit: cover;
    flex-shrink: 0;
  }

  .sticky-add-to-cart__info {
    flex: 1;
    min-width: 0;
  }

  .sticky-add-to-cart .product__title {
    margin-bottom: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .sticky-add-to-cart .product__title h3 {
    font-size: 14px !important;
    margin: 0 0 2px !important;
  }

  .sticky-add-to-cart .price {
    font-size: 14px !important;
  }

  .sticky-add-to-cart__trust-wrap {
    position: relative;
    flex-shrink: 0;
    width: 182px;
    height: 26px;
    overflow: hidden;
  }

  .sticky-add-to-cart__trust-wrap .sticky-add-to-cart__trust {
    position: absolute;
    inset: 0;
    opacity: 0;
    animation: atc-trust-cycle 20s ease infinite;
  }

  .sticky-add-to-cart__trust-wrap .sticky-add-to-cart__trust:nth-child(1) { animation-delay:  0s; }
  .sticky-add-to-cart__trust-wrap .sticky-add-to-cart__trust:nth-child(2) { animation-delay:  4s; }
  .sticky-add-to-cart__trust-wrap .sticky-add-to-cart__trust:nth-child(3) { animation-delay:  8s; }
  .sticky-add-to-cart__trust-wrap .sticky-add-to-cart__trust:nth-child(4) { animation-delay: 12s; }
  .sticky-add-to-cart__trust-wrap .sticky-add-to-cart__trust:nth-child(5) { animation-delay: 16s; }

  @keyframes atc-trust-cycle {
    0%   { opacity: 0; }
    2%   { opacity: 1; }
    18%  { opacity: 1; }
    20%  { opacity: 0; }
    100% { opacity: 0; }
  }

  .sticky-add-to-cart__trust {
    font-size: 11px;
    font-weight: 500;
    color: #1a7a45;
    white-space: nowrap;
    display: flex;
    align-items: center;
    gap: 6px;
    flex-shrink: 0;
    background: #f0faf4;
    border: 1px solid #c3e6d0;
    border-radius: 20px;
    padding: 4px 10px 4px 7px;
  }

  .sticky-add-to-cart__trust svg { width: 14px; height: 14px; flex-shrink: 0; }

  .sticky-add-to-cart__trust.is-urgent {
    color: #b91c1c;
    background: #fff5f5;
    border-color: #fecaca;
  }

  .sticky-atc-pulse {
    display: inline-block;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: #ef4444;
    flex-shrink: 0;
    animation: atcPulse 1.4s ease infinite;
  }

  @keyframes atcPulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50%      { opacity: 0.5; transform: scale(0.7); }
  }

  .sticky-add-to-cart__variant-picker {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-left: auto;
    flex-shrink: 0;
  }

  .sticky-add-to-cart__variant-picker .product-form__input  { margin: 0 !important; }
  .sticky-add-to-cart__variant-picker .form__label          { display: none !important; }
  .sticky-add-to-cart__variant-picker .product-form__submit { margin-bottom: 0 !important; }

  @media (max-width: 749px) {
    .sticky-add-to-cart { padding: 10px 0 16px; }

    .sticky-add-to-cart__trust-wrap { display: none; }

    .sticky-add-to-cart-content {
      flex-direction: column;
      align-items: stretch;
      gap: 10px;
    }

    .sticky-atc-mobile-top {
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .sticky-add-to-cart__img {
      width: 44px;
      height: 44px;
      flex-shrink: 0;
    }

    .sticky-add-to-cart__info {
      flex: 1;
      min-width: 0;
    }

    .sticky-atc-mobile-bottom {
      display: flex;
      align-items: center;
      gap: 8px;
    }

    .sticky-add-to-cart__variant-picker {
      margin-left: 0;
      flex: 1;
      gap: 8px;
    }

    .sticky-add-to-cart__variant-picker select {
      width: 100%;
    }

    .sticky-add-to-cart__variant-picker .product-form__submit {
      flex: 1;
      white-space: nowrap;
    }
  }

  @media (min-width: 750px) {
    .sticky-atc-mobile-top    { display: contents; }
    .sticky-atc-mobile-bottom { display: contents; }
  }
</style>

Next, the Liquid inventory check. This runs on the server and finds the first available variant's stock quantity:

snippets/sticky-add-to-cart.liquid — Liquid inventory
{%- assign sticky_inventory = nil -%}
{%- for variant in product.variants -%}
  {%- if variant.available -%}
    {%- assign sticky_inventory = variant.inventory_quantity -%}
    {%- break -%}
  {%- endif -%}
{%- endfor -%}

Then the HTML. The trust badges are stacked absolutely and animated by CSS — no JavaScript involved:

snippets/sticky-add-to-cart.liquid — HTML
<div class="sticky-add-to-cart" id="stickyATC" aria-hidden="true">
  <div class="page-width">
    <div class="sticky-add-to-cart-content">

      <div class="sticky-atc-mobile-top">
        <img
          class="sticky-add-to-cart__img"
          src="{{ product.featured_media | image_url: width: 150 }}"
          alt="{{ product.featured_media.alt | escape }}"
          width="48"
          height="{{ 48 | divided_by: product.featured_media.preview_image.aspect_ratio }}"
          loading="eager"
        >
        <div class="sticky-add-to-cart__info">
          <div class="product__title" {{ block.shopify_attributes }}>
            <h3 class="h3">{{ product.title | escape }}</h3>
          </div>
          {%- render 'price', product: product, use_variant: true,
            show_badges: true, price_class: 'price--large' -%}
        </div>
      </div>

      {%- if product.available -%}
        <div class="sticky-add-to-cart__trust-wrap" aria-hidden="true">

          {%- if sticky_inventory != nil and sticky_inventory > 0 and sticky_inventory <= 5 -%}
            <div class="sticky-add-to-cart__trust is-urgent">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
              <span><span class="sticky-atc-pulse"></span> Only {{ sticky_inventory }} left</span>
            </div>
          {%- endif -%}

          <div class="sticky-add-to-cart__trust">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
            <span>Order by 3 pm — ships today</span>
          </div>
          <div class="sticky-add-to-cart__trust">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1" y="3" width="15" height="13" rx="1"/><path d="M16 8h4l3 5v3h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>
            <span>Free shipping over $50</span>
          </div>
          <div class="sticky-add-to-cart__trust">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>
            <span>Secure checkout</span>
          </div>
          <div class="sticky-add-to-cart__trust">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.95"/></svg>
            <span>30-day free returns</span>
          </div>

        </div>
      {%- endif -%}

      <div class="sticky-atc-mobile-bottom">
        <div class="sticky-add-to-cart__variant-picker">
          {%- render 'sticky-add-to-cart-variant-picker',
            product: product,
            product_form_id: product_form_id -%}
          {%- render 'buy-buttons',
            block: block,
            product: product,
            product_form_id: product_form_id,
            section_id: section.id,
            show_pickup_availability: false -%}
        </div>
      </div>

    </div>
  </div>
</div>

Finally, the JavaScript. Just two responsibilities — showing/hiding the bar and syncing the sticky select to the main form:

snippets/sticky-add-to-cart.liquid — JavaScript
<script>
(function () {
  const stickyBar   = document.getElementById('stickyATC');
  const mainButtons = document.querySelector('.product-form__buttons');
  if (!stickyBar || !mainButtons) return;

  // 1. VISIBILITY 
  new IntersectionObserver(function (entries) {
    const visible = !entries[0].isIntersecting;
    stickyBar.classList.toggle('is-visible', visible);
    stickyBar.setAttribute('aria-hidden', String(!visible));
  }, { threshold: 0, rootMargin: '0px' }).observe(mainButtons);

  // 2. VARIANT SYNC 
  stickyBar.querySelectorAll('select').forEach(function (sel) {
    console.log('Select:');
    console.log(sel);
    sel.addEventListener('change', function (e) {
      console.log('Target:');
      console.log(e.target.value);
     const target = document.querySelector(`product-info [value="${e.target.value}"]`);
     console.log('target:');
     console.log(target);
     if (target) target.click();
    });
  });
})();
</script>
💡
The IntersectionObserver watches the main .product-form__buttons element. When it leaves the viewport the bar slides up; when it re-enters the bar slides back down. The CSS transition handles the animation — no JS animation needed.
02
Create snippet: sticky-add-to-cart-variant-picker.liquid

Go to Online Store → Themes → Edit code → Snippets → Add a new snippet. Name it sticky-add-to-cart-variant-picker and paste the code below. This renders a dropdown for every option except Color (which is typically shown as swatches on the main form).

snippets/sticky-add-to-cart-variant-picker.liquid
{%- comment -%}
  Renders a variant picker for the sticky ATC bar.
  Skips Color — handled by swatches on the main form.

  Accepts:
  - product: {Object} product object.
  - product_form_id: {String} product form id.

  Usage:
  {% render 'sticky-add-to-cart-variant-picker', product: product, product_form_id: product_form_id %}
{%- endcomment -%}

{%- unless product.has_only_default_variant -%}
  <variant-selects
    id="variant-selects-sticky"
    data-section="sticky"
  >
    {%- for option in product.options_with_values -%}

      {%- unless option.name == 'Color' -%}
        {%- assign picker_type = 'dropdown' -%}
        <div class="product-form__input product-form__input--dropdown">
          <label class="form__label" for="Option-sticky-{{ forloop.index0 }}">
            {{ option.name }}
          </label>
          <div class="select">
            <select
              id="Option-sticky-{{ forloop.index0 }}"
              class="select__select"
              name="options[{{ option.name | escape }}]"
              form="{{ product_form_id }}"
            >
              {%- render 'product-variant-options',
                product: product,
                option: option,
                picker_type: picker_type
              -%}
            </select>
            <span class="svg-wrapper">
              {{- 'icon-caret.svg' | inline_asset_content -}}
            </span>
          </div>
        </div>
      {%- endunless -%}

    {%- endfor -%}

    <script type="application/json" data-selected-variant>
      {{ product.selected_or_first_available_variant | json }}
    </script>
  </variant-selects>
{%- endunless -%}
💡
The id="variant-selects-sticky" avoids a duplicate ID in the DOM since the main form already has variant-selects-{{ section.id }}. The form attribute on the select ties it to the same product_form_id as the main form so the correct variant is submitted.
03
Add bag icon to buy-buttons.liquid

Open snippets/buy-buttons.liquid. Find the submit <button> and add the SVG inline inside the <span> — before the button label text. This adds the bag icon to every add-to-cart button site-wide (PDP, quick-add, and the sticky bar) from one place, with no JavaScript needed.

snippets/buy-buttons.liquid — button with icon
<button
  id="ProductSubmitButton-{{ section_id }}"
  type="submit"
  name="add"
  class="product-form__submit button button--full-width
    {%- if show_dynamic_checkout -%}button--secondary{%- else -%}button--primary{%- endif -%}"
  {% if product.selected_or_first_available_variant.available == false
    or quantity_rule_soldout
    or product.selected_or_first_available_variant == null
  %}disabled{% endif %}
>
  <span>
    <svg style="width:16px;height:16px;vertical-align:middle;margin-right:6px;flex-shrink:0"
      viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"
      aria-hidden="true">
      <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/>
      <line x1="3" y1="6" x2="21" y2="6"/>
      <path d="M16 10a4 4 0 0 1-8 0"/>
    </svg>
    {%- if product.selected_or_first_available_variant == null -%}
      {{ 'products.product.unavailable' | t }}
    {%- elsif product.selected_or_first_available_variant.available == false
      or quantity_rule_soldout -%}
      {{ 'products.product.sold_out' | t }}
    {%- else -%}
      {{ 'products.product.add_to_cart' | t }}
    {%- endif -%}
  </span>
  {%- render 'loading-spinner' -%}
</button>
📍
The SVG uses stroke="currentColor" so it automatically inherits the button's text colour — white on primary, dark on secondary. No extra CSS needed.
04
Render the snippet in theme.liquid

Open layout/theme.liquid. Find the closing </main> tag and add the render call just before it, wrapped in a product page template check so it only loads on product pages:

layout/theme.liquid
<main id="MainContent" class="content-for-layout focus-none" role="main" tabindex="-1">
  {{ content_for_layout }}

  {% if template.name == 'product' %}
    {% render 'sticky-add-to-cart' %}
  {% endif %}
</main>
⚠️
The template.name == 'product' check is important — without it the snippet would try to render on every page but product, block, and product_form_id would be undefined, causing Liquid errors.
05
Sync main form → sticky bar (product-info.js)

When a customer changes a variant on the main form, Dawn fetches updated HTML and re-renders the page. We hook into that to keep the sticky bar's hidden id input in sync. Open assets/product-info.js and add the method below inside the ProductInfo class, then call it from handleUpdateProductInfo:

assets/product-info.js — add this method
handleUpdateStickyAddToCart(variant) {
  if (!variant) return;

  // 1. Keep the hidden variant ID input in sync
  const stickyVariantIdInput = document.querySelector('.sticky-add-to-cart [name="id"]');
  if (stickyVariantIdInput) stickyVariantIdInput.value = variant.id;

  // 2. Sync every select in the sticky bar to its matching variant option
  document.querySelectorAll('.sticky-add-to-cart select').forEach((select) => {
          Array.from(select.options).forEach((opt) => {
            opt.selected = variant.options.includes(opt.value);
          });
        });


}

Then call it from handleUpdateProductInfo, right after getSelectedVariant:

assets/product-info.js — handleUpdateProductInfo
handleUpdateProductInfo(productUrl) {
  return (html) => {
    const variant = this.getSelectedVariant(html);

    this.handleUpdateStickyAddToCart(variant);  // ← add this line

    this.pickupAvailability?.update(variant);
    this.updateOptionValues(html);
    // ... rest of the method unchanged
  };
}
🎉

Save all files and preview

Open any product page, scroll past the add-to-cart button, and the sticky bar should slide up from the bottom. Change a variant — the sticky select updates. Change the sticky select — the main form updates. Low stock products show the pulsing red badge automatically.

Available for new projects

Want this built for your store?

I implement this kind of custom Shopify development daily — no apps, clean code, done fast. Tell me what you need and I'll get it done right.

60+ custom stores built
5★ Upwork rating
Reply within 24hrs
Book a Free Call Send a message
NO COMMITMENT · REPLIES WITHIN 24HRS
Need help in Shopify Development? Custom Shopify dev — no apps
Book Free Call →