UI Components Easy
Call Waiter · Service Request
Tableside service request panel — six common requests (water, bread, menu, allergy, bill, photo), live status pill, cancel before staff confirm, and a short note field.
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;
}
*,
*::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;
display: grid;
place-items: center;
padding: 32px 16px 40px;
-webkit-font-smoothing: antialiased;
}
.page {
width: 100%;
max-width: 520px;
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 18px;
padding: 24px 24px 20px;
box-shadow: 0 12px 36px rgba(44, 26, 14, 0.12);
display: flex;
flex-direction: column;
gap: 16px;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
}
.kicker {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.head h1 {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.7rem;
letter-spacing: -0.015em;
margin-top: 2px;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
padding: 6px 12px;
border-radius: 999px;
color: var(--ink-2);
font-weight: 600;
white-space: nowrap;
}
.status .dot {
width: 8px;
height: 8px;
background: var(--warm-gray);
border-radius: 999px;
}
.status[data-s="sent"] .dot {
background: var(--warning);
animation: pulse 1.2s ease-in-out infinite;
}
.status[data-s="ontheway"] .dot {
background: var(--terracotta);
animation: pulse 1.2s ease-in-out infinite;
}
.status[data-s="done"] .dot {
background: var(--success);
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 currentColor;
opacity: 1;
}
50% {
box-shadow: 0 0 0 4px transparent;
opacity: 0.45;
}
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 480px) {
.grid {
grid-template-columns: 1fr;
}
}
.action {
background: var(--cream);
border: 1.5px solid rgba(44, 26, 14, 0.1);
border-radius: 12px;
padding: 16px 18px 14px;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
font-family: inherit;
color: var(--ink);
transition: border-color 0.15s, background 0.15s, transform 0.05s;
min-height: 88px;
}
.action:hover:not(:disabled) {
border-color: var(--terracotta);
}
.action:active:not(:disabled) {
transform: scale(0.98);
}
.action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action.is-active {
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
.action.is-active .a-sub {
color: rgba(250, 247, 241, 0.65);
}
.a-warn {
border-color: rgba(217, 144, 32, 0.4);
background: rgba(217, 144, 32, 0.07);
}
.a-warn .a-name {
color: #8a5a1a;
}
.a-gold {
border-color: var(--gold);
background: rgba(201, 168, 76, 0.12);
}
.a-icon {
font-size: 1.6rem;
}
.a-name {
font-weight: 700;
font-size: 1rem;
}
.a-sub {
font-size: 0.78rem;
color: var(--warm-gray);
}
.note {
display: flex;
flex-direction: column;
gap: 6px;
}
.note > span {
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
}
.note input {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
border-radius: 10px;
padding: 10px 14px;
font-family: inherit;
font-size: 0.92rem;
color: var(--ink);
outline: none;
}
.note input:focus {
border-color: var(--terracotta);
}
.state {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 12px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.state-line {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.92rem;
}
.state-line strong {
font-family: var(--font-display);
font-weight: 700;
color: var(--ink);
font-size: 1.05rem;
}
.state-line span {
color: var(--warm-gray);
font-size: 0.78rem;
}
.cancel {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.16);
border-radius: 999px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
color: var(--danger);
cursor: pointer;
}
.cancel:hover {
background: rgba(179, 67, 42, 0.06);
border-color: var(--danger);
}
.footer-note {
font-size: 0.78rem;
color: var(--warm-gray);
text-align: center;
font-style: italic;
}
.last {
text-align: center;
font-size: 0.8rem;
color: var(--warm-gray);
}
.last b {
color: var(--ink-2);
}
/* Visibility guard: honor the [hidden] attribute over base display */
.state[hidden] {
display: none;
}const ACTIONS = {
water: { label: "Water", verb: "Topping up your water" },
bread: { label: "More bread", verb: "Bringing more bread" },
menu: { label: "Menu", verb: "Bringing a menu" },
allergy: { label: "Allergy question", verb: "Your server is on their way" },
bill: { label: "Bill", verb: "Bringing your bill" },
photo: { label: "Photo", verb: "Coming for a quick photo" },
};
const grid = document.getElementById("grid");
const status = document.getElementById("status");
const statusText = status.querySelector(".status-text");
const noteEl = document.getElementById("note");
const stateEl = document.getElementById("state");
const stateLabel = document.getElementById("stateLabel");
const stateMeta = document.getElementById("stateMeta");
const cancelBtn = document.getElementById("cancel");
const lastEl = document.getElementById("last");
let activeId = null;
let phase = null;
let timer = null;
let sentAt = null;
function setStatus(s, text) {
status.dataset.s = s;
statusText.textContent = text;
}
function send(id) {
if (activeId) return;
activeId = id;
sentAt = Date.now();
phase = "sent";
const note = noteEl.value.trim();
stateEl.hidden = false;
stateLabel.textContent = `${ACTIONS[id].label}${note ? ` · "${note}"` : ""}`;
stateMeta.textContent = "Sent · waiting for server";
setStatus("sent", "Sent · waiting");
// Mark button active, disable others
document.querySelectorAll(".action").forEach((b) => {
b.classList.toggle("is-active", b.dataset.id === id);
b.disabled = b.dataset.id !== id;
});
// Schedule progression
clearTimeout(timer);
timer = setTimeout(() => {
if (phase !== "sent") return;
phase = "ontheway";
setStatus("ontheway", "On the way");
stateMeta.textContent = ACTIONS[id].verb;
timer = setTimeout(() => {
if (phase !== "ontheway") return;
phase = "done";
setStatus("done", "Done · enjoy");
stateMeta.textContent = `${ACTIONS[id].verb} · completed`;
lastEl.innerHTML = `Last call · <b>${ACTIONS[id].label}</b> · just now`;
timer = setTimeout(() => reset(true), 3500);
}, 3000);
}, 2200);
}
function reset(keepLast) {
clearTimeout(timer);
activeId = null;
phase = null;
stateEl.hidden = true;
setStatus("", "Idle · tap a request");
document.querySelectorAll(".action").forEach((b) => {
b.classList.remove("is-active");
b.disabled = false;
});
noteEl.value = "";
}
grid.addEventListener("click", (e) => {
const btn = e.target.closest("[data-id]");
if (!btn || btn.disabled) return;
send(btn.dataset.id);
});
cancelBtn.addEventListener("click", () => {
if (phase === "sent") {
setStatus("", "Cancelled · idle");
setTimeout(() => reset(true), 800);
} else {
reset(true);
}
});
setStatus("", "Idle · tap a request");<!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@700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Call waiter · Table 7</title>
</head>
<body>
<main class="page">
<section class="card">
<header class="head">
<div>
<p class="kicker">Table 7 · server Lina</p>
<h1>Need something?</h1>
</div>
<p class="status" id="status">
<span class="dot"></span>
<span class="status-text">Idle · tap a request</span>
</p>
</header>
<div class="grid" id="grid">
<button class="action" data-id="water">
<span class="a-icon">💧</span>
<span class="a-name">Water</span>
<span class="a-sub">Top-up · still or sparkling</span>
</button>
<button class="action" data-id="bread">
<span class="a-icon">🥖</span>
<span class="a-name">More bread</span>
<span class="a-sub">With smoked oil</span>
</button>
<button class="action" data-id="menu">
<span class="a-icon">📖</span>
<span class="a-name">Bring menu</span>
<span class="a-sub">Dessert or extra dish</span>
</button>
<button class="action a-warn" data-id="allergy">
<span class="a-icon">⚠️</span>
<span class="a-name">Allergy question</span>
<span class="a-sub">Server will come right over</span>
</button>
<button class="action a-gold" data-id="bill">
<span class="a-icon">🧾</span>
<span class="a-name">Bill, please</span>
<span class="a-sub">Server will bring the check</span>
</button>
<button class="action" data-id="photo">
<span class="a-icon">📷</span>
<span class="a-name">Photo for us</span>
<span class="a-sub">A picture of the table</span>
</button>
</div>
<label class="note">
<span>Add a note (optional)</span>
<input type="text" id="note" maxlength="80" placeholder="e.g. another sparkling, please" />
</label>
<div class="state" id="state" hidden>
<p class="state-line">
<strong id="stateLabel">—</strong>
<span id="stateMeta">—</span>
</p>
<button class="cancel" type="button" id="cancel">Cancel request</button>
</div>
<p class="footer-note">
We try to answer within <strong>90 seconds</strong>. If the dining room is very busy,
we'll come over as soon as we can.
</p>
</section>
<p class="last" id="last">Last call · none this evening</p>
</main>
<script src="script.js"></script>
</body>
</html>Call Waiter
The panel on a tableside tablet or QR menu for “hey, can I get a server?” Six request buttons (Water · Bread · Menu · Allergy · Bill · Photo) — tap one to send a request, the status pill shows “Sent · waiting → On the way → Done”. Optional one-line note. While pending, the request can be cancelled. Last request shows a relative timestamp.