Pages Medium
QR Mobile Menu
Mobile-first restaurant menu opened from a table QR code — sticky scroll-spy category bar, single-column dish list with chips, and a floating cart pill that opens a bottom-sheet order summary.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(44, 26, 14, 0.08), 0 2px 6px rgba(44, 26, 14, 0.06);
--shadow-2: 0 8px 24px rgba(44, 26, 14, 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
background: linear-gradient(180deg, var(--cream-2) 0%, var(--cream) 100%);
color: var(--ink);
min-height: 100vh;
padding: 28px 16px;
display: flex;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
/* ── Phone frame ── */
.phone {
width: 380px;
max-width: 100%;
height: min(820px, calc(100vh - 56px));
background: var(--ink);
border-radius: 38px;
padding: 10px;
position: relative;
box-shadow: 0 20px 60px rgba(44, 26, 14, 0.35), inset 0 0 0 1px rgba(250, 247, 241, 0.06);
}
.phone-bezel {
position: absolute;
left: 50%;
top: 18px;
transform: translateX(-50%);
width: 110px;
height: 24px;
background: var(--ink);
border-radius: 999px;
display: grid;
place-items: center;
z-index: 2;
}
.phone-notch {
width: 12px;
height: 12px;
background: var(--forest-d);
border-radius: 999px;
}
.screen {
position: relative;
height: 100%;
background: var(--cream);
border-radius: 30px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Header ── */
.qr-head {
padding: 46px 20px 14px;
background: var(--bone);
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-shrink: 0;
}
.qr-kicker {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--terracotta);
font-weight: 600;
margin-bottom: 4px;
}
.qr-head h1 {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.qr-glyph {
font-size: 1.4rem;
color: var(--gold);
}
/* ── Tabs (sticky) ── */
.qr-tabs {
position: sticky;
top: 0;
z-index: 10;
background: var(--bone);
display: flex;
gap: 4px;
padding: 10px 18px 10px 12px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
overflow-x: auto;
scroll-padding-right: 18px;
scrollbar-width: none;
flex-shrink: 0;
}
.qr-tabs::-webkit-scrollbar {
display: none;
}
.qr-tab {
flex: 0 0 auto;
background: transparent;
border: 1px solid transparent;
color: var(--ink-2);
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.qr-tab:hover {
background: var(--cream-2);
}
.qr-tab.is-active {
background: var(--forest);
color: var(--bone);
}
/* ── Scrollable list ── */
.qr-list {
flex: 1;
overflow-y: auto;
padding: 8px 16px 96px;
}
.qr-section {
margin-top: 18px;
scroll-margin-top: 60px;
}
.qr-section:first-child {
margin-top: 8px;
}
.qr-section-title {
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 700;
margin: 12px 0 8px;
}
.dish {
display: grid;
grid-template-columns: 1fr 36px;
gap: 12px;
padding: 14px 4px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
align-items: start;
}
.dish:last-child {
border-bottom: none;
}
.dish-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 4px;
}
.dish-name {
font-size: 0.95rem;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.005em;
}
.dish-price {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.85rem;
color: var(--terracotta-d);
white-space: nowrap;
}
.dish-desc {
font-size: 0.78rem;
color: var(--ink-2);
line-height: 1.45;
}
.dish-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.chip {
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
background: var(--cream-2);
color: var(--ink-2);
}
.chip[data-tone="veg"] {
background: rgba(79, 122, 58, 0.16);
color: var(--success);
}
.chip[data-tone="gf"] {
background: rgba(201, 168, 76, 0.2);
color: #8a7325;
}
.chip[data-tone="hot"] {
background: rgba(179, 67, 42, 0.16);
color: var(--danger);
}
.add-btn {
align-self: center;
width: 36px;
height: 36px;
border-radius: 999px;
border: 1.5px solid var(--forest);
background: transparent;
color: var(--forest);
font-family: inherit;
font-size: 1.3rem;
font-weight: 700;
cursor: pointer;
display: grid;
place-items: center;
line-height: 1;
transition: background 0.15s, color 0.15s, transform 0.1s;
}
.add-btn:hover {
background: var(--forest);
color: var(--bone);
}
.add-btn:active {
transform: scale(0.92);
}
.add-btn.is-flash {
background: var(--gold);
border-color: var(--gold);
color: var(--ink);
animation: addFlash 0.45s ease;
}
@keyframes addFlash {
0% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
/* ── Cart pill ── */
.cart-pill {
position: absolute;
bottom: 18px;
left: 16px;
right: 16px;
background: var(--forest);
color: var(--bone);
border: none;
border-radius: 999px;
padding: 13px 18px;
display: grid;
grid-template-columns: 28px 1fr auto;
align-items: center;
gap: 12px;
font-family: inherit;
font-size: 0.92rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 12px 30px rgba(44, 26, 14, 0.32);
z-index: 12;
animation: pillIn 0.25s ease;
}
@keyframes pillIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.cart-pill-count {
background: var(--gold);
color: var(--ink);
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
font-family: var(--font-mono);
font-size: 0.86rem;
font-weight: 700;
}
.cart-pill-label {
text-align: left;
}
.cart-pill-total {
font-family: var(--font-mono);
background: rgba(250, 247, 241, 0.16);
padding: 4px 10px;
border-radius: 999px;
font-size: 0.82rem;
}
/* ── Sheet ── */
.sheet-overlay {
position: absolute;
inset: 0;
background: rgba(44, 26, 14, 0.45);
z-index: 14;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: var(--bone);
border-radius: 24px 24px 0 0;
z-index: 15;
padding: 8px 18px 28px;
max-height: min(80%, calc(100% - 160px));
display: flex;
flex-direction: column;
animation: sheetIn 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
box-shadow: 0 -16px 40px rgba(44, 26, 14, 0.18);
}
@keyframes sheetIn {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.sheet-grip {
width: 36px;
height: 4px;
background: rgba(44, 26, 14, 0.2);
border-radius: 999px;
margin: 0 auto 8px;
}
.sheet-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sheet-head h2 {
font-family: var(--font-display);
font-size: 1.3rem;
font-weight: 700;
}
.sheet-x {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: 999px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: var(--ink-2);
cursor: pointer;
}
.sheet-lines {
list-style: none;
flex: 1;
overflow-y: auto;
padding-bottom: 6px;
}
.sheet-line {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(44, 26, 14, 0.06);
}
.sheet-line-name {
font-size: 0.88rem;
font-weight: 600;
}
.sheet-line-meta {
font-size: 0.7rem;
color: var(--warm-gray);
margin-top: 2px;
}
.sheet-line-qty {
display: inline-flex;
align-items: center;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: 999px;
padding: 2px;
gap: 2px;
}
.sheet-line-qty button {
width: 22px;
height: 22px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--ink-2);
font-family: inherit;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
display: grid;
place-items: center;
}
.sheet-line-qty button:hover {
background: var(--cream-2);
}
.sheet-line-qty span {
min-width: 18px;
text-align: center;
font-weight: 700;
font-size: 0.78rem;
}
.sheet-line-price {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.86rem;
min-width: 56px;
text-align: right;
}
.sheet-totals {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0 8px;
border-top: 1px dashed rgba(44, 26, 14, 0.18);
margin-top: 8px;
}
.sheet-totals div {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--ink-2);
}
.sheet-totals dd {
font-family: var(--font-mono);
font-weight: 600;
}
.sheet-totals .big {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(44, 26, 14, 0.16);
font-size: 1rem;
font-weight: 700;
color: var(--ink);
}
.sheet-totals .big dd {
font-size: 1.15rem;
}
.sheet-cta {
margin-top: 12px;
background: var(--forest);
color: var(--bone);
border: none;
border-radius: 999px;
padding: 13px;
font-family: inherit;
font-size: 0.94rem;
font-weight: 700;
cursor: pointer;
}
.sheet-cta:hover {
background: var(--forest-d);
}
/* Visibility guard: honor the [hidden] attribute over base display */
.cart-pill[hidden],
.sheet[hidden] {
display: none;
}const TAX_RATE = 0.0825;
const SECTIONS = [
{
id: "entradas",
title: "Entradas",
items: [
{
id: "pan",
name: "Pan masa madre",
desc: "Sourdough, smoked olive oil, sea salt.",
price: 8,
chips: [{ label: "Veg", tone: "veg" }],
},
{
id: "burrata",
name: "Burrata huerta",
desc: "Heirloom tomato, garden basil, focaccia crumble.",
price: 16,
chips: [{ label: "Veg", tone: "veg" }],
},
{
id: "pulpo",
name: "Pulpo brasa",
desc: "Charred octopus, smoked paprika potato.",
price: 19,
chips: [{ label: "GF", tone: "gf" }],
},
{
id: "croquetas",
name: "Croquetas jamón",
desc: "Six pieces, lemon aioli.",
price: 14,
chips: [],
},
],
},
{
id: "principales",
title: "Principales",
items: [
{
id: "ribeye",
name: "Ribeye 14oz",
desc: "Dry-aged 28 days, bone marrow butter.",
price: 48,
chips: [{ label: "GF", tone: "gf" }],
},
{
id: "branzino",
name: "Branzino entero",
desc: "Whole sea bass, fennel, preserved lemon.",
price: 38,
chips: [{ label: "GF", tone: "gf" }],
},
{
id: "risotto",
name: "Risotto hongos",
desc: "Carnaroli rice, wild mushrooms, parmesan.",
price: 26,
chips: [{ label: "Veg", tone: "veg" }],
},
{
id: "pollo",
name: "Pollo carbón",
desc: "Half free-range chicken, garlic confit.",
price: 28,
chips: [{ label: "GF", tone: "gf" }],
},
{
id: "pappardelle",
name: "Pappardelle ragú",
desc: "Hand-cut pasta, slow lamb shoulder.",
price: 24,
chips: [{ label: "Spicy", tone: "hot" }],
},
],
},
{
id: "postres",
title: "Postres",
items: [
{
id: "tarta",
name: "Tarta de queso quemada",
desc: "Basque burnt cheesecake, salted caramel.",
price: 11,
chips: [{ label: "Veg", tone: "veg" }],
},
{
id: "olive",
name: "Olive oil cake",
desc: "Crème fraîche, candied orange.",
price: 10,
chips: [{ label: "Veg", tone: "veg" }],
},
{
id: "ganache",
name: "Chocolate ganache",
desc: "Hazelnut praline, espresso ice.",
price: 12,
chips: [
{ label: "Veg", tone: "veg" },
{ label: "GF", tone: "gf" },
],
},
],
},
{
id: "bebidas",
title: "Bebidas",
items: [
{
id: "vermut",
name: "Vermut casa",
desc: "House vermouth on tap.",
price: 9,
chips: [],
},
{
id: "negroni",
name: "Negroni sbagliato",
desc: "Campari, sweet vermouth, sparkling wine.",
price: 14,
chips: [],
},
{
id: "spritz",
name: "Spritz",
desc: "Aperol, prosecco, soda.",
price: 13,
chips: [],
},
{
id: "tinto",
name: "Tinto natural",
desc: "Rotating natural red, ask your server.",
price: 12,
chips: [],
},
],
},
];
const listEl = document.getElementById("list");
const tabs = document.querySelectorAll(".qr-tab");
const cartPill = document.getElementById("cartPill");
const cartCount = document.getElementById("cartCount");
const cartTotal = document.getElementById("cartTotal");
const sheet = document.getElementById("sheet");
const sheetOverlay = document.getElementById("sheetOverlay");
const sheetLines = document.getElementById("sheetLines");
const sheetClose = document.getElementById("sheetClose");
const sheetCta = document.getElementById("sheetCta");
const sSubtotal = document.getElementById("sSubtotal");
const sTax = document.getElementById("sTax");
const sTotal = document.getElementById("sTotal");
let cart = []; // { id, name, price, qty }
function money(v) {
return `$${v.toFixed(2)}`;
}
function renderList() {
listEl.innerHTML = SECTIONS.map(
(section) => `
<section class="qr-section" id="${section.id}" data-section>
<h2 class="qr-section-title">${section.title}</h2>
${section.items
.map(
(item) => `
<article class="dish">
<div>
<div class="dish-row">
<span class="dish-name">${item.name}</span>
<span class="dish-price">$${item.price.toFixed(2)}</span>
</div>
<p class="dish-desc">${item.desc}</p>
${
item.chips.length
? `<div class="dish-chips">${item.chips
.map((c) => `<span class="chip" data-tone="${c.tone}">${c.label}</span>`)
.join("")}</div>`
: ""
}
</div>
<button class="add-btn" data-id="${item.id}" data-name="${item.name}"
data-price="${item.price}" aria-label="Add ${item.name}">+</button>
</article>`
)
.join("")}
</section>`
).join("");
}
function renderCart() {
const count = cart.reduce((n, l) => n + l.qty, 0);
const subtotal = cart.reduce((s, l) => s + l.price * l.qty, 0);
const tax = subtotal * TAX_RATE;
cartCount.textContent = count;
cartTotal.textContent = money(subtotal + tax);
cartPill.hidden = count === 0;
sSubtotal.textContent = money(subtotal);
sTax.textContent = money(tax);
sTotal.textContent = money(subtotal + tax);
sheetLines.innerHTML = cart
.map(
(l) => `
<li class="sheet-line" data-id="${l.id}">
<div>
<p class="sheet-line-name">${l.name}</p>
<p class="sheet-line-meta">${money(l.price)} each</p>
</div>
<div class="sheet-line-qty">
<button data-action="dec" aria-label="Decrease">−</button>
<span>${l.qty}</span>
<button data-action="inc" aria-label="Increase">+</button>
</div>
<span class="sheet-line-price">${money(l.price * l.qty)}</span>
</li>`
)
.join("");
}
listEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-id]");
if (!btn || !btn.classList.contains("add-btn")) return;
const id = btn.dataset.id;
const existing = cart.find((l) => l.id === id);
if (existing) existing.qty += 1;
else
cart.push({
id,
name: btn.dataset.name,
price: Number(btn.dataset.price),
qty: 1,
});
btn.classList.remove("is-flash");
void btn.offsetWidth;
btn.classList.add("is-flash");
renderCart();
});
sheetLines.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const li = btn.closest("[data-id]");
if (!li) return;
const line = cart.find((l) => l.id === li.dataset.id);
if (!line) return;
if (btn.dataset.action === "inc") line.qty += 1;
if (btn.dataset.action === "dec") {
line.qty -= 1;
if (line.qty <= 0) cart = cart.filter((l) => l.id !== line.id);
}
renderCart();
if (cart.length === 0) closeSheet();
});
function openSheet() {
if (cart.length === 0) return;
sheet.hidden = false;
sheetOverlay.hidden = false;
}
function closeSheet() {
sheet.hidden = true;
sheetOverlay.hidden = true;
}
cartPill.addEventListener("click", openSheet);
sheetClose.addEventListener("click", closeSheet);
sheetOverlay.addEventListener("click", closeSheet);
sheetCta.addEventListener("click", () => {
sheetCta.textContent = "Sent ✓";
setTimeout(() => {
cart = [];
sheetCta.textContent = "Send to kitchen";
renderCart();
closeSheet();
}, 900);
});
tabs.forEach((tab) =>
tab.addEventListener("click", () => {
const target = document.getElementById(tab.dataset.target);
if (!target) return;
tabs.forEach((t) => t.classList.toggle("is-active", t === tab));
listEl.scrollTo({
top: target.offsetTop - 8,
behavior: "smooth",
});
})
);
// Scroll spy: active tab follows current section
function spy() {
const sections = listEl.querySelectorAll("[data-section]");
const top = listEl.scrollTop;
let active = sections[0];
sections.forEach((s) => {
if (s.offsetTop - 16 <= top + 80) active = s;
});
if (active) tabs.forEach((t) => t.classList.toggle("is-active", t.dataset.target === active.id));
}
listEl.addEventListener("scroll", spy);
renderList();
renderCart();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>QR Menu · Casa Olivar</title>
</head>
<body>
<div class="phone">
<div class="phone-bezel">
<div class="phone-notch"></div>
</div>
<div class="screen">
<header class="qr-head">
<div>
<p class="qr-kicker">Table 7 · Casa Olivar</p>
<h1>Dinner menu</h1>
</div>
<span class="qr-glyph" aria-hidden="true">⌧</span>
</header>
<nav class="qr-tabs" aria-label="Menu categories">
<button class="qr-tab is-active" data-target="entradas">Entradas</button>
<button class="qr-tab" data-target="principales">Principales</button>
<button class="qr-tab" data-target="postres">Postres</button>
<button class="qr-tab" data-target="bebidas">Bebidas</button>
</nav>
<main class="qr-list" id="list"></main>
<button class="cart-pill" id="cartPill" hidden>
<span class="cart-pill-count" id="cartCount">0</span>
<span class="cart-pill-label">View order</span>
<span class="cart-pill-total" id="cartTotal">$0.00</span>
</button>
<div class="sheet-overlay" id="sheetOverlay" hidden></div>
<section class="sheet" id="sheet" hidden aria-label="Order">
<div class="sheet-grip"></div>
<header class="sheet-head">
<h2>Your order</h2>
<button class="sheet-x" id="sheetClose" aria-label="Close">
<svg viewBox="0 0 24 24" width="14" height="14">
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
/>
</svg>
</button>
</header>
<ul class="sheet-lines" id="sheetLines"></ul>
<dl class="sheet-totals">
<div><dt>Subtotal</dt><dd id="sSubtotal">$0.00</dd></div>
<div><dt>Tax (8.25%)</dt><dd id="sTax">$0.00</dd></div>
<div class="big"><dt>Total</dt><dd id="sTotal">$0.00</dd></div>
</dl>
<button class="sheet-cta" id="sheetCta">Send to kitchen</button>
</section>
</div>
</div>
<script src="script.js"></script>
</body>
</html>QR Mobile Menu
The menu a diner sees after scanning a table QR. Single-column, mobile-first layout with a sticky category bar that scroll-spies the active section, an “Add” button per dish that drops items into a floating cart pill, and a bottom-sheet review screen with line items and totals.
Phone frame is part of the demo so it reads correctly in a desktop iframe; in the wild it would render full-screen on the phone.