// Cart: quantity steppers, remove buttons and totals for every [data-cart] on the page. Prices come from
// data-price on each [data-line] and are summed in cents. Shipping is data-ship unless the subtotal reaches
// data-free-over, and [data-free-msg] / [data-free-bar] show how far off free shipping is. [data-badge]
// gets the item count. Changes are announced through the cart's polite [data-announce] region.
document.querySelectorAll('[data-cart]').forEach(function (cart) {
var money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
var cents = function (v) { return Math.round(parseFloat(v) * 100); };
var freeOver = cart.dataset.freeOver ? cents(cart.dataset.freeOver) : Infinity;
var fee = cents(cart.dataset.ship || 0);
var status = cart.querySelector('[data-announce]');
function put(sel, text) { cart.querySelectorAll(sel).forEach(function (el) { el.textContent = text; }); }
function render() {
var sub = 0, count = 0;
cart.querySelectorAll('[data-line]').forEach(function (line) {
var input = line.querySelector('[data-qty]'), min = +input.min || 1, max = +input.max || 99;
var qty = Math.min(max, Math.max(min, parseInt(input.value, 10) || min));
var total = cents(line.dataset.price) * qty;
input.value = qty; sub += total; count += qty;
line.querySelector('[data-line-total]').textContent = money.format(total / 100);
line.querySelector('[data-dec]').setAttribute('aria-disabled', qty <= min);
line.querySelector('[data-inc]').setAttribute('aria-disabled', qty >= max);
});
var ship = sub === 0 || sub >= freeOver ? 0 : fee;
put('[data-subtotal]', money.format(sub / 100));
put('[data-shipping]', ship ? money.format(ship / 100) : 'Free');
put('[data-total]', money.format((sub + ship) / 100));
put('[data-count]', count + (count === 1 ? ' item' : ' items'));
put('[data-badge]', String(count));
put('[data-free-msg]', sub >= freeOver ? 'You have unlocked free shipping.' : 'Spend ' + money.format((freeOver - sub) / 100) + ' more for free shipping.');
cart.querySelectorAll('[data-free-bar]').forEach(function (bar) {
var pct = Math.min(100, Math.round(sub / freeOver * 100));
bar.style.width = pct + '%';
bar.parentElement.setAttribute('aria-valuenow', pct);
});
cart.querySelectorAll('[data-filled]').forEach(function (el) { el.hidden = !count; });
cart.querySelectorAll('[data-empty]').forEach(function (el) { el.hidden = !!count; });
return { sub: sub, count: count };
}
function announce(text) { if (status) status.textContent = text; }
cart.addEventListener('click', function (e) {
var btn = e.target.closest('[data-inc],[data-dec],[data-remove]');
if (!btn) return;
var line = btn.closest('[data-line]'), name = line.dataset.name, input = line.querySelector('[data-qty]');
if (btn.hasAttribute('data-remove')) {
var next = line.nextElementSibling || line.previousElementSibling;
line.remove();
var r = render();
announce('Removed ' + name + '. ' + (r.count ? 'Subtotal ' + money.format(r.sub / 100) + '.' : 'Your cart is empty.'));
var target = r.count ? next.querySelector('[data-remove]') : cart.querySelector('[data-empty] [tabindex="-1"]');
if (target) target.focus();
return;
}
if (btn.getAttribute('aria-disabled') === 'true') return;
input.value = (parseInt(input.value, 10) || 1) + (btn.hasAttribute('data-inc') ? 1 : -1);
var s = render();
announce(name + ', quantity ' + input.value + '. Subtotal ' + money.format(s.sub / 100) + '.');
});
cart.addEventListener('change', function (e) {
if (!e.target.matches('[data-qty]')) return;
var s = render();
announce(e.target.closest('[data-line]').dataset.name + ', quantity ' + e.target.value + '. Subtotal ' + money.format(s.sub / 100) + '.');
});
render();
});
// Drawer: a [data-drawer-open] button opens the <dialog> named by its aria-controls with showModal(), which
// keeps focus inside and closes on Escape. The backdrop and [data-drawer-close] buttons close it too, and focus
// goes back to the button that opened it.
document.querySelectorAll('[data-drawer-open]').forEach(function (btn) {
var dialog = document.getElementById(btn.getAttribute('aria-controls'));
btn.addEventListener('click', function () { dialog.showModal(); dialog.dataset.opener = btn.id; });
});
document.querySelectorAll('dialog[data-drawer]').forEach(function (dialog) {
dialog.addEventListener('click', function (e) {
if (e.target === dialog || e.target.closest('[data-drawer-close]')) dialog.close();
});
dialog.addEventListener('close', function () {
var opener = document.getElementById(dialog.dataset.opener || '');
if (opener) opener.focus();
});
});
About this set
Two cart panels that open over the page instead of sending people to a separate cart. Right-side drawer opens from a Bag button with a count badge in a clothing shop header and slides in from the right with a free-shipping message and progress bar, line items with quantity steppers and remove links, a subtotal, shipping and total, a checkout button and a link to the full cart. Bottom sheet opens from a dark mini bar on a product card, rises from the bottom like a native phone sheet and ends with Keep shopping and Checkout buttons side by side. In the plain CSS and Tailwind versions both panels are a native dialog element opened with showModal(), so the browser keeps focus inside, closes on Escape and dims the page behind a backdrop; a click on the backdrop also closes it and focus returns to the button that opened it. The slide uses @starting-style with display transitions, so browsers without them simply show the panel. The Bootstrap version uses Bootstrap’s own offcanvas component in its end and bottom placements. Inside, the same cart script as the cart pages recalculates every total in cents and moves the shipping bar.
What’s included
Right-side drawer and bottom sheet
Native dialog with showModal() in CSS and Tailwind, Bootstrap offcanvas in the Bootstrap version
Slide-in animation with @starting-style, no animation library
Free-shipping progress bar that moves as quantities change
Header badge kept in step with the item count
Accessibility
Each panel is labelled by its heading and opens as a modal, so focus stays inside, Escape closes it and focus goes back to the opening button. The close buttons are named Close bag, quantity inputs have visible labels, and the free-shipping bar is a progressbar labelled by its message. Changes are announced in a polite status region, and the slide and progress transitions switch off under prefers-reduced-motion.
Customise it
Set --dr-accent, --dr-line and --dr-soft on .dr, and the panel width on .dr-drawer (Bootstrap: --bs-offcanvas-width). The free-shipping threshold and fee are the data-free-over and data-ship attributes on each [data-cart]. In Tailwind edit the dr-* colours in @theme and the w-[min(420px,100vw)] class.