UI Components Medium
POS — Payment Terminal
Restaurant payment terminal: cash / card / split tabs, tip presets, on-screen amount keypad, change calculator, and a confirmed-payment view.
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) 0%, var(--cream-2) 100%);
color: var(--ink);
min-height: 100vh;
padding: 32px 16px 48px;
display: flex;
justify-content: center;
-webkit-font-smoothing: antialiased;
}
.pay {
width: 100%;
max-width: 520px;
background: var(--bone);
border-radius: var(--r-lg);
border: 1px solid rgba(44, 26, 14, 0.1);
box-shadow: var(--shadow-2);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Head ── */
.pay-head {
padding: 18px 22px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--terracotta);
font-weight: 600;
margin-bottom: 2px;
}
.pay-head h1 {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.x {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
color: var(--ink-2);
border-radius: 999px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
cursor: pointer;
}
.x:hover {
background: var(--cream-2);
}
/* ── Body ── */
.pay-body {
padding: 16px 22px 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Bill summary ── */
.bill {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: var(--r-md);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.bill div {
display: flex;
justify-content: space-between;
font-size: 0.86rem;
color: var(--ink-2);
}
.bill dd {
font-family: var(--font-mono);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.bill-total {
margin-top: 6px;
padding-top: 10px;
border-top: 1px solid rgba(44, 26, 14, 0.16);
font-size: 1.05rem;
font-weight: 700;
color: var(--ink);
}
.bill-total dd {
font-size: 1.2rem;
}
.tip-tag {
font-family: var(--font-mono);
font-size: 0.7rem;
background: var(--cream-2);
color: var(--ink-2);
padding: 2px 7px;
border-radius: 999px;
margin-left: 4px;
font-weight: 700;
}
/* ── Tip presets ── */
.tip-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tip-btn {
flex: 1;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
color: var(--ink-2);
border-radius: 999px;
padding: 9px 10px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.tip-btn:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
.tip-btn.is-active {
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
/* ── Methods ── */
.methods {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.m-btn {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
color: var(--ink-2);
border-radius: var(--r-md);
padding: 14px 8px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.m-btn .m-icon {
font-size: 1.3rem;
}
.m-btn:hover {
border-color: var(--terracotta);
}
.m-btn.is-active {
background: var(--gold);
color: var(--ink);
border-color: var(--gold);
}
/* ── Panels ── */
.panel {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Cash */
.cash-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.cash-field {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: var(--r-md);
padding: 10px 14px;
}
.cash-field label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--warm-gray);
font-weight: 700;
display: block;
margin-bottom: 4px;
}
.amount {
font-family: var(--font-mono);
font-weight: 700;
font-size: 1.5rem;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.amount-change.is-positive {
color: var(--success);
}
.amount-change.is-short {
color: var(--danger);
}
.cash-change {
background: var(--bone);
}
.quick {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.q-btn {
background: var(--cream-2);
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: var(--r-sm);
padding: 8px 4px;
font-family: inherit;
font-size: 0.78rem;
font-weight: 700;
color: var(--ink-2);
cursor: pointer;
}
.q-btn:hover {
background: var(--gold);
color: var(--ink);
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.keypad button {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: var(--r-md);
padding: 14px 0;
font-family: var(--font-mono);
font-size: 1.05rem;
font-weight: 700;
cursor: pointer;
transition: background 0.1s, transform 0.05s;
}
.keypad button:hover {
background: var(--cream-2);
}
.keypad button:active {
transform: scale(0.95);
}
.keypad .del {
color: var(--danger);
}
/* Card */
.card-prompt {
text-align: center;
padding: 18px 12px 12px;
background: var(--forest);
color: var(--bone);
border-radius: var(--r-md);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.card-reader {
width: 120px;
height: 78px;
background: linear-gradient(180deg, #34564a 0%, #1e3329 100%);
border-radius: 8px;
position: relative;
border: 1px solid rgba(250, 247, 241, 0.18);
box-shadow: inset 0 1px 0 rgba(250, 247, 241, 0.1);
overflow: hidden;
}
.card-reader::before {
content: "";
position: absolute;
left: 12%;
right: 12%;
top: 22%;
height: 22%;
background: rgba(201, 168, 76, 0.85);
border-radius: 2px;
}
.card-reader::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 4px;
background: var(--gold);
animation: scan 1.4s ease-in-out infinite;
}
@keyframes scan {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-78px);
}
}
.card-status {
font-size: 0.9rem;
font-weight: 600;
color: var(--gold-light);
letter-spacing: 0.04em;
}
.card-amount {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.card-prompt.is-paid {
background: var(--success);
}
.card-prompt.is-paid .card-status {
color: var(--bone);
}
/* Split */
.split-config {
display: flex;
align-items: center;
gap: 12px;
}
.split-config label {
font-size: 0.78rem;
font-weight: 700;
color: var(--ink-2);
}
.split-ways {
display: flex;
gap: 4px;
background: var(--cream-2);
padding: 3px;
border-radius: 999px;
margin-left: auto;
}
.split-ways button {
background: transparent;
border: none;
color: var(--ink-2);
font-family: var(--font-mono);
font-size: 0.86rem;
font-weight: 700;
width: 30px;
height: 30px;
border-radius: 999px;
cursor: pointer;
}
.split-ways button.is-active {
background: var(--forest);
color: var(--bone);
}
.split-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
}
.split-row {
display: grid;
grid-template-columns: 24px 1fr auto auto;
align-items: center;
gap: 10px;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: var(--r-md);
padding: 10px 12px;
}
.split-row.is-paid {
background: rgba(79, 122, 58, 0.08);
border-color: rgba(79, 122, 58, 0.3);
}
.split-num {
font-family: var(--font-mono);
font-weight: 700;
color: var(--warm-gray);
}
.split-name {
background: transparent;
border: none;
font-family: inherit;
font-size: 0.88rem;
color: var(--ink);
outline: none;
}
.split-amount {
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.9rem;
color: var(--ink);
}
.split-paid {
background: var(--cream-2);
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: 999px;
padding: 4px 10px;
font-family: inherit;
font-size: 0.72rem;
font-weight: 700;
color: var(--ink-2);
cursor: pointer;
}
.split-row.is-paid .split-paid {
background: var(--success);
color: var(--bone);
border-color: var(--success);
}
/* ── Foot ── */
.pay-foot {
padding: 14px 22px 20px;
border-top: 1px solid rgba(44, 26, 14, 0.08);
display: flex;
gap: 10px;
background: var(--cream);
}
.ghost,
.primary {
border-radius: 999px;
font-family: inherit;
font-size: 0.9rem;
font-weight: 700;
padding: 12px 18px;
cursor: pointer;
transition: background 0.15s, transform 0.05s;
}
.ghost {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
color: var(--ink-2);
}
.ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.primary {
flex: 1;
background: var(--forest);
color: var(--bone);
border: none;
}
.primary:hover:not(:disabled) {
background: var(--forest-d);
}
.primary:active:not(:disabled) {
transform: scale(0.99);
}
.primary:disabled {
background: var(--warm-gray);
cursor: not-allowed;
opacity: 0.7;
}
@media (max-width: 480px) {
.quick {
grid-template-columns: repeat(3, 1fr);
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.panel[hidden] {
display: none;
}const SUBTOTAL = 184.0;
const TAX_RATE = 0.0825;
const SERVICE_RATE = 0.1;
let tipMode = "18";
let customTip = 22;
let method = "cash";
let tendered = 0;
let tenderedRaw = ""; // string accumulator for the keypad
let ways = 2;
let splitPaid = new Set();
const subtotalEl = document.getElementById("subtotal");
const taxEl = document.getElementById("tax");
const serviceEl = document.getElementById("service");
const tipEl = document.getElementById("tip");
const totalEl = document.getElementById("total");
const tipTagEl = document.getElementById("tipTag");
const amountEl = document.getElementById("amount");
const changeEl = document.getElementById("change");
const cardAmount = document.getElementById("cardAmount");
const cardStatus = document.getElementById("cardStatus");
const cardPrompt = document.getElementById("cardPrompt");
const confirmLabel = document.getElementById("confirmLabel");
const confirmBtn = document.getElementById("confirm");
const splitList = document.getElementById("splitList");
function money(v) {
return `$${v.toFixed(2)}`;
}
function tipPercent() {
if (tipMode === "custom") return Math.max(0, Math.min(100, customTip)) / 100;
return Number(tipMode) / 100;
}
function total() {
return SUBTOTAL + SUBTOTAL * TAX_RATE + SUBTOTAL * SERVICE_RATE + SUBTOTAL * tipPercent();
}
function tenderedNum() {
if (tenderedRaw === "") return 0;
return Number(tenderedRaw) / 100;
}
function renderBill() {
const tax = SUBTOTAL * TAX_RATE;
const service = SUBTOTAL * SERVICE_RATE;
const tip = SUBTOTAL * tipPercent();
subtotalEl.textContent = money(SUBTOTAL);
taxEl.textContent = money(tax);
serviceEl.textContent = money(service);
tipEl.textContent = money(tip);
totalEl.textContent = money(total());
tipTagEl.textContent = `${Math.round(tipPercent() * 100)}%`;
cardAmount.textContent = money(total());
}
function renderTendered() {
const t = tenderedNum();
amountEl.textContent = money(t);
const change = t - total();
if (t === 0) {
changeEl.textContent = "$0.00";
changeEl.className = "amount amount-change";
} else if (change >= 0) {
changeEl.textContent = money(change);
changeEl.className = "amount amount-change is-positive";
} else {
changeEl.textContent = `-${money(Math.abs(change))}`;
changeEl.className = "amount amount-change is-short";
}
}
function renderConfirm() {
if (method === "cash") {
confirmLabel.textContent =
tenderedNum() >= total() ? `Confirm · ${money(total())}` : "Tender first";
confirmBtn.disabled = tenderedNum() < total();
} else if (method === "card") {
confirmLabel.textContent = cardPrompt.classList.contains("is-paid")
? "Done"
: `Charge ${money(total())}`;
confirmBtn.disabled = false;
} else {
const allPaid = splitPaid.size === ways;
confirmLabel.textContent = allPaid
? "Close ticket"
: `Split ${ways} ways · ${money(total() / ways)}/each`;
confirmBtn.disabled = false;
}
}
function renderSplit() {
splitList.innerHTML = Array.from({ length: ways }, (_, i) => i)
.map(
(i) => `
<li class="split-row ${splitPaid.has(i) ? "is-paid" : ""}" data-split="${i}">
<span class="split-num">#${i + 1}</span>
<input class="split-name" placeholder="Guest name" value="${i === 0 ? "Lina" : ""}" />
<span class="split-amount">${money(total() / ways)}</span>
<button class="split-paid" data-paid="${i}">${splitPaid.has(i) ? "Paid ✓" : "Mark paid"}</button>
</li>`
)
.join("");
}
function setMethod(next) {
method = next;
document
.querySelectorAll("[data-method]")
.forEach((b) => b.classList.toggle("is-active", b.dataset.method === next));
document.querySelectorAll(".panel").forEach((p) => (p.hidden = p.dataset.panel !== next));
if (next === "card") {
cardPrompt.classList.remove("is-paid");
cardStatus.textContent = "Insert or tap card on reader";
}
renderConfirm();
if (next === "split") renderSplit();
}
document.querySelectorAll("[data-tip]").forEach((btn) =>
btn.addEventListener("click", () => {
document.querySelectorAll("[data-tip]").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
if (btn.dataset.tip === "custom") {
const val = prompt("Custom tip %", String(customTip));
const n = Number(val);
if (!Number.isNaN(n)) {
customTip = Math.max(0, Math.min(100, n));
tipMode = "custom";
}
} else {
tipMode = btn.dataset.tip;
}
renderBill();
if (method === "split") renderSplit();
renderConfirm();
})
);
document
.querySelectorAll("[data-method]")
.forEach((b) => b.addEventListener("click", () => setMethod(b.dataset.method)));
const keypad = document.getElementById("keypad");
keypad.addEventListener("click", (e) => {
const btn = e.target.closest("[data-key]");
if (!btn) return;
const key = btn.dataset.key;
if (key === "del") tenderedRaw = tenderedRaw.slice(0, -1);
else tenderedRaw = (tenderedRaw + key).slice(0, 8);
renderTendered();
renderConfirm();
});
document.querySelectorAll("[data-quick]").forEach((b) =>
b.addEventListener("click", () => {
const q = b.dataset.quick;
const t = total();
let next;
if (q === "exact") next = t;
else if (q === "next-50") next = Math.ceil(t / 50) * 50;
else next = Number(q);
tenderedRaw = String(Math.round(next * 100));
renderTendered();
renderConfirm();
})
);
document.querySelectorAll("[data-ways]").forEach((b) =>
b.addEventListener("click", () => {
document.querySelectorAll("[data-ways]").forEach((x) => x.classList.remove("is-active"));
b.classList.add("is-active");
ways = Number(b.dataset.ways);
splitPaid = new Set();
renderSplit();
renderConfirm();
})
);
splitList.addEventListener("click", (e) => {
const btn = e.target.closest("[data-paid]");
if (!btn) return;
const i = Number(btn.dataset.paid);
if (splitPaid.has(i)) splitPaid.delete(i);
else splitPaid.add(i);
renderSplit();
renderConfirm();
});
confirmBtn.addEventListener("click", () => {
if (method === "card") {
cardStatus.textContent = "Approved · receipt printing";
cardPrompt.classList.add("is-paid");
renderConfirm();
} else if (method === "cash") {
cardStatus.textContent = "Drawer opened";
confirmLabel.textContent = "Closed ✓";
confirmBtn.disabled = true;
} else if (method === "split" && splitPaid.size === ways) {
confirmLabel.textContent = "Closed ✓";
confirmBtn.disabled = true;
}
});
renderBill();
renderTendered();
renderConfirm();<!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>POS · Payment</title>
</head>
<body>
<section class="pay" role="dialog" aria-label="Payment">
<header class="pay-head">
<div>
<p class="kicker">Table 7 · Lina</p>
<h1 id="payTitle">Take payment</h1>
</div>
<button class="x" type="button" aria-label="Close">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
/>
</svg>
</button>
</header>
<div class="pay-body">
<dl class="bill">
<div><dt>Subtotal</dt><dd id="subtotal">$184.00</dd></div>
<div><dt>Tax (8.25%)</dt><dd id="tax">$15.18</dd></div>
<div><dt>Service (10%)</dt><dd id="service">$18.40</dd></div>
<div class="bill-row"><dt>Tip <span class="tip-tag" id="tipTag">18%</span></dt><dd id="tip">$33.12</dd></div>
<div class="bill-total"><dt>Total</dt><dd id="total">$250.70</dd></div>
</dl>
<div class="tip-presets" role="radiogroup" aria-label="Tip">
<button class="tip-btn" type="button" data-tip="0">No tip</button>
<button class="tip-btn" type="button" data-tip="15">15%</button>
<button class="tip-btn is-active" type="button" data-tip="18">18%</button>
<button class="tip-btn" type="button" data-tip="20">20%</button>
<button class="tip-btn" type="button" data-tip="custom">Custom</button>
</div>
<div class="methods" role="tablist">
<button class="m-btn is-active" data-method="cash" type="button">
<span class="m-icon">💵</span><span>Cash</span>
</button>
<button class="m-btn" data-method="card" type="button">
<span class="m-icon">💳</span><span>Card</span>
</button>
<button class="m-btn" data-method="split" type="button">
<span class="m-icon">⛓</span><span>Split</span>
</button>
</div>
<!-- Cash panel -->
<div class="panel" data-panel="cash">
<div class="cash-row">
<div class="cash-field">
<label>Tendered</label>
<div class="amount" id="amount">$0.00</div>
</div>
<div class="cash-field cash-change">
<label>Change</label>
<div class="amount amount-change" id="change">$0.00</div>
</div>
</div>
<div class="quick">
<button class="q-btn" type="button" data-quick="exact">Exact</button>
<button class="q-btn" type="button" data-quick="20">$20</button>
<button class="q-btn" type="button" data-quick="50">$50</button>
<button class="q-btn" type="button" data-quick="100">$100</button>
<button class="q-btn" type="button" data-quick="200">$200</button>
<button class="q-btn" type="button" data-quick="next-50">Next $50</button>
</div>
<div class="keypad" id="keypad">
<button data-key="1">1</button><button data-key="2">2</button><button data-key="3">3</button>
<button data-key="4">4</button><button data-key="5">5</button><button data-key="6">6</button>
<button data-key="7">7</button><button data-key="8">8</button><button data-key="9">9</button>
<button data-key="00">00</button><button data-key="0">0</button><button data-key="del" class="del">⌫</button>
</div>
</div>
<!-- Card panel -->
<div class="panel" data-panel="card" hidden>
<div class="card-prompt" id="cardPrompt">
<div class="card-reader"></div>
<p class="card-status" id="cardStatus">Insert or tap card on reader</p>
<p class="card-amount" id="cardAmount">$250.70</p>
</div>
</div>
<!-- Split panel -->
<div class="panel" data-panel="split" hidden>
<div class="split-config">
<label>Split between</label>
<div class="split-ways">
<button type="button" data-ways="2" class="is-active">2</button>
<button type="button" data-ways="3">3</button>
<button type="button" data-ways="4">4</button>
<button type="button" data-ways="5">5</button>
<button type="button" data-ways="6">6</button>
</div>
</div>
<ul class="split-list" id="splitList"></ul>
</div>
</div>
<footer class="pay-foot">
<button class="ghost" type="button" id="back">Back</button>
<button class="primary" type="button" id="confirm">
<span id="confirmLabel">Confirm $250.70</span>
</button>
</footer>
</section>
<script src="script.js"></script>
</body>
</html>POS Payment Terminal
The pay-screen at the end of the order flow. Method tabs (Cash · Card · Split) swap the body content; tip presets (15 · 18 · 20 · custom) and an on-screen keypad set the amount tendered. The cash flow shows the change due in real time, the card flow renders a card-reader prompt, and the split flow shows N equal portions with assign-name fields.
Pairs with rest-cart-order and rest-pos-order-entry — same totals, same warm palette, designed to be opened as a modal in the live POS app.