Shop — Mini-cart Flyout
A polished e-commerce mini-cart drawer that flies in from the right when you add a product. It packs slide-in line items with thumbnails, quantity steppers, and per-line totals, a live free-shipping progress bar, a running subtotal, and view-cart plus secure-checkout actions. Built with vanilla JS, it features a focus trap, Escape and overlay dismissal, an animated badge, recompute-on-change totals, and a friendly empty state.
MCP
Code
:root {
--bg: #ffffff;
--surface: #f7f8fa;
--ink: #16181d;
--muted: #6b7280;
--brand: #3457ff;
--brand-d: #2742d6;
--sale: #e0245e;
--ok: #1f9d55;
--line: rgba(16, 18, 29, .1);
--line-2: rgba(16, 18, 29, .06);
--shadow: 0 20px 50px -12px rgba(16, 18, 29, .28);
--shadow-sm: 0 4px 14px -6px rgba(16, 18, 29, .25);
--radius: 16px;
--radius-sm: 10px;
--drawer-w: 400px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background:
radial-gradient(1200px 600px at 85% -10%, rgba(52, 87, 255, .08), transparent 60%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
a { color: inherit; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip-link {
position: absolute; left: 12px; top: -48px;
background: var(--ink); color: #fff;
padding: 10px 16px; border-radius: 10px;
z-index: 200; text-decoration: none; font-weight: 600;
transition: top .15s ease;
}
.skip-link:focus-visible { top: 12px; }
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--brand) 55%, white);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- Topbar ---------- */
.topbar {
position: sticky; top: 0; z-index: 60;
background: rgba(255, 255, 255, .82);
backdrop-filter: saturate(160%) blur(12px);
border-bottom: 1px solid var(--line);
}
.topbar__inner {
max-width: 1080px; margin: 0 auto;
display: flex; align-items: center; gap: 18px;
padding: 14px 20px;
}
.brand {
display: inline-flex; align-items: center; gap: 9px;
text-decoration: none; font-weight: 800; letter-spacing: -.02em;
font-size: 19px; color: var(--ink);
}
.brand__mark {
display: grid; place-items: center;
width: 36px; height: 36px; border-radius: 11px;
background: linear-gradient(150deg, var(--brand), var(--brand-d));
color: #fff; box-shadow: var(--shadow-sm);
}
.topnav {
display: flex; gap: 4px; margin-left: 10px;
}
.topnav a {
text-decoration: none; color: var(--muted);
font-weight: 600; font-size: 14px;
padding: 8px 12px; border-radius: 9px;
transition: color .15s ease, background .15s ease;
}
.topnav a:hover { color: var(--ink); background: var(--surface); }
.cart-btn {
margin-left: auto;
position: relative;
display: inline-flex; align-items: center; gap: 8px;
font: inherit; font-weight: 600; font-size: 14px;
color: var(--ink); cursor: pointer;
background: var(--bg);
border: 1px solid var(--line);
padding: 9px 16px 9px 13px; border-radius: 999px;
box-shadow: var(--shadow-sm);
transition: transform .12s ease, border-color .15s ease, box-shadow .15s ease;
}
.cart-btn:hover { border-color: color-mix(in srgb, var(--brand) 40%, var(--line)); }
.cart-btn:active { transform: translateY(1px); }
.cart-btn__count {
min-width: 20px; height: 20px; padding: 0 6px;
display: grid; place-items: center;
border-radius: 999px; font-size: 12px; font-weight: 700;
background: var(--brand); color: #fff;
transform: scale(0); transition: transform .22s cubic-bezier(.2, 1.4, .4, 1);
}
.cart-btn[data-has-items="true"] .cart-btn__count { transform: scale(1); }
.cart-btn.bump { animation: bump .35s ease; }
@keyframes bump {
35% { transform: scale(1.12); }
70% { transform: scale(.96); }
}
/* ---------- Shop ---------- */
.shop { max-width: 1080px; margin: 0 auto; padding: 0 20px 80px; outline: none; }
.hero { padding: 56px 0 34px; max-width: 640px; }
.hero__eyebrow {
display: inline-block; margin: 0 0 14px;
font-size: 12.5px; font-weight: 700; letter-spacing: .04em;
text-transform: uppercase; color: var(--brand);
background: color-mix(in srgb, var(--brand) 12%, white);
padding: 6px 12px; border-radius: 999px;
}
.hero h1 {
margin: 0 0 14px; font-size: clamp(2rem, 5vw, 3rem);
letter-spacing: -.03em; line-height: 1.08; font-weight: 800;
}
.hero__sub { margin: 0; color: var(--muted); font-size: 16px; max-width: 52ch; }
.hero__sub strong { color: var(--ink); }
.grid {
display: grid; gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.card {
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
display: flex; flex-direction: column;
transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
border-color: var(--line-2);
}
.card__media {
position: relative; aspect-ratio: 4 / 3;
display: grid; place-items: center;
}
.card__media svg { width: 56%; height: 56%; }
.card__badge {
position: absolute; top: 12px; left: 12px;
font-size: 11px; font-weight: 700; letter-spacing: .03em;
text-transform: uppercase;
background: var(--sale); color: #fff;
padding: 5px 9px; border-radius: 7px;
}
.card__body { padding: 14px 16px 18px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.card__cat { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.card__title { margin: 0; font-size: 15.5px; font-weight: 700; letter-spacing: -.01em; }
.rating { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); }
.rating__stars { color: #f5a623; letter-spacing: 1px; }
.card__foot { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 4px; }
.price { display: flex; align-items: baseline; gap: 7px; }
.price__now { font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
.price__was { font-size: 13px; color: var(--muted); text-decoration: line-through; }
.add-btn {
margin-left: auto;
display: inline-flex; align-items: center; gap: 6px;
font: inherit; font-weight: 600; font-size: 13.5px;
cursor: pointer; color: #fff;
background: var(--ink);
border: 0; padding: 9px 14px; border-radius: 10px;
transition: transform .12s ease, background .15s ease;
}
.add-btn:hover { background: var(--brand); }
.add-btn:active { transform: scale(.96); }
.add-btn svg { width: 15px; height: 15px; }
/* ---------- Overlay + Drawer ---------- */
.overlay {
position: fixed; inset: 0; z-index: 80;
background: rgba(16, 18, 29, .42);
opacity: 0; transition: opacity .28s ease;
}
.overlay.show { opacity: 1; }
.drawer {
position: fixed; top: 0; right: 0; z-index: 90;
height: 100dvh; width: min(var(--drawer-w), 100vw);
background: var(--bg);
box-shadow: var(--shadow);
display: flex; flex-direction: column;
transform: translateX(100%);
transition: transform .32s cubic-bezier(.32, .72, 0, 1);
visibility: hidden;
}
.drawer.open { transform: translateX(0); visibility: visible; }
.drawer__head {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px; border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.drawer__head h2 { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: -.01em; }
.drawer__count { color: var(--muted); font-weight: 600; }
.icon-btn {
display: grid; place-items: center;
width: 36px; height: 36px; border-radius: 10px;
background: transparent; border: 1px solid transparent;
color: var(--ink); cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.icon-btn:hover { background: var(--surface); border-color: var(--line); }
.ship {
padding: 16px 20px; border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.ship__msg { margin: 0 0 10px; font-size: 13px; color: var(--muted); }
.ship__msg strong { color: var(--ink); }
.ship__msg.done { color: var(--ok); }
.ship__track {
height: 7px; border-radius: 999px;
background: var(--surface); overflow: hidden;
box-shadow: inset 0 0 0 1px var(--line-2);
}
.ship__fill {
height: 100%; width: 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--brand), var(--brand-d));
transition: width .4s cubic-bezier(.32, .72, 0, 1), background .3s ease;
}
.ship__fill.done { background: linear-gradient(90deg, var(--ok), #15803d); }
.lines {
list-style: none; margin: 0; padding: 8px 20px;
overflow-y: auto; flex: 1;
}
.line {
display: grid;
grid-template-columns: 64px 1fr auto;
gap: 14px; align-items: start;
padding: 16px 0; border-bottom: 1px solid var(--line);
animation: lineIn .35s cubic-bezier(.2, 1, .3, 1) both;
}
.line.removing { animation: lineOut .28s ease forwards; }
@keyframes lineIn {
from { opacity: 0; transform: translateX(22px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes lineOut {
to { opacity: 0; transform: translateX(28px); height: 0; padding: 0; margin: 0; }
}
.line__thumb {
width: 64px; height: 64px; border-radius: 12px;
display: grid; place-items: center; flex-shrink: 0;
}
.line__thumb svg { width: 60%; height: 60%; }
.line__main { min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.line__title { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
.line__unit { font-size: 12px; color: var(--muted); }
.stepper {
display: inline-flex; align-items: center;
border: 1px solid var(--line); border-radius: 9px;
width: max-content; overflow: hidden;
}
.stepper button {
width: 28px; height: 28px;
display: grid; place-items: center;
background: transparent; border: 0; cursor: pointer;
font-size: 16px; color: var(--ink); line-height: 1;
transition: background .12s ease;
}
.stepper button:hover { background: var(--surface); }
.stepper button:disabled { color: color-mix(in srgb, var(--muted) 50%, white); cursor: not-allowed; }
.stepper output {
min-width: 30px; text-align: center;
font-size: 13px; font-weight: 700;
font-variant-numeric: tabular-nums;
}
.line__side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
.line__price { font-size: 14px; font-weight: 700; font-variant-numeric: tabular-nums; }
.line__remove {
font: inherit; font-size: 12px; font-weight: 600;
background: none; border: 0; cursor: pointer;
color: var(--muted); padding: 2px 0;
text-decoration: underline; text-underline-offset: 2px;
transition: color .15s ease;
}
.line__remove:hover { color: var(--sale); }
/* ---------- Empty state ---------- */
.empty {
flex: 1;
display: none;
flex-direction: column; align-items: center; justify-content: center;
text-align: center; gap: 6px; padding: 40px 24px;
}
.empty__art {
width: 84px; height: 84px; border-radius: 22px;
display: grid; place-items: center; margin-bottom: 10px;
color: var(--brand);
background: color-mix(in srgb, var(--brand) 9%, white);
}
.empty__title { margin: 0; font-size: 17px; font-weight: 700; }
.empty__sub { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
.drawer[data-empty="true"] .empty { display: flex; }
.drawer[data-empty="true"] .lines,
.drawer[data-empty="true"] .ship,
.drawer[data-empty="true"] .drawer__foot { display: none; }
/* ---------- Footer ---------- */
.drawer__foot {
flex-shrink: 0;
padding: 18px 20px calc(18px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: var(--bg);
display: flex; flex-direction: column; gap: 6px;
}
.totals {
display: flex; align-items: baseline; justify-content: space-between;
font-size: 14px; font-weight: 600;
}
.totals__val { font-size: 22px; font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.foot__note { margin: 0 0 8px; font-size: 12px; color: var(--muted); }
.foot__actions { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
.btn {
font: inherit; font-weight: 700; font-size: 14.5px;
cursor: pointer; border-radius: 12px;
padding: 13px 16px;
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
transition: transform .12s ease, background .15s ease, border-color .15s ease;
}
.btn:active { transform: translateY(1px); }
.btn--ghost {
background: var(--bg); color: var(--ink);
border: 1px solid var(--line);
}
.btn--ghost:hover { border-color: var(--ink); background: var(--surface); }
.btn--solid {
background: var(--brand); color: #fff; border: 0;
box-shadow: var(--shadow-sm);
}
.btn--solid:hover { background: var(--brand-d); }
.trust {
margin: 10px 0 0; text-align: center;
font-size: 12px; color: var(--muted);
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.trust svg { color: var(--ok); }
/* ---------- Toast ---------- */
.toast {
position: fixed; left: 50%; bottom: 26px; z-index: 120;
transform: translate(-50%, 130%);
background: var(--ink); color: #fff;
padding: 12px 18px; border-radius: 12px;
font-size: 13.5px; font-weight: 600;
box-shadow: var(--shadow);
opacity: 0; pointer-events: none;
transition: transform .3s cubic-bezier(.2, 1, .3, 1), opacity .3s ease;
max-width: calc(100vw - 32px);
}
.toast.show { transform: translate(-50%, 0); opacity: 1; }
/* ---------- Responsive ---------- */
@media (max-width: 640px) {
.topnav { display: none; }
.cart-btn__label { display: none; }
.hero { padding: 40px 0 28px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
:root { --drawer-w: 420px; }
}
@media (max-width: 400px) {
.foot__actions { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
/* ---------- Demo catalog (fictional) ---------- */
var PRODUCTS = [
{ id: "p1", title: "Aria Ceramic Mug", cat: "Kitchen", price: 18.0, was: 24.0, rating: 4.8, reviews: 214, badge: "Sale", tint: ["#fde7ef", "#fbcfe0"], ink: "#e0245e", shape: "mug" },
{ id: "p2", title: "Linen Throw Blanket", cat: "Home", price: 64.0, was: null, rating: 4.9, reviews: 88, badge: null, tint: ["#e7efff", "#cfe0ff"], ink: "#3457ff", shape: "blanket" },
{ id: "p3", title: "Maple Wall Clock", cat: "Decor", price: 42.0, was: 52.0, rating: 4.6, reviews: 137, badge: "Sale", tint: ["#fff4e2", "#ffe6bf"], ink: "#d97706", shape: "clock" },
{ id: "p4", title: "Terra Plant Pot", cat: "Garden", price: 29.0, was: null, rating: 4.7, reviews: 309, badge: "New", tint: ["#e6f7ee", "#c7efd9"], ink: "#1f9d55", shape: "pot" },
{ id: "p5", title: "Soy Candle — Cedar", cat: "Scent", price: 22.0, was: null, rating: 4.9, reviews: 451, badge: null, tint: ["#f1eeff", "#ddd6fe"], ink: "#7c3aed", shape: "candle" },
{ id: "p6", title: "Brass Desk Lamp", cat: "Lighting", price: 96.0, was: 120.0, rating: 4.5, reviews: 62, badge: "Sale", tint: ["#fff7da", "#ffeeb0"], ink: "#ca8a04", shape: "lamp" }
];
var SHIP_THRESHOLD = 75;
var MAX_QTY = 9;
/* ---------- SVG product silhouettes ---------- */
function svgFor(shape, color) {
var s = '<svg viewBox="0 0 48 48" fill="none" stroke="' + color + '" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">';
var paths = {
mug: '<path d="M12 16h22v16a6 6 0 0 1-6 6H18a6 6 0 0 1-6-6V16z"/><path d="M34 20h4a4 4 0 0 1 0 12h-4"/><path d="M18 10v3M24 10v3M30 10v3"/>',
blanket: '<rect x="9" y="12" width="30" height="24" rx="3"/><path d="M9 20h30M9 28h30M16 12v24M24 12v24M32 12v24"/>',
clock: '<circle cx="24" cy="24" r="14"/><path d="M24 16v8l6 4"/>',
pot: '<path d="M14 20h20l-2.5 16a3 3 0 0 1-3 2.6H19.5a3 3 0 0 1-3-2.6L14 20z"/><path d="M12 16h24v4H12z"/><path d="M24 16c0-5 4-7 4-10"/>',
candle: '<rect x="16" y="18" width="16" height="22" rx="2"/><path d="M24 18v-3"/><path d="M24 8c2 2 3 4 0 7-3-3-2-5 0-7z" fill="' + color + '" stroke="none"/>',
lamp: '<path d="M14 16h20l-4 10H18l-4-10z"/><path d="M24 26v12"/><path d="M16 40h16"/><path d="M24 16v-6h6"/>'
};
return s + (paths[shape] || paths.mug) + "</svg>";
}
function money(n) {
return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function stars(r) {
var full = Math.round(r);
return "★★★★★".slice(0, full) + "☆☆☆☆☆".slice(0, 5 - full);
}
/* ---------- State ---------- */
var cart = []; // { id, qty }
/* ---------- DOM refs ---------- */
var grid = document.getElementById("productGrid");
var cartBtn = document.getElementById("cartBtn");
var cartCount = document.getElementById("cartCount");
var cartLive = document.getElementById("cartLive");
var overlay = document.getElementById("overlay");
var drawer = document.getElementById("miniCart");
var closeBtn = document.getElementById("closeCart");
var emptyClose = document.getElementById("emptyClose");
var linesEl = document.getElementById("lines");
var drawerCount = document.getElementById("drawerCount");
var subtotalEl = document.getElementById("subtotal");
var shipMsg = document.getElementById("shipMsg");
var shipFill = document.getElementById("shipFill");
var shipBar = document.getElementById("shipBar");
var toastEl = document.getElementById("toast");
var viewCart = document.getElementById("viewCart");
var checkout = document.getElementById("checkout");
var lastFocused = null;
var toastTimer = null;
/* ---------- Toast ---------- */
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2400);
}
/* ---------- Render product grid ---------- */
function renderProducts() {
PRODUCTS.forEach(function (p) {
var card = document.createElement("article");
card.className = "card";
var was = p.was ? '<span class="price__was">' + money(p.was) + "</span>" : "";
var badge = p.badge ? '<span class="card__badge">' + p.badge + "</span>" : "";
card.innerHTML =
'<div class="card__media" style="background:linear-gradient(150deg,' + p.tint[0] + "," + p.tint[1] + ')">' +
badge + svgFor(p.shape, p.ink) +
"</div>" +
'<div class="card__body">' +
'<span class="card__cat">' + p.cat + "</span>" +
'<h3 class="card__title">' + p.title + "</h3>" +
'<span class="rating"><span class="rating__stars" aria-hidden="true">' + stars(p.rating) + "</span>" +
"<span>" + p.rating.toFixed(1) + " · " + p.reviews + " reviews</span></span>" +
'<div class="card__foot">' +
'<span class="price"><span class="price__now">' + money(p.price) + "</span>" + was + "</span>" +
'<button class="add-btn" type="button" data-add="' + p.id + '" aria-label="Add ' + p.title + ' to cart">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>Add' +
"</button>" +
"</div>" +
"</div>";
grid.appendChild(card);
});
}
/* ---------- Cart helpers ---------- */
function findProduct(id) {
for (var i = 0; i < PRODUCTS.length; i++) if (PRODUCTS[i].id === id) return PRODUCTS[i];
return null;
}
function cartLine(id) {
for (var i = 0; i < cart.length; i++) if (cart[i].id === id) return cart[i];
return null;
}
function totalQty() {
return cart.reduce(function (s, l) { return s + l.qty; }, 0);
}
function subtotal() {
return cart.reduce(function (s, l) {
var p = findProduct(l.id);
return s + (p ? p.price * l.qty : 0);
}, 0);
}
/* ---------- Add to cart ---------- */
function addToCart(id) {
var p = findProduct(id);
if (!p) return;
var line = cartLine(id);
if (line) {
if (line.qty >= MAX_QTY) { toast("Max quantity reached"); return; }
line.qty += 1;
} else {
cart.push({ id: id, qty: 1 });
}
updateBadge(true);
renderLines();
updateTotals();
toast(p.title + " added to cart");
openDrawer();
}
/* ---------- Badge ---------- */
function updateBadge(bump) {
var q = totalQty();
cartCount.textContent = q;
cartBtn.setAttribute("data-has-items", q > 0 ? "true" : "false");
cartBtn.setAttribute("aria-label", q > 0 ? "Cart, " + q + " item" + (q === 1 ? "" : "s") : "Cart, empty");
cartLive.textContent = q > 0 ? q + " item" + (q === 1 ? "" : "s") + " in cart" : "Cart is empty";
if (bump) {
cartBtn.classList.remove("bump");
void cartBtn.offsetWidth; // reflow to restart animation
cartBtn.classList.add("bump");
}
}
/* ---------- Render line items ---------- */
function renderLines() {
linesEl.innerHTML = "";
cart.forEach(function (l) {
var p = findProduct(l.id);
if (!p) return;
var li = document.createElement("li");
li.className = "line";
li.dataset.id = p.id;
li.innerHTML =
'<div class="line__thumb" style="background:linear-gradient(150deg,' + p.tint[0] + "," + p.tint[1] + ')">' +
svgFor(p.shape, p.ink) +
"</div>" +
'<div class="line__main">' +
'<p class="line__title">' + p.title + "</p>" +
'<span class="line__unit">' + money(p.price) + " each</span>" +
'<div class="stepper" aria-label="Quantity for ' + p.title + '">' +
'<button type="button" data-dec="' + p.id + '" aria-label="Decrease quantity"' + (l.qty <= 1 ? " disabled" : "") + ">−</button>" +
'<output aria-live="polite">' + l.qty + "</output>" +
'<button type="button" data-inc="' + p.id + '" aria-label="Increase quantity"' + (l.qty >= MAX_QTY ? " disabled" : "") + ">+</button>" +
"</div>" +
"</div>" +
'<div class="line__side">' +
'<span class="line__price">' + money(p.price * l.qty) + "</span>" +
'<button class="line__remove" type="button" data-remove="' + p.id + '">Remove</button>' +
"</div>";
linesEl.appendChild(li);
});
drawer.setAttribute("data-empty", cart.length === 0 ? "true" : "false");
}
/* ---------- Totals + free shipping bar ---------- */
function updateTotals() {
var q = totalQty();
var sub = subtotal();
drawerCount.textContent = "(" + q + ")";
subtotalEl.textContent = money(sub);
var pct = Math.min(100, (sub / SHIP_THRESHOLD) * 100);
shipFill.style.width = pct + "%";
var track = shipBar.querySelector(".ship__track");
track.setAttribute("aria-valuenow", Math.min(sub, SHIP_THRESHOLD).toFixed(0));
if (sub >= SHIP_THRESHOLD) {
shipMsg.innerHTML = "🎉 You unlocked <strong>free shipping!</strong>";
shipMsg.classList.add("done");
shipFill.classList.add("done");
} else {
var remain = SHIP_THRESHOLD - sub;
shipMsg.innerHTML = "Add <strong>" + money(remain) + "</strong> more for free shipping.";
shipMsg.classList.remove("done");
shipFill.classList.remove("done");
}
}
/* ---------- Qty + remove ---------- */
function changeQty(id, delta) {
var line = cartLine(id);
if (!line) return;
line.qty += delta;
if (line.qty < 1) { removeLine(id); return; }
if (line.qty > MAX_QTY) { line.qty = MAX_QTY; toast("Max quantity reached"); }
updateBadge(delta > 0);
renderLines();
updateTotals();
}
function removeLine(id) {
var p = findProduct(id);
var li = linesEl.querySelector('.line[data-id="' + id + '"]');
var commit = function () {
cart = cart.filter(function (l) { return l.id !== id; });
updateBadge(false);
renderLines();
updateTotals();
if (p) toast(p.title + " removed");
};
if (li) {
li.classList.add("removing");
var done = false;
var finish = function () { if (done) return; done = true; commit(); };
li.addEventListener("animationend", finish, { once: true });
setTimeout(finish, 400);
} else {
commit();
}
}
/* ---------- Drawer open/close + focus trap ---------- */
function getFocusable() {
return Array.prototype.slice.call(
drawer.querySelectorAll('button, [href], input, output, [tabindex]:not([tabindex="-1"])')
).filter(function (el) { return !el.disabled && el.offsetParent !== null; });
}
function openDrawer() {
if (drawer.classList.contains("open")) return;
lastFocused = document.activeElement;
overlay.hidden = false;
requestAnimationFrame(function () { overlay.classList.add("show"); });
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
cartBtn.setAttribute("aria-expanded", "true");
document.body.style.overflow = "hidden";
setTimeout(function () {
var f = getFocusable();
(f[0] || closeBtn).focus();
}, 60);
document.addEventListener("keydown", onKeydown);
}
function closeDrawer() {
if (!drawer.classList.contains("open")) return;
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
overlay.classList.remove("show");
cartBtn.setAttribute("aria-expanded", "false");
document.body.style.overflow = "";
document.removeEventListener("keydown", onKeydown);
setTimeout(function () { overlay.hidden = true; }, 300);
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function onKeydown(e) {
if (e.key === "Escape") { e.preventDefault(); closeDrawer(); return; }
if (e.key === "Tab") {
var f = getFocusable();
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
/* ---------- Event wiring ---------- */
grid.addEventListener("click", function (e) {
var btn = e.target.closest("[data-add]");
if (btn) addToCart(btn.getAttribute("data-add"));
});
linesEl.addEventListener("click", function (e) {
var inc = e.target.closest("[data-inc]");
var dec = e.target.closest("[data-dec]");
var rm = e.target.closest("[data-remove]");
if (inc) changeQty(inc.getAttribute("data-inc"), 1);
else if (dec) changeQty(dec.getAttribute("data-dec"), -1);
else if (rm) removeLine(rm.getAttribute("data-remove"));
});
cartBtn.addEventListener("click", openDrawer);
closeBtn.addEventListener("click", closeDrawer);
overlay.addEventListener("click", closeDrawer);
if (emptyClose) emptyClose.addEventListener("click", closeDrawer);
viewCart.addEventListener("click", function () { toast("Full cart page is illustrative only"); });
checkout.addEventListener("click", function () {
if (cart.length === 0) { toast("Your cart is empty"); return; }
toast("Demo checkout — no real payment. Subtotal " + money(subtotal()));
});
/* ---------- Init ---------- */
renderProducts();
renderLines();
updateBadge(false);
updateTotals();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Loomly — Mini-cart Flyout</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#shop">Skip to products</a>
<header class="topbar">
<div class="topbar__inner">
<a class="brand" href="#shop" aria-label="Loomly home">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 8l8-4 8 4-8 4-8-4z" /><path d="M4 8v8l8 4 8-4V8" /><path d="M12 12v8" />
</svg>
</span>
<span class="brand__name">Loomly</span>
</a>
<nav class="topnav" aria-label="Primary">
<a href="#shop">New</a>
<a href="#shop">Home</a>
<a href="#shop">Sale</a>
</nav>
<button class="cart-btn" id="cartBtn" type="button" aria-haspopup="dialog" aria-controls="miniCart" aria-expanded="false">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="9" cy="20" r="1.4" /><circle cx="18" cy="20" r="1.4" />
<path d="M2.5 3h2.2l2.1 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21.5 7H6.2" />
</svg>
<span class="cart-btn__label">Cart</span>
<span class="cart-btn__count" id="cartCount" aria-hidden="true">0</span>
<span class="sr-only" id="cartLive" aria-live="polite">Cart is empty</span>
</button>
</div>
</header>
<main id="shop" class="shop" tabindex="-1">
<section class="hero" aria-labelledby="heroTitle">
<p class="hero__eyebrow">Spring drop · free shipping over $75</p>
<h1 id="heroTitle">Everyday objects, beautifully made.</h1>
<p class="hero__sub">Tap <strong>Add to cart</strong> on any item — your mini-cart flies in from the right with a live free-shipping bar, quantity steppers, and a running subtotal.</p>
</section>
<section class="grid" aria-label="Featured products" id="productGrid"><!-- products injected by JS --></section>
</main>
<!-- Mini-cart -->
<div class="overlay" id="overlay" hidden></div>
<aside class="drawer" id="miniCart" role="dialog" aria-modal="true" aria-labelledby="drawerTitle" aria-hidden="true">
<div class="drawer__head">
<h2 id="drawerTitle">Your cart <span class="drawer__count" id="drawerCount">(0)</span></h2>
<button class="icon-btn" id="closeCart" type="button" aria-label="Close cart">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="ship" id="shipBar">
<p class="ship__msg" id="shipMsg">Add <strong>$75.00</strong> more for free shipping.</p>
<div class="ship__track" role="progressbar" aria-valuemin="0" aria-valuemax="75" aria-valuenow="0" aria-label="Free shipping progress">
<div class="ship__fill" id="shipFill"></div>
</div>
</div>
<ul class="lines" id="lines" aria-label="Items in cart"><!-- line items injected by JS --></ul>
<div class="empty" id="empty">
<div class="empty__art" aria-hidden="true">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="20" r="1.4" /><circle cx="18" cy="20" r="1.4" />
<path d="M2.5 3h2.2l2.1 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21.5 7H6.2" />
</svg>
</div>
<p class="empty__title">Your cart is empty</p>
<p class="empty__sub">Add something lovely to get started.</p>
<button class="btn btn--ghost" type="button" id="emptyClose">Keep shopping</button>
</div>
<footer class="drawer__foot" id="foot">
<div class="totals">
<span>Subtotal</span>
<span class="totals__val" id="subtotal">$0.00</span>
</div>
<p class="foot__note">Taxes & shipping calculated at checkout.</p>
<div class="foot__actions">
<button class="btn btn--ghost" type="button" id="viewCart">View cart</button>
<button class="btn btn--solid" type="button" id="checkout">
Checkout
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
<p class="trust">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l8 3v6c0 5-3.4 8.6-8 11-4.6-2.4-8-6-8-11V5l8-3z" /><path d="M9 12l2 2 4-4" /></svg>
Secure checkout · 30-day returns
</p>
</footer>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Mini-cart Flyout
A clean storefront with a six-product grid where every card has a CSS-gradient “product photo”, star rating, review count, and prominent price. Pressing Add on any item bumps the cart badge, slides the new line into a right-hand drawer, and animates a free-shipping progress bar toward the $75 threshold — a staple e-commerce conversion pattern.
Inside the drawer, each line shows a thumbnail, title, unit price, a working quantity stepper, and a per-line total. Adjusting quantity or removing an item recomputes the subtotal and the shipping bar in real time, and items animate out on removal. Once the cart empties, a friendly empty state replaces the list and footer.
The drawer is a proper modal dialog: it opens with a focus trap, closes on Escape, the overlay click, or the close button, restores focus to the cart button, and announces cart changes through an aria-live region. Everything is keyboard-usable with visible focus rings, and the layout collapses gracefully down to ~360px.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.