UI Components Medium
POS Quick Order Pad
Staff-facing POS quick pad: category tabs, tap-to-add menu grid, running ticket panel with line items, qty steppers, modifiers and total. Built for touch.
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;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
-webkit-font-smoothing: antialiased;
user-select: none;
}
.pos {
height: 100vh;
display: grid;
grid-template-columns: 180px 1fr 340px;
}
/* ── Categories sidebar ── */
.cats {
background: var(--forest);
color: var(--bone);
display: flex;
flex-direction: column;
border-right: 1px solid var(--forest-d);
}
.cats-head {
padding: 18px 16px;
border-bottom: 1px solid rgba(250, 247, 241, 0.12);
display: flex;
flex-direction: column;
gap: 2px;
}
.logo {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.005em;
}
.terminal {
font-size: 0.7rem;
color: var(--gold-light);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.cats-list {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.cat-btn {
background: transparent;
border: none;
color: var(--bone);
font-family: inherit;
font-size: 0.92rem;
font-weight: 600;
text-align: left;
padding: 14px 14px;
border-radius: var(--r-md);
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: background 0.15s, color 0.15s;
}
.cat-btn:hover {
background: rgba(250, 247, 241, 0.08);
}
.cat-btn.is-active {
background: var(--bone);
color: var(--forest-d);
}
.cat-icon {
font-size: 1.2rem;
}
.cat-btn.is-active .cat-icon {
filter: none;
}
.cats-foot {
padding: 12px;
border-top: 1px solid rgba(250, 247, 241, 0.12);
display: flex;
gap: 8px;
}
.util-btn {
flex: 1;
background: rgba(250, 247, 241, 0.08);
border: 1px solid rgba(250, 247, 241, 0.18);
color: var(--bone);
border-radius: var(--r-sm);
padding: 10px 6px;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.util-btn:hover {
background: rgba(250, 247, 241, 0.16);
}
/* ── Grid centre ── */
.grid-wrap {
background: var(--cream);
display: flex;
flex-direction: column;
overflow: hidden;
}
.grid-head {
padding: 18px 22px 12px;
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
}
.grid-head h1 {
font-family: var(--font-display, var(--font-body));
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.grid-hint {
font-size: 0.78rem;
color: var(--warm-gray);
font-style: italic;
}
.grid {
flex: 1;
overflow-y: auto;
padding: 18px 22px 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
align-content: start;
}
.tile {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: var(--r-md);
padding: 14px 12px;
cursor: pointer;
text-align: left;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 96px;
font-family: inherit;
color: var(--ink);
transition: background 0.15s, border-color 0.15s, transform 0.05s;
}
.tile:hover {
border-color: var(--terracotta);
background: var(--bone);
}
.tile:active {
transform: scale(0.97);
background: var(--cream-2);
}
.tile.is-flash {
animation: flash 0.45s ease;
}
@keyframes flash {
0% {
background: var(--gold);
border-color: var(--gold);
}
100% {
background: var(--bone);
border-color: rgba(44, 26, 14, 0.08);
}
}
.tile-name {
font-size: 0.9rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: -0.005em;
}
.tile-price {
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 600;
color: var(--terracotta-d);
margin-top: auto;
}
/* ── Ticket sidebar ── */
.ticket {
background: var(--bone);
border-left: 1px solid rgba(44, 26, 14, 0.1);
display: flex;
flex-direction: column;
}
.ticket-head {
padding: 18px 18px 12px;
border-bottom: 1px dashed rgba(44, 26, 14, 0.2);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.ticket-table {
font-family: var(--font-display, var(--font-body));
font-weight: 800;
font-size: 1.2rem;
}
.ticket-meta {
font-size: 0.75rem;
color: var(--warm-gray);
margin-top: 2px;
}
.clear-btn {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.16);
border-radius: 999px;
padding: 6px 12px;
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
color: var(--warm-gray);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.clear-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.lines {
flex: 1;
overflow-y: auto;
list-style: none;
padding: 8px 18px;
}
.line {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(44, 26, 14, 0.06);
animation: lineIn 0.15s ease;
}
.line:last-child {
border-bottom: none;
}
@keyframes lineIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.line-qty {
display: inline-flex;
align-items: center;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 999px;
padding: 2px;
gap: 2px;
}
.line-qty button {
width: 26px;
height: 26px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--ink-2);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
display: grid;
place-items: center;
line-height: 1;
}
.line-qty button:hover {
background: var(--cream-2);
color: var(--terracotta-d);
}
.line-qty span {
min-width: 22px;
text-align: center;
font-weight: 700;
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.line-name {
flex: 1;
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
}
.line-price {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.88rem;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.empty {
text-align: center;
font-style: italic;
color: var(--warm-gray);
padding: 32px 18px;
font-size: 0.88rem;
}
.ticket-foot {
padding: 14px 18px 18px;
border-top: 1px solid rgba(44, 26, 14, 0.1);
display: flex;
flex-direction: column;
gap: 12px;
background: var(--cream);
}
.totals {
display: flex;
flex-direction: column;
gap: 4px;
}
.total-row {
display: flex;
justify-content: space-between;
font-size: 0.82rem;
color: var(--ink-2);
}
.total-row span:last-child {
font-family: var(--font-mono);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.total-row--big {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(44, 26, 14, 0.16);
font-size: 1.1rem;
font-weight: 700;
color: var(--ink);
}
.total-row--big span:last-child {
font-size: 1.2rem;
}
.actions {
display: flex;
gap: 8px;
}
.ghost {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
color: var(--ink-2);
border-radius: 999px;
padding: 12px 16px;
font-family: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.primary {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: var(--forest);
color: var(--bone);
border: none;
border-radius: 999px;
padding: 12px 18px;
font-family: inherit;
font-size: 0.92rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.primary:hover:not(:disabled) {
background: var(--forest-d);
}
.primary:disabled {
background: var(--warm-gray);
cursor: not-allowed;
opacity: 0.7;
}
.pay-total {
font-family: var(--font-mono);
font-weight: 700;
background: rgba(250, 247, 241, 0.18);
padding: 4px 10px;
border-radius: 999px;
font-size: 0.85rem;
}
/* ── Smaller terminals ── */
@media (max-width: 900px) {
.pos {
grid-template-columns: 140px 1fr 300px;
}
}
@media (max-width: 720px) {
.pos {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
height: auto;
}
html,
body {
overflow: auto;
}
.cats {
flex-direction: row;
border-right: none;
border-bottom: 1px solid var(--forest-d);
}
.cats-head {
display: none;
}
.cats-list {
flex-direction: row;
flex-wrap: wrap;
padding: 10px;
}
.cats-foot {
display: none;
}
.ticket {
border-left: none;
border-top: 1px solid rgba(44, 26, 14, 0.1);
}
}const TAX_RATE = 0.0825;
const MENU = {
entradas: {
title: "Entradas",
icon: "🥖",
items: [
{ id: "pan", name: "Pan masa madre", price: 8 },
{ id: "burrata", name: "Burrata", price: 16 },
{ id: "pulpo", name: "Pulpo brasa", price: 19 },
{ id: "croquetas", name: "Croquetas jamón", price: 14 },
{ id: "ensalada", name: "Ensalada huerta", price: 13 },
{ id: "anchoas", name: "Anchoas Cantábrico", price: 17 },
],
},
principales: {
title: "Principales",
icon: "🥩",
items: [
{ id: "ribeye", name: "Ribeye 14oz", price: 48 },
{ id: "branzino", name: "Branzino entero", price: 38 },
{ id: "risotto", name: "Risotto hongos", price: 26 },
{ id: "pollo", name: "Pollo carbón", price: 28 },
{ id: "pappardelle", name: "Pappardelle ragú", price: 24 },
{ id: "salmon", name: "Salmón a la plancha", price: 32 },
{ id: "cordero", name: "Costilla cordero", price: 42 },
{ id: "vegetariano", name: "Plato del huerto", price: 22 },
],
},
postres: {
title: "Postres",
icon: "🍰",
items: [
{ id: "tarta", name: "Tarta de queso", price: 11 },
{ id: "olive", name: "Olive oil cake", price: 10 },
{ id: "ganache", name: "Chocolate ganache", price: 12 },
{ id: "sorbete", name: "Sorbete cítrico", price: 9 },
],
},
bebidas: {
title: "Bebidas",
icon: "🍷",
items: [
{ id: "vermut", name: "Vermut casa", price: 9 },
{ id: "negroni", name: "Negroni sbagliato", price: 14 },
{ id: "spritz", name: "Spritz", price: 13 },
{ id: "tinto", name: "Tinto natural", price: 12 },
{ id: "blanco", name: "Blanco copa", price: 11 },
{ id: "agua", name: "Agua mineral", price: 5 },
{ id: "cafe", name: "Café espresso", price: 4 },
],
},
};
const catsEl = document.getElementById("cats");
const gridEl = document.getElementById("grid");
const catTitle = document.getElementById("catTitle");
const linesEl = document.getElementById("lines");
const emptyEl = document.getElementById("empty");
const ticketMeta = document.getElementById("ticketMeta");
const subtotalEl = document.getElementById("subtotal");
const taxEl = document.getElementById("tax");
const totalEl = document.getElementById("total");
const payTotalEl = document.getElementById("payTotal");
const payBtn = document.getElementById("pay");
const clearBtn = document.getElementById("clear");
const holdBtn = document.getElementById("hold");
let activeCat = "entradas";
let ticket = []; // { id, name, price, qty }
function money(v) {
return `$${v.toFixed(2)}`;
}
function renderCats() {
catsEl.innerHTML = Object.entries(MENU)
.map(
([key, c]) => `
<button class="cat-btn ${key === activeCat ? "is-active" : ""}" data-cat="${key}">
<span class="cat-icon">${c.icon}</span>
<span>${c.title}</span>
</button>`
)
.join("");
}
function renderGrid() {
const cat = MENU[activeCat];
catTitle.textContent = cat.title;
gridEl.innerHTML = cat.items
.map(
(item) => `
<button class="tile" data-item="${item.id}" data-name="${item.name}" data-price="${item.price}">
<span class="tile-name">${item.name}</span>
<span class="tile-price">$${item.price.toFixed(2)}</span>
</button>`
)
.join("");
}
function renderTicket() {
const itemCount = ticket.reduce((n, l) => n + l.qty, 0);
ticketMeta.textContent = `${itemCount} ${itemCount === 1 ? "item" : "items"}`;
if (ticket.length === 0) {
linesEl.innerHTML = "";
emptyEl.style.display = "";
} else {
emptyEl.style.display = "none";
linesEl.innerHTML = ticket
.map(
(l) => `
<li class="line" data-id="${l.id}">
<div class="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="line-name">${l.name}</span>
<span class="line-price">${money(l.price * l.qty)}</span>
</li>`
)
.join("");
}
const subtotal = ticket.reduce((s, l) => s + l.price * l.qty, 0);
const tax = subtotal * TAX_RATE;
const total = subtotal + tax;
subtotalEl.textContent = money(subtotal);
taxEl.textContent = money(tax);
totalEl.textContent = money(total);
payTotalEl.textContent = money(total);
payBtn.disabled = ticket.length === 0;
}
function addItem(id, name, price, tile) {
const existing = ticket.find((l) => l.id === id);
if (existing) existing.qty += 1;
else ticket.push({ id, name, price, qty: 1 });
renderTicket();
if (tile) {
tile.classList.remove("is-flash");
void tile.offsetWidth; // restart animation
tile.classList.add("is-flash");
}
}
catsEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cat]");
if (!btn) return;
activeCat = btn.dataset.cat;
renderCats();
renderGrid();
});
gridEl.addEventListener("click", (e) => {
const tile = e.target.closest("[data-item]");
if (!tile) return;
addItem(tile.dataset.item, tile.dataset.name, Number(tile.dataset.price), tile);
});
linesEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const li = btn.closest("[data-id]");
if (!li) return;
const line = ticket.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) ticket = ticket.filter((l) => l.id !== line.id);
}
renderTicket();
});
clearBtn.addEventListener("click", () => {
if (ticket.length === 0) return;
ticket = [];
renderTicket();
});
holdBtn.addEventListener("click", () => {
holdBtn.textContent = "Held ✓";
setTimeout(() => (holdBtn.textContent = "Hold"), 1400);
});
payBtn.addEventListener("click", () => {
const txt = payBtn.querySelector("span").textContent;
payBtn.querySelector("span").textContent = "Sent ✓";
setTimeout(() => {
payBtn.querySelector("span").textContent = txt;
ticket = [];
renderTicket();
}, 900);
});
renderCats();
renderGrid();
renderTicket();<!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=Inter:wght@500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>POS · Quick Pad</title>
</head>
<body>
<main class="pos">
<aside class="cats" aria-label="Categories">
<header class="cats-head">
<span class="logo">Casa Olivar</span>
<span class="terminal">Terminal 02</span>
</header>
<nav class="cats-list" id="cats"></nav>
<footer class="cats-foot">
<button class="util-btn" type="button">Tables</button>
<button class="util-btn" type="button">Search</button>
</footer>
</aside>
<section class="grid-wrap">
<header class="grid-head">
<h1 id="catTitle">Entradas</h1>
<span class="grid-hint">Tap to add to ticket</span>
</header>
<div class="grid" id="grid"></div>
</section>
<aside class="ticket" aria-label="Current ticket">
<header class="ticket-head">
<div>
<p class="ticket-table">Table 7</p>
<p class="ticket-meta" id="ticketMeta">0 items</p>
</div>
<button class="clear-btn" type="button" id="clear">Clear</button>
</header>
<ul class="lines" id="lines"></ul>
<p class="empty" id="empty">Pick an item to start a ticket.</p>
<footer class="ticket-foot">
<div class="totals">
<div class="total-row">
<span>Subtotal</span><span id="subtotal">$0.00</span>
</div>
<div class="total-row">
<span>Tax (8.25%)</span><span id="tax">$0.00</span>
</div>
<div class="total-row total-row--big">
<span>Total</span><span id="total">$0.00</span>
</div>
</div>
<div class="actions">
<button class="ghost" type="button" id="hold">Hold</button>
<button class="primary" type="button" id="pay" disabled>
<span>Send · Pay</span>
<span class="pay-total" id="payTotal">$0.00</span>
</button>
</div>
</footer>
</aside>
</main>
<script src="script.js"></script>
</body>
</html>POS Quick Order Pad
The staff-side quick pad used to ring up tickets without modifier sheets. Category strip on the left, tap-to-add menu grid in the centre, running ticket panel on the right with quantity steppers, total and Hold / Pay actions. Designed for touch: 48–56 px hit targets, no hover-only affordances, large legible totals.
Forms the visual baseline for the POS core resources in Section 2 (rest-pos-order-entry, rest-pos-kitchen-display, etc.).