Table of Contents
- Introduction
- What We'll Build
- How Sticky Cart Bars Work
- Setting Up the Theme App Extension
- Building the Liquid Template
- Styling the Sticky Bar
- Adding JavaScript for Show/Hide Logic
- Handling Variant Selection
- Complete File
- Tips and Common Pitfalls
- Conclusion
- UR: Smart Sticky Cart
- References
Introduction
Here's a common scenario: a customer lands on your product page, reads the description, scrolls through reviews — and by the time they're ready to buy, the Add to Cart button is nowhere in sight. They have to scroll all the way back up. That friction, however small, costs conversions.
A sticky add-to-cart bar solves this by keeping a compact buy bar pinned to the bottom of the screen whenever the main Add to Cart button scrolls out of the viewport. The customer can add the product to their cart from anywhere on the page without scrolling back up.
In this tutorial, I'll walk you through building a fully functional sticky add-to-cart bar as a Theme App Extension for Shopify. We'll use the Intersection Observer API to detect when the original buy button leaves the viewport, handle variant selection with price and availability updates, and style everything with smooth CSS transitions. By the end, you'll have production-ready code you can drop into any Online Store 2.0 theme.
What We'll Build
The sticky cart bar will:
- Appear automatically when the main Add to Cart button scrolls out of view
- Hide when the customer scrolls back up to the original button
- Display the product image, title, price, and a variant selector
- Sync variant selection with the main product form — changing a variant in the sticky bar updates the price, availability, and selected variant
- Support smooth slide-up/slide-down animations
- Be fully configurable from the theme editor (colors, bar height, z-index)
- Work on both desktop and mobile
How Sticky Cart Bars Work
Before we dive into code, let's understand the mechanism. A sticky cart bar involves three moving parts:
1. Visibility Trigger
We need to know when the main Add to Cart button leaves the viewport. There are two common approaches:
-
Scroll event listener — Listen to
scrollevents and comparegetBoundingClientRect()against the viewport. Simple but fires on every scroll frame, which can cause jank. - Intersection Observer — A browser API purpose-built for detecting when elements enter or leave the viewport. It runs asynchronously off the main thread, so it's far more performant.
We'll use Intersection Observer. It's supported in all modern browsers and is the recommended approach for this pattern.
2. Fixed Positioning
The bar itself uses position: fixed with bottom: 0 to stay pinned to the bottom of the screen. We'll use transform: translateY(100%) to hide it off-screen and translateY(0) to slide it into view.
3. Cart Submission
When the customer clicks "Add to Cart" on the sticky bar, we submit the selected variant to Shopify's /cart/add.js endpoint via the Fetch API. This avoids a full page reload and gives us control over the post-add behavior (e.g., opening a cart drawer or redirecting).
Setting Up the Theme App Extension
Prerequisites
Make sure you have:
- A Shopify Partner account
- A development store
- Shopify CLI installed
- An existing Shopify app (or create one with
shopify app init)
Generate the Extension
Inside your app directory, run:
shopify app generate extension --template theme_app_extension --name sticky-cart
This creates the following structure:
extensions/
sticky-cart/
blocks/
assets/
locales/
snippets/
We'll create two files:
-
blocks/sticky-cart.liquid— the main Liquid template with the sticky bar markup, JavaScript, and schema -
assets/sticky-cart.css— styles for the sticky bar
Let's start building.
Building the Liquid Template
Create the app block file at extensions/sticky-cart/blocks/sticky-cart.liquid.
The Liquid template needs to render the product's image, title, price, variant selector, and an Add to Cart button. It also needs to output data attributes so our JavaScript can access product data without additional API calls.
The Sticky Bar Container
First, let's set up the outer wrapper with product data:
{{ 'sticky-cart.css' | asset_url | stylesheet_tag }}
{% if product.available %}
<div
id="sticky-cart-bar"
class="sticky-cart"
data-product-url="{{ product.url }}"
style="
--sticky-cart-bg: {{ block.settings.background_color }};
--sticky-cart-text: {{ block.settings.text_color }};
--sticky-cart-btn-bg: {{ block.settings.button_color }};
--sticky-cart-btn-text: {{ block.settings.button_text_color }};
--sticky-cart-z-index: {{ block.settings.z_index }};
"
>
<div class="sticky-cart__inner">
<!-- Product info, variant selector, and button will go here -->
</div>
</div>
{% endif %}
We wrap everything in {% if product.available %} — there's no point showing a buy bar for sold-out products. Merchant settings are passed as CSS custom properties, keeping the Liquid template clean and all visual styling in the CSS file.
Product Information
Inside the .sticky-cart__inner container, we display the product image and title:
<div class="sticky-cart__product">
{% if product.featured_image %}
<img
class="sticky-cart__image"
src="{{ product.featured_image | image_url: width: 80 }}"
alt="{{ product.featured_image.alt | escape }}"
width="40"
height="40"
loading="lazy"
/>
{% endif %}
<div class="sticky-cart__details">
<a href="{{ product.url }}" class="sticky-cart__title">
{{ product.title }}
</a>
<div class="sticky-cart__price" id="sticky-cart-price">
{{ product.selected_or_first_available_variant.price | money }}
</div>
</div>
</div>
We use image_url: width: 80 to request a small image — the bar only needs a thumbnail, and requesting a full-size image would waste bandwidth. The id="sticky-cart-price" lets our JavaScript update the price when the customer changes the variant.
Variant Selector
If the product has more than one variant, we render a <select> dropdown. For single-variant products, we output a hidden input instead:
<div class="sticky-cart__actions">
{% if product.variants.size > 1 %}
<div class="sticky-cart__variant-wrapper">
<select
id="sticky-cart-variant-select"
class="sticky-cart__variant-select"
aria-label="Select variant"
>
{% for variant in product.variants %}
<option
value="{{ variant.id }}"
data-price="{{ variant.price | money }}"
data-available="{{ variant.available }}"
{% if variant == product.selected_or_first_available_variant %}
selected
{% endif %}
>
{{ variant.title }}
</option>
{% endfor %}
</select>
</div>
{% else %}
<input
type="hidden"
id="sticky-cart-variant-select"
value="{{ product.selected_or_first_available_variant.id }}"
/>
{% endif %}
<button
type="button"
id="sticky-cart-add-btn"
class="sticky-cart__add-btn"
{% unless product.selected_or_first_available_variant.available %}
disabled
{% endunless %}
>
{{ block.settings.button_text }}
</button>
</div>
Each <option> carries data-price and data-available attributes. This way, when the customer selects a different variant, JavaScript can instantly update the price and disable the button for out-of-stock variants — no additional API calls needed.
Styling the Sticky Bar
Create the stylesheet at extensions/sticky-cart/assets/sticky-cart.css.
The key CSS technique here is using transform: translateY(100%) to keep the bar hidden below the viewport, and transitioning to translateY(0) when it should be visible. This is smoother than toggling display: none because the browser can hardware-accelerate transform animations.
.sticky-cart {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--sticky-cart-z-index, 999);
background-color: var(--sticky-cart-bg, #ffffff);
color: var(--sticky-cart-text, #333333);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
transform: translateY(100%);
transition: transform 0.3s ease;
will-change: transform;
}
.sticky-cart.is-visible {
transform: translateY(0);
}
.sticky-cart__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 10px 20px;
gap: 16px;
}
.sticky-cart__product {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.sticky-cart__image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.sticky-cart__details {
min-width: 0;
}
.sticky-cart__title {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--sticky-cart-text, #333333);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sticky-cart__title:hover {
text-decoration: underline;
}
.sticky-cart__price {
font-size: 14px;
font-weight: 700;
margin-top: 2px;
}
.sticky-cart__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.sticky-cart__variant-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
background-color: #fff;
cursor: pointer;
max-width: 160px;
}
.sticky-cart__add-btn {
padding: 10px 24px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background-color: var(--sticky-cart-btn-bg, #333333);
color: var(--sticky-cart-btn-text, #ffffff);
transition: opacity 0.2s ease;
white-space: nowrap;
}
.sticky-cart__add-btn:hover {
opacity: 0.85;
}
.sticky-cart__add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Mobile styles */
@media (max-width: 768px) {
.sticky-cart__inner {
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.sticky-cart__product {
flex: 1 1 100%;
}
.sticky-cart__actions {
flex: 1 1 100%;
justify-content: stretch;
}
.sticky-cart__variant-select {
flex: 1;
}
.sticky-cart__add-btn {
flex: 1;
}
}
A few things worth noting about these styles:
-
will-change: transform— Hints to the browser that this element's transform will change, so it can prepare by promoting the element to its own compositing layer. This ensures silky-smooth animation. -
min-width: 0on.sticky-cart__product— Without this, flexbox children won't shrink below their content size, and long product titles would blow out the layout.min-width: 0lets the title truncate withtext-overflow: ellipsis. - Mobile layout — On screens smaller than 768px, the bar wraps into two rows: product info on top, variant selector and button stretching full-width on the bottom. This gives the button a tap-friendly size on mobile.
Adding JavaScript for Show/Hide Logic
Now for the core behavior. We need to:
- Find the main product form's Add to Cart button on the page
- Observe it with an Intersection Observer
- Show the sticky bar when the button leaves the viewport, hide it when it comes back
Add this <script> tag at the bottom of your sticky-cart.liquid file (before the {% schema %} block):
<script>
(function () {
const stickyBar = document.getElementById('sticky-cart-bar');
if (!stickyBar) return;
function findMainAddToCartButton() {
const mainForm = document.querySelector(
'form[action="/cart/add"]:not(.sticky-cart form)'
);
if (!mainForm) return null;
return (
mainForm.querySelector('[name="add"]') ||
mainForm.querySelector('button[type="submit"]')
);
}
function initObserver() {
const targetButton = findMainAddToCartButton();
if (!targetButton) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
stickyBar.classList.remove('is-visible');
} else {
stickyBar.classList.add('is-visible');
}
});
},
{ threshold: 0 }
);
observer.observe(targetButton);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initObserver);
} else {
initObserver();
}
})();
</script>
Let's break this down:
findMainAddToCartButton() — We look for the product page's original form[action="/cart/add"] and grab its submit button. The :not(.sticky-cart form) exclusion ensures we don't accidentally observe our own sticky bar's button. Most Shopify themes use [name="add"] for their Add to Cart button, but we fall back to button[type="submit"] for broader compatibility.
IntersectionObserver — The callback fires whenever the observed button enters or leaves the viewport. entry.isIntersecting is true when the button is visible, false when it's scrolled away. We toggle the is-visible class accordingly, which triggers the CSS transform transition.
threshold: 0 — The callback fires as soon as even a single pixel of the target enters or leaves the viewport. This gives the snappiest response. You could set this to 1 if you only want the sticky bar to appear when the button is completely out of view.
Handling Variant Selection
When the customer selects a different variant in the sticky bar, we need to update three things:
- The displayed price
- The button's disabled state (for sold-out variants)
- The main product form on the page (so both forms stay in sync)
Add this script block alongside the observer code:
<script>
(function () {
const variantSelect = document.getElementById('sticky-cart-variant-select');
const priceEl = document.getElementById('sticky-cart-price');
const addBtn = document.getElementById('sticky-cart-add-btn');
if (!variantSelect || !addBtn) return;
// Update price and availability when variant changes
if (variantSelect.tagName === 'SELECT') {
variantSelect.addEventListener('change', function () {
const selected = this.options[this.selectedIndex];
const price = selected.dataset.price;
const available = selected.dataset.available === 'true';
if (priceEl && price) {
priceEl.textContent = price;
}
addBtn.disabled = !available;
addBtn.textContent = available
? '{{ block.settings.button_text }}'
: '{{ block.settings.sold_out_text }}';
// Sync with main product form
syncMainForm(this.value);
});
}
function syncMainForm(variantId) {
// Update the URL so the page reflects the selected variant
const url = new URL(window.location);
url.searchParams.set('variant', variantId);
window.history.replaceState({}, '', url);
// Dispatch a change event on the main form's variant input
const mainForm = document.querySelector(
'form[action="/cart/add"]:not(.sticky-cart form)'
);
if (!mainForm) return;
const mainVariantInput = mainForm.querySelector(
'select[name="id"], input[name="id"]'
);
if (mainVariantInput) {
mainVariantInput.value = variantId;
mainVariantInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// Add to Cart handler
addBtn.addEventListener('click', function () {
const variantId =
variantSelect.tagName === 'SELECT'
? variantSelect.value
: variantSelect.value;
if (!variantId) return;
addBtn.disabled = true;
addBtn.textContent = '{{ block.settings.adding_text }}';
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ id: parseInt(variantId), quantity: 1 }],
}),
})
.then((response) => {
if (!response.ok) throw new Error('Add to cart failed');
return response.json();
})
.then(() => {
addBtn.textContent = '{{ block.settings.added_text }}';
setTimeout(() => {
addBtn.disabled = false;
addBtn.textContent = '{{ block.settings.button_text }}';
}, 2000);
// Trigger cart update event for themes that listen for it
document.dispatchEvent(
new CustomEvent('cart:updated', { bubbles: true })
);
})
.catch((error) => {
console.error('Sticky cart error:', error);
addBtn.disabled = false;
addBtn.textContent = '{{ block.settings.button_text }}';
});
});
})();
</script>
Let's look at the key decisions:
syncMainForm() — When the customer picks a variant in the sticky bar, we update the URL's variant query parameter with history.replaceState. This ensures the page URL reflects the selected variant (which matters for shared links and analytics). We also find the main form's variant <select> or <input> and change its value, dispatching a change event so the theme's own JavaScript picks up the change and updates things like the product image gallery.
/cart/add.js — Shopify's Ajax Cart API. We POST a JSON body with the variant ID and quantity. This is the standard way to add items without a full page reload. The response contains the added line item, but we don't need it here — we just show a success state.
cart:updated CustomEvent — Many Shopify themes (including Dawn) listen for custom events to update the cart drawer or cart icon count. By dispatching this event, the sticky bar integrates smoothly with the theme's existing cart behavior. Some themes use different event names (cart:refresh, ajaxCart:updated), but cart:updated is the most common convention.
Button state feedback — We cycle the button through three states: the default text → "Adding..." → "Added!" → back to default. This gives the customer clear feedback that their action was registered without needing a modal or notification. The 2-second delay before resetting lets the customer see the confirmation.
Complete File
Here's the complete sticky-cart.liquid file — everything in one place so you can copy it directly into your project:
extensions/sticky-cart/blocks/sticky-cart.liquid
{{ 'sticky-cart.css' | asset_url | stylesheet_tag }}
{% if product.available %}
<div
id="sticky-cart-bar"
class="sticky-cart"
data-product-url="{{ product.url }}"
style="
--sticky-cart-bg: {{ block.settings.background_color }};
--sticky-cart-text: {{ block.settings.text_color }};
--sticky-cart-btn-bg: {{ block.settings.button_color }};
--sticky-cart-btn-text: {{ block.settings.button_text_color }};
--sticky-cart-z-index: {{ block.settings.z_index }};
"
>
<div class="sticky-cart__inner">
<div class="sticky-cart__product">
{% if product.featured_image %}
<img
class="sticky-cart__image"
src="{{ product.featured_image | image_url: width: 80 }}"
alt="{{ product.featured_image.alt | escape }}"
width="40"
height="40"
loading="lazy"
/>
{% endif %}
<div class="sticky-cart__details">
<a href="{{ product.url }}" class="sticky-cart__title">
{{ product.title }}
</a>
<div class="sticky-cart__price" id="sticky-cart-price">
{{ product.selected_or_first_available_variant.price | money }}
</div>
</div>
</div>
<div class="sticky-cart__actions">
{% if product.variants.size > 1 %}
<div class="sticky-cart__variant-wrapper">
<select
id="sticky-cart-variant-select"
class="sticky-cart__variant-select"
aria-label="Select variant"
>
{% for variant in product.variants %}
<option
value="{{ variant.id }}"
data-price="{{ variant.price | money }}"
data-available="{{ variant.available }}"
{% if variant == product.selected_or_first_available_variant %}
selected
{% endif %}
>
{{ variant.title }}
</option>
{% endfor %}
</select>
</div>
{% else %}
<input
type="hidden"
id="sticky-cart-variant-select"
value="{{ product.selected_or_first_available_variant.id }}"
/>
{% endif %}
<button
type="button"
id="sticky-cart-add-btn"
class="sticky-cart__add-btn"
{% unless product.selected_or_first_available_variant.available %}
disabled
{% endunless %}
>
{{ block.settings.button_text }}
</button>
</div>
</div>
</div>
<script>
(function () {
const stickyBar = document.getElementById('sticky-cart-bar');
if (!stickyBar) return;
// --- Intersection Observer: show/hide sticky bar ---
function findMainAddToCartButton() {
const mainForm = document.querySelector(
'form[action="/cart/add"]:not(.sticky-cart form)'
);
if (!mainForm) return null;
return (
mainForm.querySelector('[name="add"]') ||
mainForm.querySelector('button[type="submit"]')
);
}
function initObserver() {
const targetButton = findMainAddToCartButton();
if (!targetButton) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
stickyBar.classList.remove('is-visible');
} else {
stickyBar.classList.add('is-visible');
}
});
},
{ threshold: 0 }
);
observer.observe(targetButton);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initObserver);
} else {
initObserver();
}
// --- Variant selection and Add to Cart ---
const variantSelect = document.getElementById('sticky-cart-variant-select');
const priceEl = document.getElementById('sticky-cart-price');
const addBtn = document.getElementById('sticky-cart-add-btn');
if (!variantSelect || !addBtn) return;
if (variantSelect.tagName === 'SELECT') {
variantSelect.addEventListener('change', function () {
const selected = this.options[this.selectedIndex];
const price = selected.dataset.price;
const available = selected.dataset.available === 'true';
if (priceEl && price) {
priceEl.textContent = price;
}
addBtn.disabled = !available;
addBtn.textContent = available
? '{{ block.settings.button_text }}'
: '{{ block.settings.sold_out_text }}';
syncMainForm(this.value);
});
}
function syncMainForm(variantId) {
const url = new URL(window.location);
url.searchParams.set('variant', variantId);
window.history.replaceState({}, '', url);
const mainForm = document.querySelector(
'form[action="/cart/add"]:not(.sticky-cart form)'
);
if (!mainForm) return;
const mainVariantInput = mainForm.querySelector(
'select[name="id"], input[name="id"]'
);
if (mainVariantInput) {
mainVariantInput.value = variantId;
mainVariantInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}
addBtn.addEventListener('click', function () {
const variantId = variantSelect.value;
if (!variantId) return;
addBtn.disabled = true;
addBtn.textContent = '{{ block.settings.adding_text }}';
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ id: parseInt(variantId), quantity: 1 }],
}),
})
.then((response) => {
if (!response.ok) throw new Error('Add to cart failed');
return response.json();
})
.then(() => {
addBtn.textContent = '{{ block.settings.added_text }}';
setTimeout(() => {
addBtn.disabled = false;
addBtn.textContent = '{{ block.settings.button_text }}';
}, 2000);
document.dispatchEvent(
new CustomEvent('cart:updated', { bubbles: true })
);
})
.catch((error) => {
console.error('Sticky cart error:', error);
addBtn.disabled = false;
addBtn.textContent = '{{ block.settings.button_text }}';
});
});
})();
</script>
{% endif %}
{% schema %}
{
"name": "Sticky Cart Bar",
"target": "section",
"settings": [
{
"type": "color",
"id": "background_color",
"label": "Background color",
"default": "#ffffff"
},
{
"type": "color",
"id": "text_color",
"label": "Text color",
"default": "#333333"
},
{
"type": "color",
"id": "button_color",
"label": "Button color",
"default": "#333333"
},
{
"type": "color",
"id": "button_text_color",
"label": "Button text color",
"default": "#ffffff"
},
{
"type": "text",
"id": "button_text",
"label": "Button text",
"default": "Add to Cart"
},
{
"type": "text",
"id": "adding_text",
"label": "Adding text",
"default": "Adding..."
},
{
"type": "text",
"id": "added_text",
"label": "Added text",
"default": "Added!"
},
{
"type": "text",
"id": "sold_out_text",
"label": "Sold out text",
"default": "Sold Out"
},
{
"type": "range",
"id": "z_index",
"label": "Z-index",
"min": 1,
"max": 9999,
"step": 1,
"default": 999,
"info": "Adjust if the bar appears behind other elements"
}
],
"templates": ["product"]
}
{% endschema %}
extensions/sticky-cart/assets/sticky-cart.css
.sticky-cart {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--sticky-cart-z-index, 999);
background-color: var(--sticky-cart-bg, #ffffff);
color: var(--sticky-cart-text, #333333);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
transform: translateY(100%);
transition: transform 0.3s ease;
will-change: transform;
}
.sticky-cart.is-visible {
transform: translateY(0);
}
.sticky-cart__inner {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 10px 20px;
gap: 16px;
}
.sticky-cart__product {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.sticky-cart__image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.sticky-cart__details {
min-width: 0;
}
.sticky-cart__title {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--sticky-cart-text, #333333);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sticky-cart__title:hover {
text-decoration: underline;
}
.sticky-cart__price {
font-size: 14px;
font-weight: 700;
margin-top: 2px;
}
.sticky-cart__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.sticky-cart__variant-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
background-color: #fff;
cursor: pointer;
max-width: 160px;
}
.sticky-cart__add-btn {
padding: 10px 24px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background-color: var(--sticky-cart-btn-bg, #333333);
color: var(--sticky-cart-btn-text, #ffffff);
transition: opacity 0.2s ease;
white-space: nowrap;
}
.sticky-cart__add-btn:hover {
opacity: 0.85;
}
.sticky-cart__add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.sticky-cart__inner {
flex-wrap: wrap;
padding: 8px 12px;
gap: 8px;
}
.sticky-cart__product {
flex: 1 1 100%;
}
.sticky-cart__actions {
flex: 1 1 100%;
justify-content: stretch;
}
.sticky-cart__variant-select {
flex: 1;
}
.sticky-cart__add-btn {
flex: 1;
}
}
Tips and Common Pitfalls
Z-index Conflicts
Many Shopify themes use high z-index values for headers, modals, and overlays. If the sticky bar appears behind other elements, increase the z-index in the theme editor settings. The default of 999 works for most themes, but some may need 9999 or higher. That's why we made it configurable.
Mobile Bottom Navigation
Some themes add their own fixed-position bottom navigation or chat widgets on mobile. If both exist, they'll overlap. Test on mobile and consider adding a bottom offset via a setting if needed. A common pattern is to detect other fixed elements and adjust accordingly, but for a Theme App Extension, keeping it simple with a configurable z-index is the pragmatic choice.
Cart Drawer Integration
The cart:updated custom event works with many themes out of the box, but not all. If the theme's cart drawer doesn't open or the cart count doesn't update after adding via the sticky bar, check what event the theme listens for. In Shopify's Dawn theme, you may need to dispatch a cart:refresh event or trigger a section rendering update via the Section Rendering API.
Accessibility
The sticky bar uses aria-label on the variant selector and provides visual feedback through button state changes. For even better accessibility, consider adding an aria-live="polite" region that announces when an item has been added to the cart — screen reader users won't see the button text change.
Performance
The Intersection Observer API is lightweight and runs off the main thread, so it won't cause scroll jank. However, be mindful of the CSS box-shadow on the sticky bar — on low-end mobile devices, large box-shadows on fixed-position elements can sometimes cause repaint issues. If you notice performance problems, consider using a simpler border instead of a shadow.
Conclusion
We've built a fully functional sticky add-to-cart bar that enhances the shopping experience by keeping the buy action always within reach. The implementation uses modern browser APIs — Intersection Observer for performant visibility detection, the Fetch API for seamless cart additions, and CSS transforms for smooth animations.
The bar handles variant selection with instant price and availability updates, syncs with the main product form, and integrates with theme cart drawers through custom events. All visual properties are configurable from the theme editor, making it flexible enough for any store's branding.
If you'd rather skip the implementation and get a polished sticky cart with more advanced features out of the box, check out UR: Smart Sticky Cart below.
UR: Smart Sticky Cart
If you want a sticky cart solution without writing any code, UR: Smart Sticky Cart by UnReact provides a ready-to-use sticky add-to-cart bar with advanced features:
- Zero-code setup — Install and configure entirely from the Shopify theme editor
- Multiple display styles — Choose from a compact bar, a full-width bar, or a floating button
- Smart visibility logic — Automatically shows/hides based on the page's Add to Cart button position
- Full variant support — Variant selector with real-time price and availability updates
- Mobile-optimized design — Responsive layout that adapts to any screen size
- Customizable appearance — Colors, fonts, button styles, and animations are all configurable
UR: Smart Sticky Cart on the Shopify App Store
Top comments (0)