Shopify AJAX cart drawer
19/08/2026 in #shopify #cart-drawer #progressive-enhancementThe 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.
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) => URLThe URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
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.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL ? input: URLinput.URL.href: stringThe href property of the URL interface is a string containing the whole URL.
href : input: Requestinput.Request.url: stringThe url read-only property of the Request interface contains the URL of the request.
url,
'http://localhost',
);
if (const url: URLurl.URL.pathname: stringThe pathname property of the URL interface represents a location in a hierarchical structure.
pathname.String.endsWith(searchString: string, endPosition?: number): booleanReturns 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 | undefinedA 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.
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?: FormDataA BodyInit object or null to set request's body.
body.FormData.get(name: string): FormDataEntryValue | nullThe get() method of the FormData interface returns the first value associated with a given key from within a FormData object.
get('id')) : '101';
/* update the in-memory cart, then return the added line item… */
return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe Response interface of the Fetch API represents the response to a request.
Response(
var JSON: JSONAn 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.
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.