Airline — Boarding Pass
A mobile-first boarding pass UI for a fictional carrier, rendering perforated-edge cards with passenger, route, gate, seat, zone and live status pills. Each pass carries a deterministic animated QR stub, an add-to-wallet action, a brightness boost for gate scanners, and a flip-to-reverse view with fare, baggage and frequent-flyer details. A simulated gate change pulses and toasts in real time. Built with vanilla HTML, CSS and JavaScript, fully responsive down to small handsets.
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 28px rgba(19, 35, 59, 0.08);
--shadow-lg: 0 18px 50px rgba(8, 78, 149, 0.22);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background:
radial-gradient(1100px 460px at 50% -120px, var(--sky-50), transparent 70%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.wrap {
max-width: 760px;
margin: 0 auto;
padding: 28px 20px 64px;
}
/* Topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(150deg, var(--sky), var(--sky-d));
color: #fff;
box-shadow: 0 6px 16px rgba(8, 78, 149, 0.32);
}
.brand strong { display: block; font-size: 1.05rem; letter-spacing: -0.01em; }
.brand-sub { display: block; font-size: 0.78rem; color: var(--muted); font-weight: 500; }
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-size: 0.83rem;
font-weight: 600;
color: var(--sky-d);
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 9px 15px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.ghost-btn:hover { border-color: var(--sky); box-shadow: var(--shadow); }
.ghost-btn:active { transform: translateY(1px); }
.ghost-btn:focus-visible { outline: 3px solid rgba(10, 102, 194, 0.4); outline-offset: 2px; }
.lead {
margin: 0 0 22px;
color: var(--ink-2);
font-size: 0.92rem;
max-width: 52ch;
}
/* Passes grid */
.passes {
display: grid;
gap: 26px;
}
/* Flip card scaffolding */
.pass {
perspective: 1600px;
}
.pass-inner {
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.65, 0.05, 0.36, 1);
}
.pass.flipped .pass-inner { transform: rotateY(180deg); }
.face {
border-radius: var(--r-lg);
background: var(--surface);
box-shadow: var(--shadow);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
overflow: hidden;
}
.face-back {
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
.pass.flipped .face-front { pointer-events: none; }
.pass:not(.flipped) .face-back { pointer-events: none; }
/* Pass header */
.pass-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
color: #fff;
background: linear-gradient(125deg, var(--sky), var(--sky-d));
}
.pass-airline { display: flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: -0.01em; }
.pass-airline svg { opacity: 0.95; }
.pass-flightno {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 0.86rem;
background: rgba(255, 255, 255, 0.16);
padding: 4px 10px;
border-radius: 999px;
}
/* Status pill */
.status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 5px 11px;
border-radius: 999px;
white-space: nowrap;
}
.status::before {
content: "";
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
}
.status.boarding { background: rgba(31, 157, 98, 0.14); color: var(--boarding); }
.status.boarding::before { animation: pulse 1.3s infinite; }
.status.ontime { background: rgba(31, 157, 98, 0.12); color: var(--ok); }
.status.delayed { background: rgba(224, 150, 42, 0.16); color: var(--warn); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(31, 157, 98, 0.5); }
50% { box-shadow: 0 0 0 5px rgba(31, 157, 98, 0); }
}
/* Route */
.route {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
padding: 22px 18px 14px;
}
.airport { text-align: center; }
.airport.from { text-align: left; }
.airport.to { text-align: right; }
.code {
font-size: 2.1rem;
font-weight: 800;
letter-spacing: 0.01em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.city { font-size: 0.78rem; color: var(--muted); margin-top: 4px; font-weight: 500; }
.time {
font-size: 0.95rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 7px;
}
.plane-line {
display: grid;
place-items: center;
color: var(--sky);
position: relative;
}
.plane-line::before, .plane-line::after {
content: "";
position: absolute;
top: 50%;
width: 26px;
height: 2px;
background: repeating-linear-gradient(90deg, var(--line-2) 0 4px, transparent 4px 8px);
}
.plane-line::before { right: 100%; }
.plane-line::after { left: 100%; }
.plane-line svg { transform: rotate(0deg); }
/* Detail row (front) */
.detail-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
padding: 0 14px 8px;
}
.detail {
text-align: center;
padding: 8px 6px;
}
.detail .lab {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 700;
}
.detail .val {
font-size: 1.1rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 3px;
}
.detail.gate .val { color: var(--sunrise); }
.detail.gate.changed .val { animation: gateflash 0.7s ease 3; }
@keyframes gateflash {
0%, 100% { color: var(--sunrise); }
50% { color: var(--danger); transform: scale(1.08); }
}
.gate-tag {
display: inline-block;
margin-top: 2px;
font-size: 0.58rem;
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Perforation */
.perf {
position: relative;
height: 26px;
margin: 4px 0 0;
}
.perf::before, .perf::after {
content: "";
position: absolute;
top: 50%;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg);
transform: translateY(-50%);
box-shadow: inset 0 0 0 1px var(--line);
}
.perf::before { left: -13px; }
.perf::after { right: -13px; }
.perf .dashes {
position: absolute;
top: 50%;
left: 18px;
right: 18px;
height: 2px;
transform: translateY(-50%);
background: repeating-linear-gradient(90deg, var(--line-2) 0 7px, transparent 7px 14px);
}
/* QR / stub */
.stub {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px 20px;
}
.qr {
width: 92px;
height: 92px;
flex: 0 0 auto;
border-radius: var(--r-sm);
background:
conic-gradient(from 0deg, #0e1f33, #16314f, #0e1f33);
padding: 6px;
position: relative;
overflow: hidden;
}
.qr canvas { width: 100%; height: 100%; display: block; border-radius: 4px; }
.qr::after {
content: "";
position: absolute;
left: 0; right: 0;
top: 0;
height: 36%;
background: linear-gradient(180deg, rgba(255, 122, 51, 0.55), transparent);
animation: scan 2.4s ease-in-out infinite;
pointer-events: none;
}
@keyframes scan {
0%, 100% { transform: translateY(-10%); opacity: 0.2; }
50% { transform: translateY(180%); opacity: 0.85; }
}
.stub-info { flex: 1; min-width: 0; }
.stub-name {
font-weight: 800;
font-size: 1.02rem;
letter-spacing: -0.01em;
}
.stub-sub {
font-size: 0.74rem;
color: var(--muted);
font-weight: 500;
margin-top: 2px;
}
.zone-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.zone-chip {
font-size: 0.72rem;
font-weight: 700;
background: var(--sky-50);
color: var(--sky-d);
border-radius: 999px;
padding: 5px 11px;
font-variant-numeric: tabular-nums;
}
/* Actions */
.actions {
display: flex;
gap: 8px;
padding: 0 18px 18px;
}
.act {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
border-radius: var(--r-sm);
padding: 10px;
cursor: pointer;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink-2);
transition: transform 0.12s ease, box-shadow 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.act:hover { border-color: var(--sky); box-shadow: var(--shadow); }
.act:active { transform: translateY(1px); }
.act:focus-visible { outline: 3px solid rgba(10, 102, 194, 0.4); outline-offset: 2px; }
.act.wallet {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.act.wallet:hover { background: #0c1a2e; }
.act.bright.on {
background: var(--sunrise-50);
border-color: var(--sunrise);
color: #b8501c;
}
/* Brightness boost overlay */
.pass.boosted .pass-inner { box-shadow: 0 0 0 4px var(--sunrise), var(--shadow-lg); border-radius: var(--r-lg); }
.pass.boosted .face { filter: contrast(1.08) brightness(1.06); }
/* Back face */
.back-head {
padding: 16px 18px 12px;
background: linear-gradient(125deg, var(--ink), #0c1a2e);
color: #fff;
}
.back-head h3 { margin: 0; font-size: 1rem; }
.back-head p { margin: 3px 0 0; font-size: 0.76rem; color: rgba(255,255,255,0.7); }
.back-body {
padding: 16px 18px 18px;
display: grid;
gap: 0;
}
.brow {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid var(--line);
font-size: 0.86rem;
}
.brow:last-child { border-bottom: 0; }
.brow .k { color: var(--muted); font-weight: 500; }
.brow .v { font-weight: 700; font-variant-numeric: tabular-nums; text-align: right; }
.brow .v.ff { color: var(--sky-d); }
.flip-back {
margin: 14px 18px 18px;
width: calc(100% - 36px);
font: inherit;
font-weight: 600;
font-size: 0.84rem;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink-2);
border-radius: var(--r-sm);
padding: 10px;
cursor: pointer;
}
.flip-back:hover { border-color: var(--sky); }
/* Legend */
.legend {
display: flex;
gap: 18px;
justify-content: center;
margin-top: 34px;
font-size: 0.78rem;
color: var(--muted);
font-weight: 500;
}
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.dot-board { background: var(--boarding); }
.dot-ontime { background: var(--ok); }
.dot-delay { background: var(--warn); }
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
font-size: 0.86rem;
font-weight: 600;
padding: 12px 18px;
border-radius: 999px;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
max-width: calc(100% - 32px);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
@media (max-width: 520px) {
.wrap { padding: 20px 14px 56px; }
.ghost-btn span, .ghost-btn { font-size: 0.78rem; }
.code { font-size: 1.75rem; }
.detail .val { font-size: 1rem; }
.qr { width: 80px; height: 80px; }
.stub-name { font-size: 0.95rem; }
.legend { gap: 12px; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
.pass-inner, .toast, .ghost-btn, .act { transition: none; }
.qr::after, .status.boarding::before { animation: none; }
}/* Skyward Air — mobile boarding passes (vanilla JS) */
(function () {
"use strict";
var planeSVG =
'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3.5s-2.5 0-4 1.5L13.5 8.5 5.3 6.7c-.5-.1-.9.1-1.1.5l-.3.5c-.2.4-.1.9.3 1.1L8 11l-2 2H4l-.5.5c-.3.3-.3.8 0 1.1L7 18l3.4 3.5c.3.3.8.3 1.1 0L12 21v-2l2-2 2.3 4.8c.2.4.7.5 1.1.3l.5-.3c.4-.2.6-.6.5-1.1z"/></svg>';
var passes = [
{
id: "p1",
flightNo: "SW 482",
status: "boarding",
statusLabel: "Boarding",
from: { code: "JFK", city: "New York", time: "18:40" },
to: { code: "LHR", city: "London", time: "06:55" },
gate: "B12",
seat: "14A",
zone: "2",
terminal: "T4",
board: "18:10",
pnr: "QXR4ZK",
name: "Amara N. Okafor",
cabin: "Economy Plus",
fare: "Flex",
bags: "1 checked · 23kg",
ff: "Skyward Gold",
duration: "7h 15m",
},
{
id: "p2",
flightNo: "SW 119",
status: "ontime",
statusLabel: "On time",
from: { code: "SFO", city: "San Francisco", time: "09:05" },
to: { code: "NRT", city: "Tokyo", time: "13:30" },
gate: "A07",
seat: "3C",
zone: "1",
terminal: "T2",
board: "08:35",
pnr: "LM7PWD",
name: "Diego Ferreira",
cabin: "Business",
fare: "Flex Plus",
bags: "2 checked · 32kg",
ff: "Skyward Platinum",
duration: "11h 25m",
},
{
id: "p3",
flightNo: "SW 256",
status: "delayed",
statusLabel: "Delayed +35m",
from: { code: "DXB", city: "Dubai", time: "23:50" },
to: { code: "CDG", city: "Paris", time: "04:20" },
gate: "C21",
seat: "27F",
zone: "4",
terminal: "T3",
board: "23:20",
pnr: "VT9HBN",
name: "Mei-Lin Chua",
cabin: "Economy",
fare: "Saver",
bags: "1 checked · 23kg",
ff: "Skyward Silver",
duration: "7h 30m",
},
];
/* ---- QR-ish matrix renderer (deterministic, decorative) ---- */
function drawQR(canvas, seed) {
var n = 21;
var ctx = canvas.getContext("2d");
var px = 8;
canvas.width = n * px;
canvas.height = n * px;
ctx.fillStyle = "#f6f9ff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#0e1f33";
var h = 2166136261;
for (var i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
function rnd() {
h ^= h << 13; h ^= h >>> 17; h ^= h << 5;
return ((h >>> 0) % 1000) / 1000;
}
function finder(ox, oy) {
ctx.fillRect(ox * px, oy * px, 7 * px, 7 * px);
ctx.fillStyle = "#f6f9ff";
ctx.fillRect((ox + 1) * px, (oy + 1) * px, 5 * px, 5 * px);
ctx.fillStyle = "#0e1f33";
ctx.fillRect((ox + 2) * px, (oy + 2) * px, 3 * px, 3 * px);
}
for (var y = 0; y < n; y++) {
for (var x = 0; x < n; x++) {
var inFinder =
(x < 8 && y < 8) || (x > n - 9 && y < 8) || (x < 8 && y > n - 9);
if (inFinder) continue;
if (rnd() > 0.52) ctx.fillRect(x * px, y * px, px, px);
}
}
finder(0, 0); finder(n - 7, 0); finder(0, n - 7);
}
/* ---- toast helper ---- */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2400);
}
/* ---- build a pass card ---- */
function buildPass(p) {
var el = document.createElement("article");
el.className = "pass";
el.dataset.id = p.id;
el.innerHTML =
'<div class="pass-inner">' +
'<div class="face face-front">' +
'<div class="pass-head">' +
'<span class="pass-airline">' + planeSVG + " Skyward Air</span>" +
'<span class="pass-flightno">' + p.flightNo + "</span>" +
"</div>" +
'<div class="route">' +
'<div class="airport from">' +
'<div class="code">' + p.from.code + "</div>" +
'<div class="city">' + p.from.city + "</div>" +
'<div class="time">' + p.from.time + "</div>" +
"</div>" +
'<div class="plane-line">' + planeSVG + "</div>" +
'<div class="airport to">' +
'<div class="code">' + p.to.code + "</div>" +
'<div class="city">' + p.to.city + "</div>" +
'<div class="time">' + p.to.time + "</div>" +
"</div>" +
"</div>" +
'<div class="detail-row">' +
detail("Gate", p.gate, "gate") +
detail("Seat", p.seat) +
detail("Zone", p.zone) +
detail("Term", p.terminal) +
"</div>" +
'<div class="perf"><span class="dashes"></span></div>' +
'<div class="stub">' +
'<div class="qr"><canvas></canvas></div>' +
'<div class="stub-info">' +
'<div class="stub-name">' + p.name + "</div>" +
'<div class="stub-sub">' + p.cabin + " · Boards " + p.board + "</div>" +
'<div class="zone-row">' +
'<span class="zone-chip">Zone ' + p.zone + "</span>" +
'<span class="status ' + p.status + '">' + p.statusLabel + "</span>" +
"</div>" +
"</div>" +
"</div>" +
'<div class="actions">' +
'<button class="act wallet" type="button" data-act="wallet">' +
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="3"/><path d="M2 10h20"/></svg>' +
" Add to wallet</button>" +
'<button class="act bright" type="button" data-act="bright" aria-pressed="false">' +
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>' +
" Brightness</button>" +
'<button class="act" type="button" data-act="flip">Details</button>' +
"</div>" +
"</div>" +
'<div class="face face-back">' +
'<div class="back-head">' +
"<h3>" + p.from.code + " → " + p.to.code + "</h3>" +
"<p>" + p.flightNo + " · " + p.duration + " · " + p.name + "</p>" +
"</div>" +
'<div class="back-body">' +
brow("Booking ref", p.pnr) +
brow("Cabin", p.cabin) +
brow("Fare", p.fare) +
brow("Seat", p.seat) +
brow("Baggage", p.bags) +
brow("Frequent flyer", p.ff, true) +
brow("Boarding", "Gate " + p.gate + " · " + p.board) +
"</div>" +
'<button class="flip-back" type="button" data-act="flip">Back to pass</button>' +
"</div>" +
"</div>";
return el;
}
function detail(lab, val, extra) {
var cls = "detail" + (extra ? " " + extra : "");
return '<div class="' + cls + '"><div class="lab">' + lab +
'</div><div class="val">' + val + "</div></div>";
}
function brow(k, v, ff) {
return '<div class="brow"><span class="k">' + k + '</span><span class="v' +
(ff ? " ff" : "") + '">' + v + "</span></div>";
}
/* ---- render all ---- */
var container = document.getElementById("passes");
passes.forEach(function (p) {
var el = buildPass(p);
container.appendChild(el);
drawQR(el.querySelector(".qr canvas"), p.pnr + p.flightNo);
});
/* ---- interactions (event delegation) ---- */
container.addEventListener("click", function (e) {
var btn = e.target.closest("[data-act]");
if (!btn) return;
var pass = btn.closest(".pass");
var act = btn.dataset.act;
if (act === "flip") {
pass.classList.toggle("flipped");
return;
}
if (act === "wallet") {
var code = pass.querySelector(".pass-flightno").textContent;
toast("Added " + code + " to Wallet");
btn.disabled = true;
btn.textContent = "Added ✓";
return;
}
if (act === "bright") {
var on = pass.classList.toggle("boosted");
btn.classList.toggle("on", on);
btn.setAttribute("aria-pressed", String(on));
toast(on ? "Brightness boosted for scanning" : "Brightness restored");
}
});
/* ---- simulated gate change on the boarding flight ---- */
setTimeout(function () {
var first = container.querySelector('.pass[data-id="p1"]');
if (!first) return;
var gate = first.querySelector(".detail.gate");
var val = gate.querySelector(".val");
val.textContent = "B27";
gate.classList.add("changed");
if (!gate.querySelector(".gate-tag")) {
var tag = document.createElement("span");
tag.className = "gate-tag";
tag.textContent = "Gate changed";
gate.appendChild(tag);
}
toast("Gate change: SW 482 now boarding at B27");
}, 4200);
/* ---- add all to wallet ---- */
document.getElementById("walletAll").addEventListener("click", function () {
var btns = container.querySelectorAll('[data-act="wallet"]:not([disabled])');
btns.forEach(function (b) {
b.disabled = true;
b.textContent = "Added ✓";
});
toast(
btns.length
? "Added " + btns.length + " passes to Wallet"
: "All passes already in Wallet"
);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skyward Air — Boarding Passes</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="wrap">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3.5s-2.5 0-4 1.5L13.5 8.5 5.3 6.7c-.5-.1-.9.1-1.1.5l-.3.5c-.2.4-.1.9.3 1.1L8 11l-2 2H4l-.5.5c-.3.3-.3.8 0 1.1L7 18l3.4 3.5c.3.3.8.3 1.1 0L12 21v-2l2-2 2.3 4.8c.2.4.7.5 1.1.3l.5-.3c.4-.2.6-.6.5-1.1z"/></svg>
</span>
<div>
<strong>Skyward Air</strong>
<span class="brand-sub">Mobile boarding passes</span>
</div>
</div>
<button class="ghost-btn" id="walletAll" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="3"/><path d="M2 10h20"/></svg>
Add all to wallet
</button>
</header>
<p class="lead">Tap a pass to flip for fare details. Boost brightness before you reach the gate scanner.</p>
<section class="passes" id="passes" aria-label="Boarding passes"></section>
<footer class="legend">
<span><i class="dot dot-board"></i>Boarding</span>
<span><i class="dot dot-ontime"></i>On time</span>
<span><i class="dot dot-delay"></i>Delayed</span>
</footer>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Boarding Pass
Three mobile boarding passes for the fictional Skyward Air, each laid out as a perforated card with a punched-edge stub. The header carries the airline mark and flight number, the route block shows airport codes, cities and 24-hour times either side of a dashed flight path, and a compact detail row surfaces gate, seat, zone and terminal in tabular figures. A large status pill reads Boarding, On time or Delayed, with the boarding pill softly pulsing.
Every pass is interactive. Tap Details to flip the card on its Y axis and reveal booking reference, cabin, fare, baggage allowance and frequent-flyer tier on the reverse. Add to wallet fires a toast and locks the button, while Brightness ringfences the card in sunrise orange and lifts contrast so a gate scanner can read the QR stub. The QR matrix is drawn deterministically on a canvas from each passenger’s PNR, complete with finder squares and a sweeping scan line.
Shortly after load, the boarding flight gets a simulated gate change: the gate value flashes, a Gate changed tag appears, and a toast announces the new gate — the kind of live nudge a real wallet pass would push. Everything is vanilla HTML, CSS and JavaScript with no dependencies, honours reduced-motion preferences, and reflows cleanly down to roughly 360px.
Illustrative UI only — fictional airline, not a real booking or flight system.