Titouan Mathis

CTO at Studio Meta and ikko

Shopify AJAX cart drawer

19/08/2026 in #shopify #cart-drawer #progressive-enhancement

The series

Part 5 of 7 of the series 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 gives you the drawer and the AJAX wiring as components, on top of @studiometa/js-toolkit.

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

The drawer traps focus, closes on Escape and locks the background scroll; all three come from the native <dialog> element that Dialog enhances. Open it in the playground to edit it.

Table of content

The drawer is a Dialog

Dialog enhances a native <dialog> element. It opens with 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 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.

<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 is the hidden (offscreen) state -->
  <div data-component="ViewTransition"
    data-option-view-transition-name="cart-panel"
    data-option-leave-to="translate-x-full"
    class="absolute inset-y-0 right-0 w-screen max-w-sm overflow-y-auto p-6 bg-white translate-x-full">
    <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 Escape 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 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:

<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. The id for the added variant comes from the 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 on a shared parent opens the Dialog and drives the loader:

<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:

import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { class Action<T extends BaseProps = BaseProps>

Action class.

Action
, import DialogDialog, class Fetch<T extends BaseProps = BaseProps>

Fetch class.

Fetch
, class Transition<T extends BaseProps = BaseProps>

Transition class.

Transition
, import ViewTransitionViewTransition } from '@studiometa/ui';
import registerComponentsregisterComponents(class Action<T extends BaseProps = BaseProps>

Action class.

Action
, import DialogDialog, class Fetch<T extends BaseProps = BaseProps>

Fetch class.

Fetch
, class Transition<T extends BaseProps = BaseProps>

Transition class.

Transition
, import ViewTransitionViewTransition);

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.

module window
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
.function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>fetch = async (input: RequestInfo | URLinput, init: RequestInit | undefinedinit) => {
const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@sincev10.0.0
URL
(
typeof input: RequestInfo | URLinput === 'string' ? input: stringinput : input: Request | URLinput instanceof
var URL: {
    new (url: string | URL, base?: string | URL): URL;
    prototype: URL;
    canParse(url: string | URL, base?: string | URL): boolean;
    createObjectURL(obj: Blob | MediaSource): string;
    parse(url: string | URL, base?: string | URL): URL | null;
    revokeObjectURL(url: string): void;
}

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@sincev10.0.0
URL
? input: URLinput.URL.href: string

The href property of the URL interface is a string containing the whole URL.

MDN Reference

href
: input: Requestinput.Request.url: string

The url read-only property of the Request interface contains the URL of the request.

MDN Reference

url
,
'http://localhost', ); if (const url: URLurl.URL.pathname: string

The pathname property of the URL interface represents a location in a hierarchical structure.

MDN Reference

pathname
.String.endsWith(searchString: string, endPosition?: number): boolean

Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at endPosition – length(this). Otherwise returns false.

endsWith
('/cart/add.js')) {
const const id: stringid = init: RequestInit | undefinedinit.RequestInit.body?: BodyInit | null | undefined

A BodyInit object or null to set request's body.

body
instanceof
var FormData: {
    new (form?: HTMLFormElement, submitter?: HTMLElement | null): FormData;
    prototype: FormData;
}

The FormData interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods.

MDN Reference

FormData
?
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(init: RequestInit | undefinedinit.RequestInit.body?: FormData

A BodyInit object or null to set request's body.

body
.FormData.get(name: string): FormDataEntryValue | null

The get() method of the FormData interface returns the first value associated with a given key from within a FormData object.

MDN Reference

get
('id')) : '101';
/* update the in-memory cart, then return the added line item… */ return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
(
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer A function that transforms the results.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.@throws{TypeError} If a circular reference or a BigInt value is found.
stringify
({
id: stringid, quantity: numberquantity: 1, title: stringtitle: 'Cap', // …plus a `sections` object because the request carried a `sections` param.
sections: {
    'cart-drawer': any;
    'cart-count': any;
}
sections
: { 'cart-drawer': drawerSection(), 'cart-count': countSection() },
}), { ResponseInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'application/json' } }, ); } return realFetch(input: RequestInfo | URLinput, init: RequestInit | undefinedinit); };

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.

Where this goes next

Submitting the add-to-cart form now adds the selected variant, refreshes the drawer and count from one bundled request, and opens an accessible panel. The section-swapping underneath it all is the Section Rendering API example.

Next article

Next, Shopify predictive search adds a debounced, keyboard-navigable suggestions dropdown over the search endpoint.