Shopify AJAX Cart Drawer — Quantity Updates Without Reload
Add quantity increment/decrement buttons to the Shopify cart drawer with AJAX updates — no page reload, optimistic UI, and proper error handling using the Shopify AJAX API.
This Shopify AJAX cart drawer JavaScript snippet adds quantity controls to a cart drawer with AJAX updates, optimistic UI, and error handling.
Use this when your cart drawer shows item quantities as plain text and customers keep opening the cart page to update amounts. One JavaScript module turns your drawer into a fully interactive cart — add, remove, and adjust quantities without leaving the current page.
Shopify AJAX cart drawer — The Code
This Shopify AJAX cart drawer snippet updates cart quantities without a page reload using the Shopify Cart API.
We’ll build this in 5 progressive steps. Each step adds exactly one new concept — static markup, API wiring, optimistic updates, edge-case handling, and the full production module with debouncing and error recovery. Every step is a complete, working checkpoint you can stop at and test. No prior Shopify AJAX API experience assumed.
Step 1: Static quantity display in the cart drawer. Before touching JavaScript, get the HTML right. Each cart item gets a data-line attribute holding the Liquid loop index — this is the line parameter the Cart API expects. The quantity sits between two buttons inside a .cart-drawer__qty wrapper. The minus button uses the HTML entity − (not a hyphen), the plus button uses +. Both buttons have type="button" to prevent accidental form submission inside the drawer. At this point clicking the buttons does nothing — we just need the markup structure in place.
// Shopify AJAX cart drawer — JS snippet by Mosharaf Hossain
{% comment %}sections/cart-drawer.liquid — Step 1{% endcomment %}
{% for item in cart.items %}
<div class="cart-drawer__item" data-line="{{ forloop.index }}">
<img src="{{ item.image | image_url }}" alt="{{ item.title | escape }}">
<span class="cart-drawer__title">{{ item.title }}</span>
<div class="cart-drawer__qty">
<button type="button" class="qty-btn qty-btn--minus"
aria-label="Decrease quantity">−</button>
<span class="qty-value">{{ item.quantity }}</span>
<button type="button" class="qty-btn qty-btn--plus"
aria-label="Increase quantity">+</button>
</div>
<span class="cart-drawer__price">{{ item.line_price | money }}</span>
</div>
{% endfor %}
Step 2: Wire the buttons to the Shopify Cart API. The fetch('/cart/change.js') endpoint accepts a POST with { line, quantity } in the JSON body. line is the 1-based index of the cart item (matching Liquid’s forloop.index). quantity is the absolute new quantity — not a delta. The response is the full cart object with updated items, item_count, and total_price. On success we update the quantity text and the line price using Shopify.formatMoney(). The button click handlers subtract or add 1 from the current displayed quantity before sending. This step sends a real API call on every button press — no debouncing, no optimistic render, but it proves the API integration works.
// Shopify AJAX cart drawer
// assets/cart-drawer.js — Step 2
document.querySelectorAll('.cart-drawer__qty').forEach(group => {
const minus = group.querySelector('.qty-btn--minus');
const plus = group.querySelector('.qty-btn--plus');
const lineEl = group.closest('[data-line]');
function update(qty) {
const idx = lineEl.dataset.line;
fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line: parseInt(idx), quantity: qty }),
})
.then(r => r.json())
.then(cart => {
group.querySelector('.qty-value').textContent = qty;
lineEl.querySelector('.cart-drawer__price').textContent =
Shopify.formatMoney(cart.items[idx - 1].line_price);
});
}
minus.addEventListener('click', () => {
update(parseInt(group.querySelector('.qty-value').textContent) - 1);
});
plus.addEventListener('click', () => {
update(parseInt(group.querySelector('.qty-value').textContent) + 1);
});
});
Step 3: Add optimistic UI. Instead of waiting for the API response to update the DOM, we update the quantity number immediately on click — the user sees the change before the network round-trip completes. This is optimistic UI: assume success, render the result, revert if wrong. The handler stores the old quantity and old price before changing the DOM. If the fetch() fails (network error, non-2xx response), the .catch() block restores both values to their original state. The price only updates after the API confirms the change — displaying a stale price for 200ms is acceptable; displaying a wrong price permanently is not. This step introduces error recovery but no edge case handling yet.
// Shopify AJAX cart drawer
// assets/cart-drawer.js — Step 3
function updateQty(lineEl, action) {
const qtyEl = lineEl.querySelector('.qty-value');
const priceEl = lineEl.querySelector('.cart-drawer__price');
const oldQty = parseInt(qtyEl.textContent);
const oldPrice = priceEl.textContent;
const newQty = action === 'plus' ? oldQty + 1 : oldQty - 1;
const idx = parseInt(lineEl.dataset.line);
qtyEl.textContent = newQty;
fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line: idx, quantity: newQty }),
})
.then(r => { if (!r.ok) throw new Error('Cart update failed'); return r.json(); })
.then(cart => {
priceEl.textContent = Shopify.formatMoney(cart.items[idx - 1].line_price);
})
.catch(() => {
qtyEl.textContent = oldQty;
priceEl.textContent = oldPrice;
});
}
Step 4: Handle edge cases. Four edge cases that break the naive implementation: 1) Disabling the minus button at quantity 1 prevents the user from going below 1 — the guard if (action === 'minus' && oldQty <= 1) return blocks both the click and the optimistic update. 2) When quantity reaches 0 (the user clicks clear or the quantity logic hits zero), we send quantity: 0 to the API, which removes the item from the cart entirely. We add a .cart-drawer__item--removing class for a CSS fade-out transition before removing the DOM element cleanly via .remove(). 3) During the API call, the minus button is disabled to prevent rapid-click race conditions — re-enabled on success or failure. 4) The loading state is communicated through the disabled button attribute alone — no spinners, no overlays. A disabled button with opacity: 0.4 is the only visual feedback needed.
// Shopify AJAX cart drawer
// assets/cart-drawer.js — Step 4
function updateQty(lineEl, action) {
const qtyEl = lineEl.querySelector('.qty-value');
const priceEl = lineEl.querySelector('.cart-drawer__price');
const minus = lineEl.querySelector('.qty-btn--minus');
const oldQty = parseInt(qtyEl.textContent);
if (action === 'minus' && oldQty <= 1) return;
const newQty = action === 'plus' ? oldQty + 1 : oldQty - 1;
const idx = parseInt(lineEl.dataset.line);
if (newQty === 0) {
lineEl.classList.add('cart-drawer__item--removing');
fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line: idx, quantity: 0 }),
})
.then(r => r.json())
.then(() => lineEl.remove());
return;
}
minus.disabled = true;
qtyEl.textContent = newQty;
fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line: idx, quantity: newQty }),
})
.then(r => { if (!r.ok) throw new Error('Cart update failed'); return r.json(); })
.then(cart => {
priceEl.textContent = Shopify.formatMoney(cart.items[idx - 1].line_price);
minus.disabled = (newQty <= 1);
})
.catch(() => {
qtyEl.textContent = oldQty;
minus.disabled = false;
});
}
Step 5: Full production version. All concepts combined into a class-based module with production refinements: 1) Debounced updates — rapid clicks are batched into a single API call after a 300ms quiet period. The Map stores per-item timers keyed by the line item key, so clicking “+” 5 times in a row sends one request with the final quantity, not five. 2) The commit() method captures the old quantity at debounce start (before any DOM changes) and the new quantity at debounce end (after all clicks resolved), so the revert logic is always correct. 3) Variant-aware line item keys — the cart drawer markup now uses data-key="{{ item.key }}" instead of the loop index, which uniquely identifies variant-specific line items. 4) The error toast appends a dedicated #cart-toast element to the body and auto-removes after 3 seconds — no dependency on theme alert bars. 5) Cart count badge sync via syncCount() updates the header badge after every successful cart mutation.
// Shopify AJAX cart drawer
// assets/cart-drawer.js — production
class CartDrawer {
constructor() {
this.pending = new Map();
this.drawer = document.querySelector('.cart-drawer');
if (!this.drawer) return;
this.drawer.addEventListener('click', e => this.onClick(e));
}
onClick(e) {
const btn = e.target.closest('.qty-btn');
const lineEl = e.target.closest('[data-line]');
if (!btn || !lineEl) return;
const key = lineEl.dataset.key;
const qtyEl = lineEl.querySelector('.qty-value');
const oldQty = parseInt(qtyEl.textContent);
const action = btn.classList.contains('qty-btn--plus') ? 'plus' : 'minus';
if (action === 'minus' && oldQty <= 1) return;
clearTimeout(this.pending.get(key));
this.pending.set(key, setTimeout(() => {
const currentQty = parseInt(qtyEl.textContent);
this.commit(key, lineEl, oldQty, currentQty);
}, 300));
const newQty = action === 'plus' ? oldQty + 1 : oldQty - 1;
qtyEl.textContent = newQty;
lineEl.querySelector('.qty-btn--minus').disabled = (newQty <= 1);
}
commit(key, lineEl, oldQty, newQty) {
const priceEl = lineEl.querySelector('.cart-drawer__price');
if (newQty === 0) {
lineEl.classList.add('cart-drawer__item--removing');
return this.request({ id: key, quantity: 0 })
.then(() => { lineEl.remove(); this.syncCount(); });
}
this.request({ id: key, quantity: newQty })
.then(cart => {
const item = cart.items.find(i => i.key === key);
if (item) priceEl.textContent = Shopify.formatMoney(item.line_price);
this.syncCount(cart.item_count);
})
.catch(e => {
this.showError(e);
lineEl.querySelector('.qty-value').textContent = oldQty;
lineEl.querySelector('.qty-btn--minus').disabled = (oldQty <= 1);
});
}
request(body) {
return fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(r => { if (!r.ok) throw new Error('Cart update failed'); return r.json(); });
}
syncCount(count) {
const badge = document.querySelector('.cart-count-badge');
if (badge && count !== undefined) badge.textContent = count;
}
showError() {
const toast = document.getElementById('cart-toast') ||
Object.assign(document.createElement('div'), { id: 'cart-toast' });
toast.textContent = 'Quantity update failed. Try again.';
toast.className = 'cart-toast cart-toast--error';
if (!toast.parentNode) document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
}
document.addEventListener('DOMContentLoaded', () => new CartDrawer());
Where to put the code
The JavaScript module lives in assets/cart-drawer.js. The cart drawer markup (Step 1) lives in sections/cart-drawer.liquid. In your theme.liquid layout, load the script near the closing </body> tag:
{{ 'cart-drawer.js' | asset_url | script_tag }}
The Cart Drawer JavaScript depends on three things existing in the DOM: .cart-drawer (the drawer container), [data-line] elements (each cart item), and .cart-count-badge (the header badge — optional, gracefully skipped if absent). Make sure the drawer section is included in your theme.liquid and the cart items render with the Step 5 markup (using data-key).
How Shopify AJAX cart drawer Works — Why Each Layer Exists
Here is how this Shopify AJAX cart drawer works step by step: Every line in this module serves a purpose. Here's why each safeguard exists, how the Shopify Cart AJAX API works, and how the optimistic update pattern avoids data corruption.
This Shopify AJAX cart drawer implementation works as follows.
Every layer in this module solves a real failure mode. Here’s why each safeguard exists, how the Shopify Cart AJAX API works under the hood, and how the optimistic update pattern avoids data corruption.
Shopify Cart AJAX API — /cart/change.js endpoint and response shape
The Shopify Cart AJAX API endpoint — /cart/change.js — is a POST endpoint available on every Shopify storefront with AJAX cart enabled. It accepts a JSON body with two fields: id (the variant ID) or line (the 1-based line index), and quantity (the absolute new quantity, not a delta). Setting quantity to 0 removes the item. The response is the full cart object — { token, note, attributes, total_price, total_weight, item_count, items: [{ key, id, quantity, line_price, ... }] }. Unlike the GraphQL Storefront API, this endpoint requires no authentication token and is available on unauthenticated storefronts. The response includes every cart field, so one API call after a quantity change gives you line prices, item count, and totals — no need for a second request. The /cart/update.js endpoint (alternative) accepts an updates object with multiple line changes at once, but it overwrites the entire cart state rather than mutating a single line.
Line item keys vs line indexes — why the key is immutable
Line item keys vs line indexes. Every cart item has two identifiers: the line (1-based positional index, changes when items are added or removed) and the key (a colon-separated string like 4123456789:abc123 that uniquely identifies a variant + any line-item properties). Step 2 uses the 1-based index (forloop.index in Liquid stored in data-line) because it’s the simplest approach and matches the API’s line parameter directly. Step 5 upgrades to the item key (data-key="{{ item.key }}" stored as the debounce map key) because the index can drift — if a previous item is removed while a debounced update is pending, the index for the remaining items shifts by one, and the API call targets the wrong item. The key is immutable and survives cart mutations. In production, always use the key.
Optimistic update pattern — store, render, reconcile
The optimistic update pattern works in three phases: 1) Store state — capture the current quantity, price, and button state before any DOM changes. 2) Render new state — update the DOM immediately (quantity text changes, button disables, price stays stale). 3) Reconcile on response — on success: apply the server-confirmed price and release button lock. On failure: restore all three stored values. The price is deliberately not optimistically updated because the line price depends on the cart’s total recalculation (line price = item price × new quantity, with any quantity-break discounts applied). Guessing the price and being wrong is worse than showing a slightly stale price for 200ms. The quantity is safe to optimistically update because it’s just an integer change — the user knows what they clicked. The button disable prevents double-clicks in the 200ms window.
Debouncing — batched API calls and race condition prevention
Debouncing replaces the naive “one click = one API call” pattern. Each click resets a 300ms timer. If the user clicks “+” five times rapidly, the quantity text updates optimistically on every click (immediate visual feedback), but only one API call fires — 300ms after the last click, with the final quantity. The Map stores one timer per line item key, so clicking on item A doesn’t cancel item B’s pending request. The commit() method captures the quantity at debounce start (oldQty) and re-reads the DOM at debounce end (currentQty) — this is important because the user might click “+” twice, then “−” once, all within 300ms. The final quantity sent to the API is what’s currently displayed, not what was displayed when the first click happened. Without debouncing, five rapid clicks produce five API calls in parallel — the server processes them in arbitrary order, and the last response to arrive (not the last click) determines the visible state. This is the source of the “my cart quantity is wrong after fast clicking” bug.
Error recovery — promise rejection handling and toast notification
Error recovery happens at two levels. The .catch() on the fetch promise handles network-level failures (offline, DNS failure, CDN outage) and HTTP error responses (Shopify returning a 422 for an invalid quantity, 5xx for server errors). The catch restores the pre-click quantity and price — the DOM returns to the last known valid state. The showError() method creates an ephemeral toast element: a fixed-position banner at the top of the viewport that auto-removes after 3 seconds. The toast checks for an existing #cart-toast element before creating one — no duplicate toasts, no element leak. On the error path, the minus button re-enables and the stale quantity is shown. The user can immediately retry. This pattern — instant retryability without a page reload — is the core value proposition over default Shopify cart behavior, which requires navigating to /cart after every error.
Shopify AJAX cart drawer — Setup & Integration
To integrate this Shopify AJAX cart drawer into your project: One section file, one JavaScript module, and a few CSS rules transform your cart drawer into a fully interactive cart. Here's where everything lives and how to wire it up.
This Shopify AJAX cart drawer implementation works as follows.
One section file, one JavaScript module, and a few CSS rules transform your cart drawer into a fully interactive cart. Here’s where everything lives and how to wire it up.
Create the cart drawer section with quantity button markup
Create the cart drawer section. If your theme already has a cart drawer, skip to step 2 and integrate the quantity button markup into the existing loop. If you’re building from scratch, create sections/cart-drawer.liquid with the basic drawer structure:
<div class="cart-drawer" id="cart-drawer">n <div class="cart-drawer__overlay"></div>n <div class="cart-drawer__panel">n <h2>Your Cart</h2>n {% for item in cart.items %}n <div class="cart-drawer__item" data-line="{{ forloop.index }}" data-key="{{ item.key }}">n <img src="{{ item.image | image_url: width: 120 }}" alt="{{ item.title | escape }}">n <div class="cart-drawer__details">n <span class="cart-drawer__title">{{ item.title }}</span>n <div class="cart-drawer__qty">n <button class="qty-btn qty-btn--minus" type="button" aria-label="Decrease quantity"−</button>n <span class="qty-value">{{ item.quantity }}</span>n <button class="qty-btn qty-btn--plus" type="button" aria-label="Increase quantity"+</button>n </div>n </div>n <span class="cart-drawer__price">{{ item.line_price | money }}</span>n </div>n {% endfor %}n </div>n</div>
The data-key="{{ item.key }}" attribute is critical for the production version — it uniquely identifies variant-specific line items and survives cart mutations where indexes shift.
Wire the JavaScript module via asset_url script_tag
Wire the JavaScript module. Copy the Step 5 code into assets/cart-drawer.js. In layout/theme.liquid, add the script tag near </body>:
{{ 'cart-drawer.js' | asset_url | script_tag }}
The module initializes itself on DOMContentLoaded — no inline script tags, no manual new CartDrawer() call. It checks for the existence of .cart-drawer at construction and silently exits if the element isn’t on the page, so it’s safe to include on every page. The event listener is delegated on the .cart-drawer element itself, so dynamically added cart items (after adding a product via AJAX) are automatically handled — no MutationObserver needed.
Style the quantity buttons with CSS — tap targets, transitions, and removal animation
Style the quantity buttons. Add these CSS rules to your theme’s stylesheet. The quantity controls should look like buttons, not form inputs — they’re interactive targets on a touch device, so minimum 36px tap area:
.cart-drawer__qty {n display: inline-flex;n align-items: center;n gap: 8px;n border: 1px solid #ddd;n border-radius: 4px;n padding: 0;n}nn.qty-btn {n width: 36px;n height: 36px;n display: flex;n align-items: center;n justify-content: center;n background: none;n border: none;n font-size: 1.25rem;n cursor: pointer;n color: #333;n transition: opacity 0.2s;n}nn.qty-btn:hover { background: #f5f5f5; }n.qty-btn:disabled { opacity: 0.3; cursor: not-allowed; }nn.qty-value {n min-width: 24px;n text-align: center;n font-weight: 600;n}nn.cart-drawer__item--removing {n opacity: 0;n transform: translateX(20px);n transition: opacity 0.3s, transform 0.3s;n}
The qty-btn:disabled rule gives low-opacity visual feedback during API calls — the button is greyed out but still in place, so the layout doesn’t shift. The cart-drawer__item--removing class triggers a fade-and-slide-out animation before the element is removed from the DOM. The 36px width/height exceeds the WCAG minimum touch target of 24px and matches Google’s recommended 48dp target (36px is close enough for most designs).
Making This Shopify AJAX cart drawer Your Own
Customise this Shopify AJAX cart drawer to match your specific needs: The module is a pattern, not a closed box. Add keyboard navigation, animate quantity changes, sync with header badges, and handle variant-specific line items correctly.
Add keyboard support — arrow-key navigation between items
Add keyboard support. The quantity buttons should respond to Enter and Space keys. Add this handler inside the onClick method or as a separate onKeyDown listener. Since the buttons are <button> elements (not <div>), they already respond to Space and Enter natively — no extra code needed for basic keyboard activation. To add arrow-key navigation between quantity groups, add this to the CartDrawer class:
this.drawer.addEventListener('keydown', e => {n if (!e.target.classList.contains('qty-btn')) return;n const items = [...this.drawer.querySelectorAll('.qty-btn')];n const idx = items.indexOf(e.target);n if (e.key === 'ArrowDown' && idx + 2 < items.length)n items[idx + 2].focus();n if (e.key === 'ArrowUp' && idx - 2 >= 0)n items[idx - 2].focus();n});
The +2 offset skips to the same button (plus or minus) on the next item — because buttons alternate minus/plus per row.
Add animation on quantity change — scale pop on click
Add animation on quantity change. A brief scale pop on the quantity number confirms the click was registered — especially important for touchscreens where there’s no hover state. Add this CSS:
.qty-value--changed {n animation: qtyPop 0.2s ease-out;n}nn@keyframes qtyPop {n 0% { transform: scale(1); }n 50% { transform: scale(1.3); }n 100% { transform: scale(1); }n}
Then add/remove the class in the click handler: qtyEl.classList.add('qty-value--changed'); setTimeout(() => qtyEl.classList.remove('qty-value--changed'), 200); The class triggers a 200ms scale animation — the same duration as the CSS, so the class can be removed after one animation cycle.
Sync the cart count badge in the header — multiple instances and AJAX syncing
Sync the cart count badge in the header. The syncCount() method in Step 5 already handles this for simple setups — it looks for a .cart-count-badge element and updates its text. If your header has multiple badge instances (mobile + desktop), update the method:
syncCount(count) {n document.querySelectorAll('.cart-count-badge').forEach(badge => {n if (count !== undefined) badge.textContent = count;n });n}
If your cart count is rendered as a Liquid variable in the header (not updated via JavaScript), you’ll need to handle the badge via AJAX. Alternative: after every successful cart mutation, fetch /cart.js (GET) to get the full cart state including item_count, then update every badge on the page. This adds one extra network request per mutation but guarantees consistency. For performance, cache the badge elements in the constructor (this.badges = document.querySelectorAll('.cart-count-badge')) so syncCount() doesn’t re-query the DOM.
Variant-specific line items — why the key matters.
Shopify treats each variant as a separate purchasable entity. If a customer adds a blue shirt (variant ID 123) and a red shirt (variant ID 456) to the cart, they are two separate line items with two separate keys — even though they’re the same product. The /cart/change.js endpoint identifies items by either the 1-based line index or the variant ID. Using the variant ID directly ({ id: 123, quantity: 2 }) means you must look up the variant ID from the DOM — store it in data-variant-id="{{ item.variant_id }}". Using the line item key ({ id: key, quantity: 2 }, where id is the key string) uniquely identifies the exact cart entry including the variant and any line-item properties. Do not use the product ID — a product can have multiple variants, and a product ID alone cannot distinguish which variant to update. Also note: when a customer changes the quantity of one variant, the other variant’s quantity is unaffected. This is correct behavior — the cart tracks line items, not product aggregates.
Performance notes:
- Shopify’s Cart AJAX API has no documented rate limit for the storefront, but aggressive rapid-fire requests (50+ per second) will trigger Shopify’s bot detection. The 300ms debounce in Step 5 keeps you well under any threshold — at most ~3 requests per second per cart item during rapid clicking.
- The
/cart/change.jsresponse is the full cart object — every line item, every price, every attribute. For a cart with 20 items, the response is roughly 4-6KB of JSON. The debounce means this payload is fetched once per user action, not per click. No chunked loading, no pagination, no incremental diff — Shopify always returns the full cart. - No database writes on the storefront side. The Cart module stores PENDING updates in a JavaScript
Map— in-memory, per-page-load. If the user refreshes the page mid-debounce, the pending API call is cancelled by the browser and the server never sees the intermediate quantity changes. The next page load shows the last committed cart state. - Delegated event listeners scale to any cart size. The single
clicklistener on.cart-drawerhandles all quantity buttons — even for a 50-item cart, one listener services every button. No per-button event handler allocation, no memory growth as items increase. - The
Shopify.formatMoney()function is a global included by every Shopify theme. It converts a cents integer (line_priceis in cents) to a localized currency string using the store’s money format setting. No external library, no locale data bundle, no network request.

No comments yet. Be the first.