Airline — Gate Departure Display
A big-screen airline gate departure display built with vanilla JS. A bold banner shows the flight number, LHR to SIN route with airport codes and a plane on the route line, plus a large colour-coded status pill for Boarding, On time, Delayed, Departed or Cancelled. Tiles surface boarding and departure times, gate, terminal and aircraft, while a live countdown ticks toward boarding close. Group rows track who is boarding now, next and waiting, and a progress bar fills as zones advance — all interactive.
MCP
Code
:root {
--sky: #0a66c2;
--sky-d: #084e95;
--sky-50: #e9f2fb;
--cloud: #f5f8fc;
--sunrise: #ff7a33;
--sunrise-50: #fff0e7;
--ink: #13233b;
--ink-2: #3a4d68;
--muted: #6b7c93;
--bg: #f5f8fc;
--surface: #ffffff;
--line: rgba(19, 35, 59, 0.1);
--line-2: rgba(19, 35, 59, 0.18);
--ok: #1f9d62;
--warn: #e0962a;
--danger: #d4493e;
--boarding: #1f9d62;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow: 0 1px 2px rgba(19, 35, 59, 0.06), 0 8px 24px rgba(19, 35, 59, 0.08);
--tnum: "tnum" 1, "lnum" 1;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background:
radial-gradient(1100px 600px at 80% -10%, var(--sky-50), transparent 60%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 24px;
}
.tnum { font-variant-numeric: tabular-nums; font-feature-settings: var(--tnum); }
.screen {
width: 100%;
max-width: 880px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Header */
.gate-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: linear-gradient(135deg, var(--sky-d), var(--sky));
color: #fff;
border-radius: var(--r-lg);
padding: 16px 20px;
box-shadow: var(--shadow);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
.brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.brand-text strong { font-size: 17px; font-weight: 800; letter-spacing: -0.01em; }
.brand-text span { font-size: 12.5px; color: rgba(255, 255, 255, 0.82); font-weight: 500; }
.clock { text-align: right; line-height: 1.1; }
.clock-time {
display: block;
font-size: 26px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.clock-zone { font-size: 11px; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.8); font-weight: 600; }
/* Flight banner */
.flight-banner {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: 22px 24px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 24px;
}
.flight-no { display: flex; flex-direction: column; gap: 2px; }
.flight-no .label {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
font-weight: 700;
}
.flight-no .value {
font-size: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.route { display: flex; align-items: center; gap: 18px; justify-content: center; }
.port { display: flex; flex-direction: column; align-items: center; min-width: 84px; }
.port .code {
font-size: 34px;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.port .city { font-size: 12px; color: var(--muted); font-weight: 500; text-align: center; }
.route-line {
position: relative;
flex: 1;
min-width: 90px;
max-width: 220px;
height: 28px;
display: flex;
align-items: center;
}
.route-line::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 0;
border-top: 2px dashed var(--line-2);
}
.route-line .dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--sky);
position: relative;
z-index: 1;
}
.route-line .dot:last-child { background: var(--sunrise); margin-left: auto; }
.route-line .plane {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--sky);
background: var(--surface);
border-radius: 50%;
padding: 2px;
}
.status-wrap { display: flex; justify-content: flex-end; }
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 800;
padding: 12px 20px;
border-radius: 999px;
letter-spacing: 0.01em;
white-space: nowrap;
}
.status-pill::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
}
.status-pill[data-status="ontime"] { background: var(--sky-50); color: var(--sky-d); }
.status-pill[data-status="boarding"] {
background: rgba(31, 157, 98, 0.14);
color: var(--boarding);
animation: blink 1.4s ease-in-out infinite;
}
.status-pill[data-status="delayed"] { background: rgba(224, 150, 42, 0.16); color: var(--warn); }
.status-pill[data-status="departed"] { background: var(--ink); color: #fff; }
.status-pill[data-status="cancelled"] { background: rgba(212, 73, 62, 0.14); color: var(--danger); }
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* Tile grid */
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.tile {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow);
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tile-label {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
font-weight: 700;
}
.tile-value {
font-size: 26px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.tile-value.small { font-size: 20px; }
.tile-value.gate { color: var(--sky-d); }
.tile-sub { font-size: 12.5px; color: var(--muted); font-weight: 500; }
/* Boarding panel */
.boarding-panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: 20px 22px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 700; letter-spacing: 0.01em; }
.now-zone {
font-size: 13px;
font-weight: 700;
color: var(--boarding);
background: rgba(31, 157, 98, 0.12);
padding: 6px 12px;
border-radius: 999px;
}
.groups {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.group {
display: grid;
grid-template-columns: 36px 1fr auto auto;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--cloud);
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.group .g-no {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 9px;
background: var(--surface);
border: 1px solid var(--line-2);
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--ink-2);
}
.group .g-name { font-weight: 700; font-size: 14px; }
.group .g-rows {
font-size: 12.5px;
color: var(--muted);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.group .g-state {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.group[data-active="now"] {
background: rgba(31, 157, 98, 0.1);
border-color: rgba(31, 157, 98, 0.4);
}
.group[data-active="now"] .g-no { background: var(--boarding); color: #fff; border-color: var(--boarding); }
.group[data-active="now"] .g-state { color: var(--boarding); }
.group[data-active="next"] { border-color: var(--line-2); }
.group[data-active="next"] .g-state { color: var(--sunrise); }
.group[data-active="done"] { opacity: 0.55; }
.group[data-active="done"] .g-state { color: var(--ink-2); }
.group[data-active="done"] .g-no { background: var(--sky-50); color: var(--sky-d); border-color: transparent; }
.progress {
height: 8px;
border-radius: 999px;
background: var(--sky-50);
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--boarding), #2bbd7a);
transition: width 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Controls */
.controls { display: flex; flex-wrap: wrap; gap: 10px; }
.btn {
font: inherit;
font-weight: 700;
font-size: 14px;
padding: 11px 18px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink);
cursor: pointer;
transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.btn:hover { border-color: var(--sky); color: var(--sky-d); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 3px solid var(--sky-50); outline-offset: 2px; }
.btn.primary {
background: var(--sky);
border-color: var(--sky);
color: #fff;
box-shadow: 0 6px 16px rgba(10, 102, 194, 0.28);
}
.btn.primary:hover { background: var(--sky-d); color: #fff; }
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 14px);
background: var(--ink);
color: #fff;
font-size: 13.5px;
font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: 0 10px 30px rgba(19, 35, 59, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 50;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* Responsive */
@media (max-width: 720px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.flight-banner { grid-template-columns: 1fr; gap: 18px; text-align: center; }
.flight-no { align-items: center; }
.status-wrap { justify-content: center; }
.route-line { max-width: none; }
}
@media (max-width: 520px) {
body { padding: 14px; }
.gate-head { padding: 14px 16px; }
.brand-text strong { font-size: 15px; }
.clock-time { font-size: 22px; }
.port .code { font-size: 28px; }
.flight-no .value { font-size: 24px; }
.status-pill { font-size: 15px; padding: 10px 16px; }
.group { grid-template-columns: 30px 1fr auto; gap: 10px; }
.group .g-rows { display: none; }
.tile-value { font-size: 22px; }
.controls .btn { flex: 1; text-align: center; }
}
@media (prefers-reduced-motion: reduce) {
.status-pill[data-status="boarding"] { animation: none; }
.progress-bar { transition: none; }
}(function () {
"use strict";
// ---- Elements ----
var clockEl = document.getElementById("clock");
var statusPill = document.getElementById("statusPill");
var boardTimeEl = document.getElementById("boardTime");
var depTimeEl = document.getElementById("depTime");
var countdownEl = document.getElementById("countdown");
var gateEl = document.getElementById("gate");
var nowZoneEl = document.getElementById("nowZone");
var groupsEl = document.getElementById("groups");
var progressBar = document.getElementById("progressBar");
var advanceBtn = document.getElementById("advanceBtn");
var statusBtn = document.getElementById("statusBtn");
var resetBtn = document.getElementById("resetBtn");
var toastEl = document.getElementById("toast");
var groupEls = Array.prototype.slice.call(groupsEl.querySelectorAll(".group"));
var totalGroups = groupEls.length;
// ---- State ----
var STATUSES = [
{ key: "boarding", label: "Boarding" },
{ key: "ontime", label: "On time" },
{ key: "delayed", label: "Delayed" },
{ key: "departed", label: "Departed" },
{ key: "cancelled", label: "Cancelled" }
];
var state = {
activeGroup: 1, // 1-based; the group currently boarding
status: "boarding",
// boarding closes ~45s from load in this demo to keep the countdown lively
boardCloseAt: Date.now() + 18 * 60 * 1000
};
// ---- Toast helper ----
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
// ---- Clock ----
function pad(n) { return n < 10 ? "0" + n : "" + n; }
function tickClock() {
var d = new Date();
clockEl.textContent = pad(d.getHours()) + ":" + pad(d.getMinutes());
}
// ---- Countdown to boarding close ----
function renderCountdown() {
if (state.status === "departed") {
countdownEl.textContent = "Doors closed";
return;
}
if (state.status === "cancelled") {
countdownEl.textContent = "Flight cancelled";
return;
}
var diff = state.boardCloseAt - Date.now();
if (diff <= 0) {
countdownEl.textContent = "Final call";
return;
}
var mins = Math.floor(diff / 60000);
var secs = Math.floor((diff % 60000) / 1000);
if (mins > 0) {
countdownEl.textContent = "in " + mins + " min";
} else {
countdownEl.textContent = "in " + secs + " s";
}
}
// ---- Render boarding groups ----
function renderGroups() {
var boardingActive = state.status === "boarding";
groupEls.forEach(function (el) {
var g = parseInt(el.getAttribute("data-group"), 10);
var stateEl = el.querySelector(".g-state");
if (!boardingActive) {
el.removeAttribute("data-active");
if (state.status === "departed") {
stateEl.textContent = "Closed";
} else if (state.status === "cancelled") {
stateEl.textContent = "—";
} else {
stateEl.textContent = "Holding";
}
return;
}
if (g < state.activeGroup) {
el.setAttribute("data-active", "done");
stateEl.textContent = "Boarded";
} else if (g === state.activeGroup) {
el.setAttribute("data-active", "now");
stateEl.textContent = "Boarding";
} else if (g === state.activeGroup + 1) {
el.setAttribute("data-active", "next");
stateEl.textContent = "Next";
} else {
el.removeAttribute("data-active");
stateEl.textContent = "Waiting";
}
});
// Now-boarding zone label + progress
if (boardingActive) {
var current = groupEls.find(function (el) {
return parseInt(el.getAttribute("data-group"), 10) === state.activeGroup;
});
var name = current ? current.querySelector(".g-name").textContent : "";
nowZoneEl.textContent = "Group " + state.activeGroup + " · " + name;
nowZoneEl.style.display = "";
} else {
nowZoneEl.style.display = "none";
}
var pct = boardingActive
? Math.round((state.activeGroup / totalGroups) * 100)
: (state.status === "departed" ? 100 : 0);
progressBar.style.width = pct + "%";
}
// ---- Render status pill ----
function renderStatus() {
var info = STATUSES.filter(function (s) { return s.key === state.status; })[0];
statusPill.setAttribute("data-status", state.status);
statusPill.textContent = info.label;
if (state.status === "delayed") {
depTimeEl.textContent = "23:25";
depTimeEl.nextElementSibling.textContent = "Was 22:40";
depTimeEl.nextElementSibling.style.color = "var(--warn)";
} else {
depTimeEl.textContent = "22:40";
depTimeEl.nextElementSibling.textContent = "Scheduled";
depTimeEl.nextElementSibling.style.color = "";
}
advanceBtn.disabled = !(state.status === "boarding");
advanceBtn.style.opacity = advanceBtn.disabled ? "0.5" : "";
advanceBtn.style.cursor = advanceBtn.disabled ? "not-allowed" : "";
}
function renderAll() {
renderStatus();
renderGroups();
renderCountdown();
}
// ---- Actions ----
function advanceGroup() {
if (state.status !== "boarding") {
toast("Boarding not in progress");
return;
}
if (state.activeGroup >= totalGroups) {
state.status = "departed";
renderAll();
toast("All groups boarded — doors closed");
return;
}
state.activeGroup += 1;
var current = groupEls.find(function (el) {
return parseInt(el.getAttribute("data-group"), 10) === state.activeGroup;
});
var name = current ? current.querySelector(".g-name").textContent : "";
renderAll();
toast("Now boarding Group " + state.activeGroup + " · " + name);
}
function cycleStatus() {
var idx = STATUSES.map(function (s) { return s.key; }).indexOf(state.status);
idx = (idx + 1) % STATUSES.length;
state.status = STATUSES[idx].key;
if (state.status === "departed") state.activeGroup = totalGroups;
if (state.status === "boarding" && state.activeGroup > totalGroups) state.activeGroup = 1;
renderAll();
toast("Status: " + STATUSES[idx].label);
}
function reset() {
state.activeGroup = 1;
state.status = "boarding";
state.boardCloseAt = Date.now() + 18 * 60 * 1000;
renderAll();
toast("Display reset");
}
// ---- Wire up ----
advanceBtn.addEventListener("click", advanceGroup);
statusBtn.addEventListener("click", cycleStatus);
resetBtn.addEventListener("click", reset);
// Click a group to jump boarding to it (only while boarding)
groupEls.forEach(function (el) {
el.addEventListener("click", function () {
if (state.status !== "boarding") return;
state.activeGroup = parseInt(el.getAttribute("data-group"), 10);
renderAll();
toast("Now boarding Group " + state.activeGroup);
});
});
// ---- Timers ----
tickClock();
setInterval(tickClock, 1000 * 15);
setInterval(renderCountdown, 1000);
renderAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gate Departure Display — Skyfaring Air</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="screen" role="main" aria-label="Gate departure display">
<header class="gate-head">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none">
<path d="M21 16.5 14 12V5a2 2 0 0 0-4 0v7l-7 4.5V18l7-2.5V20l-2 1.2V22l3.5-1 3.5 1v-.8L13 20v-4.5l7 2.5z" fill="currentColor"/>
</svg>
</span>
<div class="brand-text">
<strong>Skyfaring Air</strong>
<span>Terminal 4 · Gate B22</span>
</div>
</div>
<div class="head-meta">
<div class="clock" aria-live="off">
<span class="clock-time" id="clock">--:--</span>
<span class="clock-zone">LOCAL · LHR</span>
</div>
</div>
</header>
<section class="flight-banner" aria-label="Departing flight">
<div class="flight-no">
<span class="label">Flight</span>
<span class="value" id="flightNo">SF 0428</span>
</div>
<div class="route">
<div class="port">
<span class="code">LHR</span>
<span class="city">London Heathrow</span>
</div>
<div class="route-line" aria-hidden="true">
<span class="dot"></span>
<svg class="plane" viewBox="0 0 24 24" width="22" height="22"><path d="M21 16.5 14 12V5a2 2 0 0 0-4 0v7l-7 4.5V18l7-2.5V20l-2 1.2V22l3.5-1 3.5 1v-.8L13 20v-4.5z" fill="currentColor"/></svg>
<span class="dot"></span>
</div>
<div class="port">
<span class="code">SIN</span>
<span class="city">Singapore Changi</span>
</div>
</div>
<div class="status-wrap">
<span class="status-pill" id="statusPill" data-status="boarding" role="status" aria-live="polite">Boarding</span>
</div>
</section>
<section class="grid">
<div class="tile">
<span class="tile-label">Boarding</span>
<span class="tile-value" id="boardTime">22:05</span>
<span class="tile-sub" id="countdown">in 18 min</span>
</div>
<div class="tile">
<span class="tile-label">Departs</span>
<span class="tile-value" id="depTime">22:40</span>
<span class="tile-sub">Scheduled</span>
</div>
<div class="tile">
<span class="tile-label">Gate</span>
<span class="tile-value gate" id="gate">B22</span>
<span class="tile-sub">Terminal 4</span>
</div>
<div class="tile">
<span class="tile-label">Aircraft</span>
<span class="tile-value small">A350-900</span>
<span class="tile-sub">9V-SFK</span>
</div>
</section>
<section class="boarding-panel" aria-label="Boarding groups">
<div class="panel-head">
<h2>Now boarding</h2>
<span class="now-zone" id="nowZone">Group 1 · First & Business</span>
</div>
<ol class="groups" id="groups">
<li class="group" data-group="1">
<span class="g-no">1</span>
<span class="g-name">First & Business</span>
<span class="g-rows">Rows 1–14</span>
<span class="g-state">Boarding</span>
</li>
<li class="group" data-group="2">
<span class="g-no">2</span>
<span class="g-name">Premium · Priority</span>
<span class="g-rows">Rows 30–34</span>
<span class="g-state">Next</span>
</li>
<li class="group" data-group="3">
<span class="g-no">3</span>
<span class="g-name">Economy · Zone A</span>
<span class="g-rows">Rows 50–62</span>
<span class="g-state">Waiting</span>
</li>
<li class="group" data-group="4">
<span class="g-no">4</span>
<span class="g-name">Economy · Zone B</span>
<span class="g-rows">Rows 63–78</span>
<span class="g-state">Waiting</span>
</li>
</ol>
<div class="progress" aria-hidden="true">
<div class="progress-bar" id="progressBar" style="width:25%"></div>
</div>
</section>
<section class="controls" aria-label="Display controls">
<button class="btn primary" id="advanceBtn" type="button">Advance group</button>
<button class="btn" id="statusBtn" type="button">Change status</button>
<button class="btn" id="resetBtn" type="button">Reset</button>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Gate Departure Display
A large-format gate display in the clean, status-forward aviation style. A gradient header carries the carrier, terminal and gate alongside a live local clock, while the banner below states the flight in big tabular figures, draws the LHR to SIN route with airport codes and a plane sitting on a dashed route line, and surfaces a prominent colour-coded status pill that gently blinks while the flight is Boarding.
A four-up tile row reads out boarding time, scheduled departure, gate and aircraft, with a countdown that ticks down toward boarding close every second. The boarding panel lists each group — First and Business through Economy zones — marking which one is boarding now, which is next and which is still waiting, and a progress bar fills as zones clear. Switching the flight to Delayed reveals the revised departure time in amber.
The control row makes it fully interactive: advance the boarding group one zone at a time, cycle the status through On time, Delayed, Boarding, Departed and Cancelled, or reset the display. You can also tap any group row to jump boarding straight to it. Everything is vanilla JS with a small toast() helper, tabular figures for times and flight numbers, keyboard-usable buttons and a layout that collapses cleanly to a narrow passenger screen.
Illustrative UI only — fictional airline, not a real booking or flight system.