Slide-over cart drawer
A cart panel that slides in from the right with a backdrop, line items, quantity steppers and a live subtotal.
Tailwind CSSModalscartdrawerslide-overecommerce
Live preview
Code
<button id="cart-open" type="button" class="rounded-lg border border-slate-300 px-4 py-2 text-sm">
Cart (<span id="cart-count">0</span>)
</button>
<div id="cart-backdrop" class="fixed inset-0 z-40 hidden bg-slate-900/50"></div>
<aside id="cart-drawer" role="dialog" aria-modal="true" aria-labelledby="cart-title" inert
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-sm translate-x-full flex-col bg-white shadow-xl transition-transform duration-300">
<header class="flex items-center justify-between border-b border-slate-200 p-4">
<h2 id="cart-title" class="font-semibold">Your cart</h2>
<button id="cart-close" type="button" class="text-sm text-slate-500">Close</button>
</header>
<ul id="cart-list" class="flex-1 divide-y divide-slate-100 overflow-y-auto px-4"></ul>
<footer class="border-t border-slate-200 p-4">
<p class="flex justify-between font-medium"><span>Subtotal</span><span id="cart-total" aria-live="polite"></span></p>
<a href="/checkout" class="mt-3 block rounded-lg bg-slate-900 py-2.5 text-center font-semibold text-white">Checkout</a>
</footer>
</aside>
const LOCALE = 'en-US', CURRENCY = 'USD';
const money = new Intl.NumberFormat(LOCALE, { style: 'currency', currency: CURRENCY });
let items = [
{ id: 1, name: 'Canvas bag', cents: 4800, qty: 1 },
{ id: 2, name: 'Enamel mug', cents: 1500, qty: 2 },
];
const $ = (id) => document.getElementById(id);
const drawer = $('cart-drawer'), list = $('cart-list');
function render() {
list.replaceChildren(...items.map((it) => {
const li = document.createElement('li');
li.className = 'flex items-center justify-between gap-3 py-4 text-sm';
li.innerHTML = `<span class="flex-1"></span>
<span class="flex items-center gap-2">
<button type="button" data-id="${it.id}" data-d="-1" aria-label="Decrease quantity">-</button>
<output>${it.qty}</output>
<button type="button" data-id="${it.id}" data-d="1" aria-label="Increase quantity">+</button>
</span>
<span class="w-16 text-right">${money.format((it.cents * it.qty) / 100)}</span>`;
li.firstElementChild.textContent = it.name; // never inject names as HTML
return li;
}));
$('cart-total').textContent = money.format(items.reduce((s, i) => s + i.cents * i.qty, 0) / 100);
$('cart-count').textContent = items.reduce((s, i) => s + i.qty, 0);
}
list.addEventListener('click', (e) => {
const b = e.target.closest('button[data-id]');
if (!b) return;
items = items.map((i) => (i.id == b.dataset.id ? { ...i, qty: i.qty + Number(b.dataset.d) } : i)).filter((i) => i.qty > 0);
render();
});
function setOpen(open) {
drawer.classList.toggle('translate-x-full', !open);
drawer.inert = !open;
$('cart-backdrop').classList.toggle('hidden', !open);
document.body.classList.toggle('overflow-hidden', open);
(open ? $('cart-close') : $('cart-open')).focus();
}
$('cart-open').addEventListener('click', () => setOpen(true));
$('cart-close').addEventListener('click', () => setOpen(false));
$('cart-backdrop').addEventListener('click', () => setOpen(false));
document.addEventListener('keydown', (e) => e.key === 'Escape' && !drawer.inert && setOpen(false));
render();
How it works
What it solves
Opening a cart on a separate page breaks the shopping flow. A slide-over lets shoppers review items, change quantities and keep browsing, and it must behave like any dialog: contained focus, an Escape exit and a readable total.
How it works
The drawer is an aside with role="dialog" and aria-modal, positioned fixed on the right and moved with translate-x-full until opened. A sibling backdrop fades in with the same toggle. Line items are rendered from an array of objects with a price in cents; each row has minus and plus buttons wired through event delegation on the list, so new rows need no extra listeners. After every change the script recalculates the subtotal in integer cents and formats it with Intl.NumberFormat, avoiding floating point drift. Quantity zero removes the row. The count in the header button is updated, and the total sits in an aria-live="polite" region so changes are announced. Escape and the backdrop close the panel and focus returns to the cart button.
Adapting it
Replace the seeded array with data from your cart API, and post changes back inside the click handler. Add a free shipping bar by comparing the subtotal to a threshold. Currency and locale are two constants at the top.
Pitfalls
- Store money as integers; floats add cents errors.
- Lock page scroll while the drawer is open.
- Do not render product names with
innerHTML.
How to use it
- Paste
cart-drawer.htmlinto your layout and keep the ids. - Load
cart-drawer.jswithdefer; replace theitemsarray with your cart data. - Set
LOCALEandCURRENCYfor your shop. - After each quantity change, send the update to your server in
change().
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.