---
date: 2026-07-31
title: Shopify AJAX cart drawer
description: Build a Shopify AJAX cart drawer with @studiometa/ui. Add to cart and open an accessible slide-in drawer with the Cart AJAX API and progressive enhancement.
tags: shopify, cart-drawer, ajax-cart, progressive-enhancement, studiometa-ui
---

# Shopify AJAX cart drawer

31/07/2026 in #shopify #cart-drawer #progressive-enhancement

> **The series**
>
> **Part 5 of 7** of the series [Building a Shopify storefront with @studiometa/ui](/articles/building-a-shopify-storefront-with-studiometa-ui).
>

A cart drawer confirms an add-to-cart in a panel that slides in, without leaving the page. It also has the most moving parts: an accessible overlay, a focus trap, a scroll lock, and a cart that stays in sync. [`@studiometa/ui`](https://ui.studiometa.dev) gives you the drawer and the AJAX wiring as components, on top of [`@studiometa/js-toolkit`](https://js-toolkit.studiometa.dev).

Add a product below. The item posts to the cart, the drawer refreshes and slides in, and the count updates:

```twig
<!-- demo.twig -->
<div
  data-component="Action"
  data-on:fetch-before="Transition(#cart-loader) -> target.enter()"
  data-on:fetch-after="Transition(#cart-loader) -> target.leave()"
  data-on:fetch-update="Dialog(#cart-dialog) -> target.open()"
  class="space-y-6">
  <header class="flex items-center justify-between gap-4">
    <span class="font-bold">My shop</span>
    <span
      id="cart-loader"
      data-component="Transition"
      data-option-enter-from="opacity-0"
      data-option-leave-to="opacity-0"
      data-option-leave-keep
      class="ml-auto text-sm text-current/60 opacity-0">
      Adding…
    </span>

    <!-- Open trigger: an Action that calls the Dialog's open(). -->
    <button
      type="button"
      data-component="Action"
      data-on:click="Dialog(#cart-dialog) -> target.open()"
      class="border-b border-current">
      Cart (<span id="cart-count">0</span>)
    </button>
  </header>

  <!-- The drawer is a native <dialog>. Dialog calls showModal(), so the browser
       traps focus, makes the rest of the page inert and restores focus on close.
       The backdrop and panel are ViewTransition children, batched into one
       coordinated transition; the slide/fade live in the CSS panel. -->
  <dialog
    id="cart-dialog"
    data-component="Action Dialog"
    data-on:cancel.prevent="Dialog.close()"
    data-on:click="event.target === $el && Dialog.close()"
    class="fixed inset-0 m-0 p-0 w-full h-full max-w-none max-h-none overflow-hidden bg-transparent">
    <div
      data-component="Action ViewTransition"
      data-on:click="Dialog(#cart-dialog) -> target.close()"
      data-option-view-transition-name="cart-backdrop"
      data-option-leave-to="opacity-0"
      class="fixed inset-0 bg-black/40 opacity-0"></div>

    <!-- Right-anchored panel; translate-x-full opacity-0 is the hidden state.
         The opacity-0 keeps WebKit from snapshotting the offscreen panel and
         dragging a ghost copy across on open. -->
    <div
      data-component="ViewTransition"
      data-option-view-transition-name="cart-panel"
      data-option-leave-to="translate-x-full opacity-0"
      class="absolute inset-y-0 right-0 w-screen max-w-sm overflow-y-auto p-6 bg-white dark:bg-zinc-800 shadow-2xl translate-x-full opacity-0">
      <div class="flex items-center justify-between mb-4">
        <h2 class="font-bold">Your cart</h2>
        <button
          type="button"
          data-component="Action"
          data-on:click="Dialog(#cart-dialog) -> target.close()"
          class="border-b border-current">
          Close
        </button>
      </div>
      <div id="cart-drawer">
        <p class="text-current/60">Your cart is empty.</p>
      </div>
    </div>
  </dialog>

  <main class="grid grid-cols-3 gap-4">
    <article class="p-4 border rounded space-y-2">
      <p>Cap · €25</p>
      <form action="/cart/add.js" method="post"
        data-component="Fetch"
        data-option-response="response.json().then((data) => Object.values(data.sections).filter(Boolean).join(''))">
        <input type="hidden" name="id" value="101" />
        <input type="hidden" name="quantity" value="1" />
        <input type="hidden" name="sections" value="cart-drawer,cart-count" />
        <button type="submit" class="border-b border-current">Add to cart</button>
      </form>
    </article>
    <article class="p-4 border rounded space-y-2">
      <p>Tote · €18</p>
      <form action="/cart/add.js" method="post"
        data-component="Fetch"
        data-option-response="response.json().then((data) => Object.values(data.sections).filter(Boolean).join(''))">
        <input type="hidden" name="id" value="102" />
        <input type="hidden" name="quantity" value="1" />
        <input type="hidden" name="sections" value="cart-drawer,cart-count" />
        <button type="submit" class="border-b border-current">Add to cart</button>
      </form>
    </article>
    <article class="p-4 border rounded space-y-2">
      <p>Mug · €12</p>
      <form action="/cart/add.js" method="post"
        data-component="Fetch"
        data-option-response="response.json().then((data) => Object.values(data.sections).filter(Boolean).join(''))">
        <input type="hidden" name="id" value="103" />
        <input type="hidden" name="quantity" value="1" />
        <input type="hidden" name="sections" value="cart-drawer,cart-count" />
        <button type="submit" class="border-b border-current">Add to cart</button>
      </form>
    </article>
  </main>
</div>
```

```css
/* demo.css */
/* The native ::backdrop cannot be class-transitioned; animate our own. */
dialog::backdrop { background: transparent; }

/* Pin the panel above the backdrop; some Chromium versions mis-order the
   top-layer view-transition snapshots and grey the panel out otherwise. */
::view-transition-group(cart-panel)    { z-index: 2; }
::view-transition-group(cart-backdrop) { z-index: 1; }

/* The panel slides in from / out to the right edge. */
@keyframes cart-slide-in  { from { transform: translateX(100%); } }
@keyframes cart-slide-out { to   { transform: translateX(100%); } }
::view-transition-new(cart-panel) { animation: 300ms ease-out both cart-slide-in; }
::view-transition-old(cart-panel) { animation: 300ms ease-in both cart-slide-out; }

/* Backdrop fades in / out. */
@keyframes cart-fade-in  { from { opacity: 0; } }
@keyframes cart-fade-out { to   { opacity: 0; } }
::view-transition-new(cart-backdrop) { animation: 300ms ease-out both cart-fade-in; }
::view-transition-old(cart-backdrop) { animation: 300ms ease-in both cart-fade-out; }
```

```ts
// demo.ts
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Fetch, Dialog, ViewTransition, Transition } from '@studiometa/ui';

// --- Simulated Cart AJAX API ------------------------------------------------
// Shopify's POST /cart/add.js returns the added line item (id, key, quantity,
// title, …), and when a `sections` parameter is sent it also includes a
// `sections` key with the rendered HTML for each requested section (bundled
// section rendering). We reproduce that here with an in-memory cart. Delete
// this block to hit the real endpoint.
type Line = { id: string; title: string; price: number; qty: number };
const catalog: Record<string, { title: string; price: number }> = {
  '101': { title: 'Cap', price: 25 },
  '102': { title: 'Tote', price: 18 },
  '103': { title: 'Mug', price: 12 },
};
const cart: Line[] = [];

function drawerSection() {
  if (!cart.length) {
    return '<div id="cart-drawer"><p class="text-current/60">Your cart is empty.</p></div>';
  }
  const items = cart
    .map(
      (l) =>
        `<li class="flex justify-between gap-4"><span>${l.title} × ${l.qty}</span><span>€${l.price * l.qty}</span></li>`,
    )
    .join('');
  return `<div id="cart-drawer"><ul class="space-y-2">${items}</ul></div>`;
}

function countSection() {
  const n = cart.reduce((sum, l) => sum + l.qty, 0);
  return `<span id="cart-count">${n}</span>`;
}

const realFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
  const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, 'http://localhost');
  if (url.pathname.endsWith('/cart/add.js')) {
    const body = init?.body;
    const id = body instanceof FormData ? String(body.get('id')) : '101';
    const product = catalog[id];
    let line = cart.find((l) => l.id === id);
    if (line) line.qty += 1;
    else if (product) {
      line = { id, title: product.title, price: product.price, qty: 1 };
      cart.push(line);
    }
    await new Promise((resolve) => setTimeout(resolve, 400)); // fake latency
    // /cart/add.js returns the added line item, plus a `sections` object because
    // the form sent a `sections` param (bundled section rendering).
    const payload = {
      id: line?.id,
      quantity: line?.qty,
      title: line?.title,
      sections: { 'cart-drawer': drawerSection(), 'cart-count': countSection() },
    };
    return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } });
  }
  return realFetch(input, init);
};
// ---------------------------------------------------------------------------

registerComponents(Action, Fetch, Dialog, ViewTransition, Transition);
```

The drawer traps focus, closes on <kbd>Escape</kbd> and locks the background scroll; all three come from the native `<dialog>` element that `Dialog` enhances. [Open it in the playground](https://ui.studiometa.dev/play/#html=eNrtVs2O2zYQvucppmqRtYGl7XXSINjYBtIUuRSLAE3QokdKoiTuUqRKUv7pqejD9NR7732UPEmHP7Jk1%2FuXHNODdylyZjjzzTczXOR8%2FQQgp5aSTNWNkkzaZfI6s1zJpDtR8rJgNqtIygql2TL5oKk03MmMvs6otkQomjM9BrICS3XJ7ATtMD0a%2F9cGLfDgQSYEo2t2ykTb4De68T2nQpVRP%2FcfQ33VMBnUM0GNWSamoRkjO%2FIiWeHmomLuxu6wEGwL3LLakMw7D9etsbzYYdR2w5iEkjbkuVdFZbQl96pKWpIqkSerqx2YSjWLqTsfiPoVAM%2BXySDaJG4f49%2BDcyChGrdFvHek0KpeJgpD4nZHZqcEPYDEqgeJ3TDWxMMYVi0Iba0Cy7aWmDr8z1qt0YHpixn0RldR8XWec1l%2B%2FP3PEHgEIXx8RQi8w4yA1bwsmb4EBDAQDWxFLWRUCINLBiGvZwZCBidASIQyba1VHZh21yAJwtZtSPZM7qOWl5ng2c0j%2BDMAJVUaE0dSiIuIxx6CN2gLRoEe%2B2xnqnUis4jIOMITXPdcnAYyerA8VB8Qh1zTDfKQG6AgqeVrBovg5moSQYqoIec2VyqnYjQ%2BB6M8iqlWG8N09Athp42BQmWtOYea3rCAtWbGgir8uqElAy4ZRkBl7o%2Bw3KMSKMd3Zdiks%2BhcTGl2k2vVeAWMjQmgmsFPnG16EkNWcZEjTOcojwXMcrwFiYU56mxlCuHkGCSe2b3iK%2B%2BWETxn0wLxAeEw4NJvv3n%2FPtzY8SNi8%2BSg0MJeSOJpckQkByKOIVRmaLnRbO1Fg8zEA9Bx4ohNXnISuAPL5RK%2BQTCePoVTql3j4FuPhWGWzKDGX4O%2FDSlaIaAK%2F2q6JRsi0WW%2FrMJSrZkuhNqQiuc5FlVaEg9bQwdsXITufkfoh3n6tDI5COyws6zRPOnTSSStWcxLR5xH9q2TwGHwqUB70%2BcHTWkxxfiH7edHXlaWYGYrpHWk66tAN4HMI9uA%2BN6EqzzHtIixsSi0Z3%2Fgfy%2Fr%2BqeBn1n6A7fgmjMYiRVXKWuxJ3o7qihMpt0oiZUi894aFntZOkkKZaWwKDPV7IBmWhlfe64bDTrh7am9K6f3psU7dndObsfrKEk0NUq0lsU8OZC0z4CjeAQisBuHy57QuzB0GvLC5XVT4UhGR%2FTNJX79xmVGXs5m2O9ojrLzrbgjffue7MB6%2BJiv0%2F2U98rV%2FMSc%2F0W1%2BHJAxLBzzwfCBwPqtiF1%2F6D67Cp8xMTyU8tp91EMJlP49oU0ALNvr35EDeFqunuPngsDzFxdsbqxu8li2py4ZL90Cz%2Fu%2FFisKd8%2FuUrNc3B%2FEENhyLPDxxnewTPBOmE8icGDxkmcY%2B13T8F5T5Jm9YY28M%2Ff8PGPv%2BbfDj3DR2%2BNlegStEymLoIpzfPJtUmgZrZSCEaDFdtjf5zct%2B7ZenQc6wrnK0oZ5Ei3QrvKvXqwY8jRyMmOYbmCd%2Bk1y%2BxkTUXLjN%2BeGOZ9MuNJwYV7bn%2BnFNapHE%2BuFZejs7PxeJgZLpvWRkKGlpZAKH2eJ%2BANL5OL2UUC0wdp%2FdpSabHSet2HanaO7zUHXDofvJgOzAVORnumTWuOAvdxHF%2BkgN0kVOoxq11eO8ZFynwegT4obFaBQRcvv1wGzf9n0Kcy6KotOwLNv1wCPfvSCbSYumG3ehIn4b8IzpDt&script=eNqtVv1uHDUQ%2F%2F%2BeYoiQdpfu7SWhIHRfIUpVQdXSqgkUFEWKb3fu1onX3vqj11N1EuIleAP%2B4i14kz4JY3u3uWsLohKRkqxnxjM%2Fz4x%2FY960Slt4AxpX3FjUZ4okEqU1sIWlVg0k3xrrKq4atGx0Y4ZWKXHLbTIZ8H7zaWm5kjk8RFvWOTzgTKhVDj9xXF9oJg2P6rvvj%2Fp2nHwORiMYDodwzhsnmMUKzhjFOH10%2BjOcPvve6z7px%2Fs7r1XLl5vEwLOn5xcwKsnjiFVVcWPo3NZpacDWCCSieIJLBG6xgZRXOdziJoeXjknL7Sb37uhDYA5vf%2F0jy4HJCtY1SmBwbTDkwVxDyzSjM6EGbsBQNskhMGEUcFkKV6EB5l3tbKE4sOa2Dkg0ygo1gfnu4sljWCoNyMqaxC8dGp%2BUbh%2BkCycrgZV31sviZi5XWQEvvK9Wq8qVSJ6ZhZr8xkBMEpphg43SG%2FA5KeABCkIdDlkT8oVQ5S1YBTXBj7iYAHLfKi5tMbCbFuGxz9eMuoBXYzDWB57EHN0tW81LWkrXLFBP4KXd9AvYTgYlJcASAuvbZgzPsVS6msa9OTn%2BN2ewnfvgA4Dk6PAoGd%2BZJ2esTfLe%2Bvgr2ObR6njP6kJZvDM7%2Buad2Zd7Zk%2Fcasfq2FvtINd2HPJweUVgLq%2BojZdOxmpUmq1Rn8fapFmAypeQfhYyLlCubB2l0DUjJNOKv6J0zg68zTB6OJhPWygFM2Z2YPG1HZZOU6Xt6OvDg%2FkvyukAw%2FcbNq3dFNNRO5%2BOyNGcbhXAln4jWt%2FahmB68xC1aFibhi%2BAVGQwm3cLgOup4H3QpcDXcOOMpas0XKBdI3X9irXD%2BwTNtEzOP38jipCvLfz1O%2FgVFXo7HQVlNHn7259eHvIIX8C%2BxUjw%2BXUegmcR2Q31WZokmT9Bl5zrf0iOEz1QclbicDM8PiBE4bQUwYkuG9eTwXanPKVy0u5XJ6ZJdikq6BrS5UlT45ocQnqAPuFeBJ%2FD4T46f5Q7eMG9xyH7U8b4MYa%2FToEzKdaay0qti6VfFgtapFFC3ndVZMnMRpZETrJ1NqcrzG0AdQfdaUFmEtfw4%2FPHqb%2BkagnBHGazGSTxIiVw0gnH3X9Om5ksvTVt7NVFrXHZ2xTkO4ektrYdj0ZED0zUythYId%2FWpC9aZmtJ%2FFcQU5gXRDVpssu5Sdb3e4S7UNWG8PqDnBR%2BMdlR8opUwWIH3UOlmwdEFwTxPJwl9RbFCm2a8Mr7H0c62PUUWdCGsgamueTVVTQg0ou035V86dMfrwJV2UOgtPEqi9b%2BmN46C3t8E8C9GRxFJQqDwaIL1x8V%2BgCeJ%2FOeVjqjohspHb300rDMI1seeaKMjgLE1pk6oojibfjL1oyY2lf%2BGY1XbqhtNRolXmHsW7QXvEHlbC%2FO4f7hYZZNgCh%2FyW4R%2FMyV5SZ4I9l%2FnZWEXTizPwTV4oa%2BYYElcwZ7j34zDbQmjsUPp%2Ba7kfaxebZbTrYRilUd%2BYe60PzxgE6oYnkn6wd3r%2FE3tlN1JYjyWIBO0yPy%2FJ%2FssAwNhPfYPO%2F04ZqT%2Bj022UaPXeU6ivDVeY6GXllUnkfnT38o4n0kWk27Q2V%2B6tXI6NgdCCWpLHbo7zKFSVjbCk5tTGHoSaZkQg%2BqbdbzfBfoHbvsUcXEz634yPq%2FfgaDD1%2BP6Se%2FCQnZ3zgXXDM%3D&style=eNqNkr9u2zAQxnc%2FxbcUiA0oVtJNXoL0BTpk6HoWzzIRiSeQtF3X6LvnSFuF5BqJN1G6%2B%2F78xOUCb1uGo2j3jKpaU%2F1uvPSoyTmJWDPqlkIooicXbLTi2KxAznYUGbLzkIN7xGI5M5ZaaUYSJ6THxsvOmQpZoCfPLq7wdzZbLvDTOkR178lxC1qLRkjnQWGFIB3jx9ZLZ3cd9uyDBgjobCjEG%2FZpfAYgSl%2B0dNQXe8uHUVgER33YSgya2aDxfBxZyi5C9OgPNnAuUVVXAkXK3z%2FU5GORl%2BZqp9X%2BFNYZ%2Fl3hObX5dG2oMx%2BvPQ0Q3v6lCa01HKBQNloYyxwvSs7rbbONYNOcY76883HjqdPxbJFXVToly8unM%2B%2BN%2BO6CvtX%2F9evhqSy%2FzdVazW9rJM9TcsU9Gv%2F3dnyYwDpd7op%2Bq%2FC9LLsApnD2WSv7af7bLKU1d2lq%2F2tJtRlAvw73ckMXzJnwTZ5pZIpTeqptPFYob9PLGxN41xufsBrfkHtwXeJ9Qetr1QmwoUFS%2FQC97mk%2F&theme=light) to edit it.

## The drawer is a Dialog

[`Dialog`](https://ui.studiometa.dev/components/Dialog/) enhances a native `<dialog>` element. It opens with [`showModal()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal), so the platform does the accessibility work for you: it traps focus, makes the rest of the page `inert`, restores focus on close, and paints the dialog in the top layer. On top of that `Dialog` adds a scroll lock (on by default) and coordinates the enter/leave animations. It is a JavaScript component that enhances the markup you render in Liquid, so you write a `<dialog>` with your own children.

There are no refs to wire. The component's element *is* the `<dialog>`; inside it you put whatever you want, here a fading backdrop and a right-anchored panel, each a [`ViewTransition`](https://ui.studiometa.dev/components/ViewTransition/) child so `Dialog` animates them together. The panel's edge and slide are yours to style (`absolute inset-y-0 right-0` plus a transform and keyframes); the library ships no drawer opinion.

```html
<button
  type="button"
  data-component="Action"
  data-on:click="Dialog(#cart-dialog) -> target.open()">
  Cart (<span id="cart-count">0</span>)
</button>

<dialog
  id="cart-dialog"
  data-component="Action Dialog"
  data-on:cancel.prevent="Dialog.close()"
  data-on:click="event.target === $el && Dialog.close()"
  class="fixed inset-0 m-0 p-0 w-full h-full max-w-none max-h-none overflow-hidden bg-transparent">
  <!-- fading backdrop -->
  <div
    data-component="Action ViewTransition"
    data-on:click="Dialog(#cart-dialog) -> target.close()"
    data-option-view-transition-name="cart-backdrop"
    data-option-leave-to="opacity-0"
    class="fixed inset-0 bg-black/40 opacity-0"></div>

  <!-- right-anchored panel; translate-x-full opacity-0 is the hidden state -->
  <div
    data-component="ViewTransition"
    data-option-view-transition-name="cart-panel"
    data-option-leave-to="translate-x-full opacity-0"
    class="absolute inset-y-0 right-0 w-screen max-w-sm overflow-y-auto p-6 bg-white translate-x-full opacity-0">
    <button type="button" data-component="Action" data-on:click="Dialog(#cart-dialog) -> target.close()">Close</button>
    <div id="cart-drawer">Your cart is empty.</div>
  </div>
</dialog>
```

The open button is an `Action` calling the dialog's `open()`; the close button and a backdrop click call `close()`; and <kbd>Escape</kbd> fires the dialog's native `cancel` event, which `data-on:cancel.prevent` turns into a `close()`. The `scrollLock` option is on by default; add `data-option-no-scroll-lock` to the `<dialog>` to opt out. The two `ViewTransition` children give the backdrop a fade and the panel a slide, batched into one coordinated transition, with the keyframes in your CSS. (The full demo markup fills in the Tailwind classes and the view-transition keyframes; this is the load-bearing structure.)

## Add to cart with the Cart AJAX API

Shopify's [Cart AJAX API](https://shopify.dev/docs/api/ajax/reference/cart) adds items with `POST /cart/add.js`. A drawer relies on **bundled section rendering**: pass a `sections` parameter and the JSON response includes a `sections` key with the rendered HTML of each section you asked for, in one round trip. So a single add-to-cart request can return both the added line and the re-rendered drawer.

The add-to-cart form is a `Fetch` component. It posts the variant `id` and `quantity`, plus the sections to refresh, and its `response` option pulls the section HTML out of the JSON:

```liquid
<form
  action="{{ routes.cart_add_url }}"
  method="post"
  data-component="Fetch"
  data-option-response="response.json().then((data) => Object.values(data.sections).filter(Boolean).join(''))">
  <input type="hidden" name="id" value="{{ variant.id }}">
  <input type="hidden" name="quantity" value="1">
  <input type="hidden" name="sections" value="cart-drawer,cart-count">
  <button type="submit">Add to cart</button>
</form>
```

`Fetch` swaps the returned `#cart-drawer` and `#cart-count` by `id`, exactly as in the [Section Rendering article](/articles/shopify-section-rendering-api-example). The `id` for the added variant comes from the [variant selector](/articles/shopify-variant-selector): its job is to keep that hidden input pointed at the right variant.

## Opening the drawer on add

The last piece connects the two components. `Fetch` emits a bubbling `fetch-update` event once the DOM is swapped, so an [`Action`](https://ui.studiometa.dev/components/Action/) on a shared parent opens the `Dialog` and drives the loader:

```html
<div
  data-component="Action"
  data-on:fetch-before="Transition(#cart-loader) -> target.enter()"
  data-on:fetch-after="Transition(#cart-loader) -> target.leave()"
  data-on:fetch-update="Dialog(#cart-dialog) -> target.open()">
  <!-- header with the open button and the <dialog>, plus the add-to-cart forms -->
</div>
```

Because `Fetch`'s events bubble, one `Action` on the wrapper handles every add-to-cart form on the page. `Dialog(#cart-dialog) -> target.open()` calls the drawer's `open()` method — the `(#cart-dialog)` selector scopes it to the drawer, so no other `Dialog` on the page opens — and `open()` is a no-op if it is already open, so adding a second item just refreshes the contents.

Register the five components this drawer uses once, in your theme's main script:

```js twoslash
// @noErrors
import { registerComponents } from '@studiometa/js-toolkit';
import { Action, Dialog, Fetch, Transition, ViewTransition } from '@studiometa/ui';

registerComponents(Action, Dialog, Fetch, Transition, ViewTransition);
```

## How the demo simulates it

The playground has no cart, so the demo mocks `POST /cart/add.js`: it keeps an in-memory cart, and returns what Shopify returns from that endpoint, the added line item plus a `sections` object holding the rendered `cart-drawer` and `cart-count` HTML. The added line is the item itself, with fields like `id`, `key`, `quantity` and `title`; a top-level `items` array is what `/cart.js` returns, not `/cart/add.js`.

```js twoslash
// @noErrors
window.fetch = async (input, init) => {
  const url = new URL(
    typeof input === 'string' ? input : input instanceof URL ? input.href : input.url,
    'http://localhost',
  );
  if (url.pathname.endsWith('/cart/add.js')) {
    const id = init.body instanceof FormData ? String(init.body.get('id')) : '101';
    /* update the in-memory cart, then return the added line item… */
    return new Response(
      JSON.stringify({
        id, quantity: 1, title: 'Cap',
        // …plus a `sections` object because the request carried a `sections` param.
        sections: { 'cart-drawer': drawerSection(), 'cart-count': countSection() },
      }),
      { headers: { 'content-type': 'application/json' } },
    );
  }
  return realFetch(input, init);
};
```

**Swap the mock for your store:** delete the `window.fetch` block. The form already posts to `/cart/add.js` with the `sections` parameter, which Shopify answers with real bundled sections, so the same markup drives the real cart. Use locale-aware URLs on a live theme: `{{ routes.cart_add_url }}` in Liquid, or `window.Shopify.routes.root + 'cart/add.js'` in JavaScript. You can point the section rendering at a specific page with a `sections_url` parameter (for example `/cart`) if the drawer markup only exists there.

> **Next article**
>
> Next, [Shopify predictive search](/articles/shopify-predictive-search) adds a debounced, keyboard-navigable suggestions dropdown over the search endpoint.
