Componentes UI Easy
Hotel Shuttle Schedule
An airport and city shuttle timetable widget with a direction toggle (Hotel→Airport / Airport→Hotel), a live countdown to the next departure, per-departure seat counts, and a reserve-seat button with toast feedback.
Abrir no Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
/* ── Design tokens ── */
:root {
--navy: #1a2b4a;
--navy-d: #0f1d36;
--navy-2: #2d4570;
--cream: #f7f3eb;
--cream-2: #ece5d4;
--bone: #fbf8f2;
--gold: #c9a649;
--gold-light: #e0c378;
--gold-d: #a88a2e;
--ink: #161e2c;
--ink-2: #2e3a52;
--warm-gray: #6c7280;
--line: rgba(22, 30, 44, 0.1);
--line-strong: rgba(22, 30, 44, 0.18);
--success: #4a7752;
--danger: #b34232;
--warning: #d99020;
--info: #4a6da0;
--font-display: "Cormorant Garamond", 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(22, 30, 44, 0.06), 0 2px 8px rgba(22, 30, 44, 0.06);
--shadow-2: 0 12px 36px rgba(15, 29, 54, 0.16);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
color: var(--ink);
-webkit-font-smoothing: antialiased;
background: radial-gradient(circle at 75% 80%, rgba(201, 166, 73, 0.15), transparent 50%),
linear-gradient(160deg, #14213b 0%, #0f1d36 100%);
display: grid;
place-items: center;
min-height: 100vh;
padding: 32px;
}
/* ── Card ── */
.bg {
width: 100%;
display: grid;
place-items: center;
}
.card {
width: min(580px, 100%);
background: var(--bone);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: 0 40px 100px rgba(15, 29, 54, 0.55);
}
/* ── Header ── */
.card-head {
padding: 22px 26px 18px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--gold-d);
font-weight: 700;
}
.card-head h2 {
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--navy-d);
margin-top: 2px;
}
.clock-wrap {
display: flex;
align-items: flex-end;
}
.clock {
font-family: var(--font-mono);
font-size: 1.35rem;
font-weight: 700;
color: var(--navy);
font-variant-numeric: tabular-nums;
letter-spacing: 0.03em;
}
/* ── Direction bar ── */
.dir-bar {
padding: 12px 26px;
border-bottom: 1px solid var(--line);
background: var(--cream);
}
.seg {
display: flex;
background: var(--cream-2);
border-radius: var(--r-md);
padding: 3px;
gap: 3px;
}
.seg-btn {
flex: 1;
background: transparent;
border: none;
font-family: var(--font-body);
font-size: 0.86rem;
font-weight: 600;
color: var(--warm-gray);
padding: 9px 12px;
border-radius: 7px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn:hover {
color: var(--ink);
}
.seg-btn.is-active {
background: var(--bone);
color: var(--navy-d);
box-shadow: var(--shadow-1);
}
.route-icon {
margin-right: 5px;
}
/* ── Next departure banner ── */
.next-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 26px 16px;
background: linear-gradient(135deg, var(--navy-2), var(--navy-d));
gap: 16px;
flex-wrap: wrap;
}
.next-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: rgba(251, 248, 242, 0.6);
font-weight: 700;
}
.next-time {
font-family: var(--font-mono);
font-size: 2rem;
font-weight: 700;
color: var(--gold-light);
line-height: 1;
font-variant-numeric: tabular-nums;
margin-top: 3px;
}
.next-stops {
font-size: 0.78rem;
color: rgba(251, 248, 242, 0.7);
margin-top: 5px;
}
.next-right {
text-align: right;
}
.countdown-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(251, 248, 242, 0.55);
font-weight: 700;
}
.countdown {
font-family: var(--font-mono);
font-size: 1.8rem;
font-weight: 700;
color: var(--bone);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
line-height: 1;
margin-top: 3px;
}
.reserve-btn {
margin-top: 10px;
font-family: var(--font-body);
font-weight: 600;
font-size: 0.82rem;
padding: 8px 18px;
border-radius: 999px;
cursor: pointer;
background: var(--gold);
color: var(--navy-d);
border: none;
white-space: nowrap;
}
.reserve-btn:hover {
background: var(--gold-light);
}
.reserve-btn:disabled {
background: rgba(251, 248, 242, 0.15);
color: rgba(251, 248, 242, 0.4);
cursor: not-allowed;
}
/* ── Departure list ── */
.dep-list {
padding: 8px 0 8px;
max-height: 340px;
overflow-y: auto;
}
.dep-row {
display: grid;
grid-template-columns: 72px 1fr auto auto;
align-items: center;
gap: 10px;
padding: 11px 26px;
border-bottom: 1px solid var(--line);
transition: background 0.12s;
}
.dep-row:last-child {
border-bottom: none;
}
.dep-row:hover:not(.is-departed) {
background: var(--cream);
}
.dep-row.is-next {
background: rgba(201, 166, 73, 0.07);
}
.dep-row.is-departed {
opacity: 0.45;
}
.dep-time {
font-family: var(--font-mono);
font-size: 1rem;
font-weight: 700;
color: var(--navy);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dep-row.is-departed .dep-time {
text-decoration: line-through;
}
.dep-info {
}
.dep-route {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-2);
}
.dep-stops {
font-size: 0.75rem;
color: var(--warm-gray);
margin-top: 2px;
}
.dep-seats {
text-align: center;
min-width: 54px;
}
.seats-n {
font-family: var(--font-mono);
font-size: 1rem;
font-weight: 700;
color: var(--navy);
display: block;
font-variant-numeric: tabular-nums;
}
.seats-n.low {
color: var(--danger);
}
.seats-label {
font-size: 0.66rem;
color: var(--warm-gray);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 700;
}
.dep-action {
min-width: 86px;
text-align: right;
}
.status-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 4px 10px;
border-radius: 999px;
}
.status-ontime {
background: rgba(74, 119, 82, 0.12);
color: var(--success);
}
.status-boarding {
background: rgba(201, 166, 73, 0.14);
color: var(--gold-d);
}
.status-departed {
background: rgba(22, 30, 44, 0.06);
color: var(--warm-gray);
}
.btn-reserve {
font-family: var(--font-body);
font-size: 0.78rem;
font-weight: 600;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid var(--line-strong);
background: var(--bone);
color: var(--navy-d);
cursor: pointer;
white-space: nowrap;
}
.btn-reserve:hover {
border-color: var(--gold);
background: var(--cream);
}
.btn-reserve:disabled {
color: var(--warm-gray);
background: transparent;
border-style: dashed;
cursor: not-allowed;
}
/* ── Toast ── */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--navy-d);
color: var(--bone);
padding: 11px 22px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: 0 10px 30px rgba(22, 30, 44, 0.18);
z-index: 100;
white-space: nowrap;
}
/* ── Responsive ── */
@media (max-width: 560px) {
body {
padding: 0;
}
.card {
border-radius: 0;
}
.dep-row {
grid-template-columns: 62px 1fr auto;
gap: 8px;
}
.dep-action {
display: none;
}
.next-right {
text-align: left;
}
}// ── Schedule data ────────────────────────────────────────────────────────────
const SCHEDULE = {
out: [
{
id: "o1",
hhmm: "07:30",
route: "Hotel → Airport T1",
stops: "Lobby · Concourse A · T1 Departures",
seats: 8,
maxSeats: 8,
},
{
id: "o2",
hhmm: "09:00",
route: "Hotel → Airport T2",
stops: "Lobby · T2 Departures",
seats: 5,
maxSeats: 8,
},
{
id: "o3",
hhmm: "10:45",
route: "Hotel → Airport T1",
stops: "Lobby · Concourse A · T1 Departures",
seats: 8,
maxSeats: 8,
},
{
id: "o4",
hhmm: "12:30",
route: "Hotel → Airport T2",
stops: "Lobby · T2 Departures",
seats: 3,
maxSeats: 8,
},
{
id: "o5",
hhmm: "14:00",
route: "Hotel → Airport T1",
stops: "Lobby · Concourse A · T1 Departures",
seats: 7,
maxSeats: 8,
},
{
id: "o6",
hhmm: "16:15",
route: "Hotel → Airport T2",
stops: "Lobby · T2 Departures",
seats: 8,
maxSeats: 8,
},
{
id: "o7",
hhmm: "18:30",
route: "Hotel → Airport T1",
stops: "Lobby · Concourse A · T1 Departures",
seats: 6,
maxSeats: 8,
},
{
id: "o8",
hhmm: "20:00",
route: "Hotel → Airport T2",
stops: "Lobby · T2 Departures",
seats: 8,
maxSeats: 8,
},
],
in: [
{
id: "i1",
hhmm: "06:45",
route: "Airport T1 → Hotel",
stops: "T1 Arrivals · Concourse A · Lobby",
seats: 4,
maxSeats: 8,
},
{
id: "i2",
hhmm: "08:15",
route: "Airport T2 → Hotel",
stops: "T2 Arrivals · Lobby",
seats: 8,
maxSeats: 8,
},
{
id: "i3",
hhmm: "10:00",
route: "Airport T1 → Hotel",
stops: "T1 Arrivals · Concourse A · Lobby",
seats: 2,
maxSeats: 8,
},
{
id: "i4",
hhmm: "11:30",
route: "Airport T2 → Hotel",
stops: "T2 Arrivals · Lobby",
seats: 8,
maxSeats: 8,
},
{
id: "i5",
hhmm: "13:45",
route: "Airport T1 → Hotel",
stops: "T1 Arrivals · Concourse A · Lobby",
seats: 6,
maxSeats: 8,
},
{
id: "i6",
hhmm: "15:00",
route: "Airport T2 → Hotel",
stops: "T2 Arrivals · Lobby",
seats: 5,
maxSeats: 8,
},
{
id: "i7",
hhmm: "17:30",
route: "Airport T1 → Hotel",
stops: "T1 Arrivals · Concourse A · Lobby",
seats: 8,
maxSeats: 8,
},
{
id: "i8",
hhmm: "19:45",
route: "Airport T2 → Hotel",
stops: "T2 Arrivals · Lobby",
seats: 3,
maxSeats: 8,
},
],
};
// ── State ─────────────────────────────────────────────────────────────────────
let dir = "out"; // "out" | "in"
// ── Helpers ───────────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const toast = $("toast");
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2200);
}
function nowMinutes() {
const d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function hhmmToMinutes(hhmm) {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
function padTwo(n) {
return String(n).padStart(2, "0");
}
function formatCountdown(diffMin) {
if (diffMin < 0) return "–";
const h = Math.floor(diffMin / 60);
const m = diffMin % 60;
return h > 0 ? `${h}h ${padTwo(m)}m` : `${m}m`;
}
function getStatus(hhmm) {
const depMin = hhmmToMinutes(hhmm);
const now = nowMinutes();
const diff = depMin - now;
if (diff < -3) return "departed";
if (diff <= 10) return "boarding";
return "ontime";
}
// ── Find next departure ───────────────────────────────────────────────────────
function findNext() {
const now = nowMinutes();
const rows = SCHEDULE[dir];
return rows.find((r) => hhmmToMinutes(r.hhmm) - now > -3) || null;
}
// ── Render clock ──────────────────────────────────────────────────────────────
function renderClock() {
const d = new Date();
$("clock").textContent = `${padTwo(d.getHours())}:${padTwo(d.getMinutes())}`;
}
// ── Render next banner ────────────────────────────────────────────────────────
function renderNext() {
const next = findNext();
if (!next) {
$("nextTime").textContent = "—";
$("nextStops").textContent = "No more departures today";
$("countdown").textContent = "—";
$("nextReserveBtn").disabled = true;
return;
}
const diff = hhmmToMinutes(next.hhmm) - nowMinutes();
$("nextTime").textContent = next.hhmm;
$("nextStops").textContent = next.stops;
$("countdown").textContent = formatCountdown(diff);
$("nextReserveBtn").disabled = next.seats === 0 || diff < -3;
$("nextReserveBtn").dataset.id = next.id;
}
// ── Render departure list ─────────────────────────────────────────────────────
function renderList() {
const rows = SCHEDULE[dir];
const next = findNext();
$("depList").innerHTML = rows
.map((r) => {
const status = getStatus(r.hhmm);
const isNext = next && r.id === next.id;
const isDep = status === "departed";
const isBoard = status === "boarding";
const canRes = !isDep && r.seats > 0;
const seatsLow = r.seats <= 2 && r.seats > 0;
const statusHtml = isDep
? `<span class="status-badge status-departed">Departed</span>`
: isBoard
? `<span class="status-badge status-boarding">Boarding</span>`
: `<span class="status-badge status-ontime">On time</span>`;
const actionHtml = isDep
? statusHtml
: `<button type="button" class="btn-reserve" data-id="${r.id}" ${r.seats === 0 ? "disabled" : ""}>${r.seats === 0 ? "Full" : "Reserve"}</button>`;
return `
<div class="dep-row ${isNext ? "is-next" : ""} ${isDep ? "is-departed" : ""}" role="listitem">
<span class="dep-time">${r.hhmm}</span>
<div class="dep-info">
<div class="dep-route">${r.route}</div>
<div class="dep-stops">${r.stops}</div>
</div>
<div class="dep-seats">
<span class="seats-n ${seatsLow ? "low" : ""}">${r.seats}</span>
<span class="seats-label">seats</span>
</div>
<div class="dep-action">${actionHtml}</div>
</div>`;
})
.join("");
}
// ── Reserve seat (list) ───────────────────────────────────────────────────────
$("depList").addEventListener("click", (e) => {
const btn = e.target.closest(".btn-reserve");
if (!btn || btn.disabled) return;
reserveSeat(btn.dataset.id);
});
// ── Reserve seat (banner) ─────────────────────────────────────────────────────
$("nextReserveBtn").addEventListener("click", () => {
const next = findNext();
if (!next) return;
reserveSeat(next.id);
});
function reserveSeat(id) {
const row = [...SCHEDULE.out, ...SCHEDULE.in].find((r) => r.id === id);
if (!row || row.seats === 0) return;
row.seats -= 1;
const suffix = row.seats === 1 ? "1 seat left" : `${row.seats} seats left`;
showToast(`Reserved · ${row.hhmm} ${row.route} — ${suffix}`);
renderNext();
renderList();
}
// ── Direction toggle ──────────────────────────────────────────────────────────
$("dirSeg").addEventListener("click", (e) => {
const btn = e.target.closest(".seg-btn");
if (!btn) return;
dir = btn.dataset.dir;
document.querySelectorAll(".seg-btn").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
renderNext();
renderList();
});
// ── Tick ──────────────────────────────────────────────────────────────────────
function tick() {
renderClock();
renderNext();
renderList();
}
// ── Init ──────────────────────────────────────────────────────────────────────
tick();
setInterval(tick, 30_000); // refresh every 30 s<!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=Cormorant+Garamond: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>Shuttle Schedule · Aurelia Hotels</title>
</head>
<body>
<div class="bg">
<section class="card" aria-labelledby="cardTitle">
<!-- ── Header ── -->
<header class="card-head">
<div>
<p class="kicker">Ground transport</p>
<h2 id="cardTitle">Shuttle Schedule</h2>
</div>
<div class="clock-wrap" aria-live="polite" aria-label="Current time">
<span class="clock" id="clock">--:--</span>
</div>
</header>
<!-- ── Direction toggle ── -->
<div class="dir-bar">
<div class="seg" role="group" aria-label="Route direction" id="dirSeg">
<button class="seg-btn is-active" data-dir="out">
<span class="route-icon">✈</span> Hotel → Airport
</button>
<button class="seg-btn" data-dir="in">
<span class="route-icon">🏨</span> Airport → Hotel
</button>
</div>
</div>
<!-- ── Next departure highlight ── -->
<div class="next-banner" id="nextBanner" aria-live="polite">
<div class="next-left">
<p class="next-label">Next departure</p>
<p class="next-time" id="nextTime">--:--</p>
<p class="next-stops" id="nextStops"></p>
</div>
<div class="next-right">
<p class="countdown-label">Departs in</p>
<p class="countdown" id="countdown">--:--</p>
<button class="reserve-btn" id="nextReserveBtn" type="button">Reserve seat</button>
</div>
</div>
<!-- ── Departure list ── -->
<div class="dep-list" id="depList" role="list" aria-label="Departures"></div>
</section>
</div>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hotel Shuttle Schedule
A centered timetable card showing today’s shuttle departures between Aurelia Hotels and the airport. A direction toggle at the top switches between outbound (Hotel→Airport) and inbound (Airport→Hotel) runs. The next departure is highlighted with a live countdown clock. Each row shows departure time, stops, seats remaining, and status (On time / Boarding / Departed). The “Reserve” button decrements the seat count in real time and fires a toast confirmation.