Snippets / Shopify

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.

JavaScript utility Production-tested ~10 min setup Cart system Intermediate Shopify 2.0+ Updated May 15, 2026
TL;DR

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.

01 — Building it step by step

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.

Step 1 — Static quantity display.liquid
liquid
// 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.

Step 2 — Wire buttons to Cart API.js
javascript
// 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.

Step 3 — Optimistic UI.js
javascript
// 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.

Step 4 — Edge cases.js
javascript
// 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.

assets/cart-drawer.js
javascript
// 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).

02 — How it works

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.

1

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.

2

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.

3

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.

4

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.

5

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.

03 — Setup & integration

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.

your-shopify-theme/
│   ├── assets/
│   │   └── cart-drawer.js
│   ├── sections/
│   │   └── cart-drawer.liquid
│   └── layout/
        └── theme.liquid
1

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"&minus;</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.

2

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.

3

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).

04 — Making it your own

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.

1

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.

2

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.

3

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.js response 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 click listener on .cart-drawer handles 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_price is in cents) to a localized currency string using the store’s money format setting. No external library, no locale data bundle, no network request.
0 comments

No comments yet. Be the first.

Leave a Reply

Table of Contents

Shopify AJAX cart drawer — Code Snippet

Shopify AJAX cart drawer — code snippet by Mosharaf Hossain

The Shopify AJAX cart drawer pattern is useful when a Shopify theme needs cart quantity changes, subtotal refreshes, and item removal without forcing a full page reload. Keep the Shopify AJAX cart drawer code close to the cart drawer markup so the JavaScript selectors stay predictable.

This Shopify AJAX cart drawer is a production-tested PHP snippet for WordPress developers. See the Shopify AJAX Cart API documentation for related official documentation.

The Shopify AJAX cart drawer snippet handles quantity updates inside a slide-out cart drawer — incrementing, decrementing, and removing line items — without triggering a full page reload. It uses the Shopify Cart API endpoints available on every store, no custom app or plugin required.

The problem with the default Shopify cart page is that every quantity change requires a full page submission and reload. A cart drawer that also reloads the page defeats the purpose of the drawer pattern. The Cart API at /cart/change.js accepts a line item key and a new quantity, updates the server-side cart, and returns a JSON object with the updated totals, line items, and item count. This snippet calls that endpoint, updates the drawer UI optimistically before the response arrives, then reconciles with the real response to handle edge cases like out-of-stock quantities being rejected.

The snippet builds in three parts. The event listener attaches to quantity buttons inside the cart drawer. When a button is clicked, the current quantity updates immediately in the DOM as an optimistic update and a fetch request fires to /cart/change.js with the line item key and new quantity. On a successful response, the cart total, item count in the header bubble, and line item price are updated from the JSON response.

On failure due to a network error, rejected quantity, or rate limit, the original quantity is restored and an error state is applied to the button so the customer knows the update failed.

The remove logic is a special case of quantity update: setting quantity to zero removes the line item. The snippet handles empty cart state by checking the item count in the response and hiding or replacing the drawer content when the cart reaches zero items, prompting the customer to continue shopping.

Place the JavaScript in assets/cart-drawer.js and load it inside your cart drawer section schema using the Shopify asset_url filter with script_tag.

Tested on Shopify 2.0+ with Dawn, Sense, and Craft themes. Works with all standard Shopify cart API versions. Browse all Shopify JavaScript snippets at Code Snippets or see the Shopify Product Badge pattern for product card enhancements. Deployed in production on the Omnia Stores cart experience.

Related: Omnia Stores Shopify project — and browse the full code snippet library.

Chat on WhatsApp