Airline — Booking Flow
A polished, four-step airline checkout that takes a fictional JFK to LHR fare from passenger details through extras, payment, and a final review to an animated confirmation. Validated name and card forms, a baggage stepper, seat and meal pickers, and an insurance toggle all feed a live fare-summary sidebar with a running total. Confirmation issues a perforated boarding pass with codes, gate, seat, and an on-time status pill. Built with vanilla JS and tabular aviation typography.
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;
--sh-1: 0 1px 2px rgba(19, 35, 59, 0.06), 0 1px 3px rgba(19, 35, 59, 0.05);
--sh-2: 0 6px 22px rgba(19, 35, 59, 0.09);
--sh-3: 0 18px 48px rgba(8, 78, 149, 0.16);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.tnum, b, strong, output, input { font-variant-numeric: tabular-nums; }
/* ---------- top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px clamp(16px, 4vw, 40px);
background: var(--surface);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 20;
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand-mark {
display: grid;
place-items: center;
width: 38px; height: 38px;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, var(--sky), var(--sky-d));
box-shadow: var(--sh-1);
}
.brand strong { display: block; font-size: 15px; font-weight: 800; letter-spacing: -0.01em; }
.brand-sub { font-size: 11.5px; color: var(--muted); font-weight: 500; }
.trip-pill {
display: flex; flex-direction: column; align-items: flex-end; gap: 1px;
text-align: right;
}
.trip-pill .route { display: inline-flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 700; color: var(--ink); }
.trip-pill .route svg { color: var(--sky); }
.trip-meta { font-size: 12px; color: var(--muted); font-weight: 500; }
/* ---------- stepper ---------- */
.stepper {
display: flex;
gap: 6px;
list-style: none;
margin: 0;
padding: 16px clamp(16px, 4vw, 40px) 4px;
max-width: 1120px;
}
.step {
display: flex; align-items: center; gap: 9px;
flex: 1;
font-size: 13px; font-weight: 600;
color: var(--muted);
min-width: 0;
}
.step .dot {
flex: none;
display: grid; place-items: center;
width: 26px; height: 26px;
border-radius: 50%;
background: var(--sky-50);
color: var(--sky-d);
font-size: 12.5px; font-weight: 700;
border: 1.5px solid transparent;
transition: .2s;
}
.step .lbl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.step.is-active { color: var(--ink); }
.step.is-active .dot { background: var(--sky); color: #fff; box-shadow: 0 0 0 4px var(--sky-50); }
.step.is-done { color: var(--ink-2); }
.step.is-done .dot { background: var(--ok); color: #fff; }
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 22px;
max-width: 1120px;
margin: 0 auto;
padding: 14px clamp(16px, 4vw, 40px) 56px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
overflow: hidden;
}
/* ---------- panes ---------- */
.pane { display: none; padding: clamp(20px, 3.5vw, 32px); animation: fade .32s ease; }
.pane.is-active { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.pane-title { margin: 0 0 4px; font-size: clamp(20px, 3vw, 25px); font-weight: 800; letter-spacing: -0.02em; }
.pane-sub { margin: 0 0 22px; color: var(--muted); font-size: 14px; max-width: 52ch; }
/* ---------- fields ---------- */
.pax-card {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px 18px;
margin: 0 0 16px;
}
.pax-card legend {
display: flex; align-items: center; gap: 8px;
font-size: 13px; font-weight: 600; color: var(--ink-2);
padding: 0 4px;
}
.pax-tag {
background: var(--sky); color: #fff;
font-size: 11px; font-weight: 700;
padding: 2px 9px; border-radius: 999px;
letter-spacing: .01em;
}
.pax-tag.alt { background: var(--sunrise); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.field > span { font-size: 12.5px; font-weight: 600; color: var(--ink-2); }
.field input[type="text"],
.field input[type="email"],
.field input[type="date"] {
font: inherit; font-size: 14.5px;
padding: 11px 13px;
border: 1.5px solid var(--line-2);
border-radius: var(--r-sm);
background: #fff; color: var(--ink);
transition: border-color .16s, box-shadow .16s;
width: 100%;
}
.field input::placeholder { color: #9aa8bb; }
.field input:focus-visible {
outline: none;
border-color: var(--sky);
box-shadow: 0 0 0 3px var(--sky-50);
}
.field input.invalid { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(212,73,62,.12); }
.err { font-size: 11.5px; color: var(--danger); font-style: normal; min-height: 0; }
.field.check {
flex-direction: row; align-items: center; gap: 10px; margin-top: 8px;
}
.field.check input { width: 17px; height: 17px; accent-color: var(--sky); flex: none; }
.field.check span { font-size: 13px; color: var(--ink-2); font-weight: 500; }
/* ---------- extras ---------- */
.extra-block { margin-bottom: 22px; }
.extra-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 11px; }
.extra-head h2 { margin: 0; font-size: 15.5px; font-weight: 700; }
.hint { font-size: 11.5px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .04em; }
.stepper-row, .toggle-row {
display: flex; align-items: center; justify-content: space-between;
gap: 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 13px 15px;
}
.extra-info { display: flex; align-items: center; gap: 12px; min-width: 0; }
.i-emoji { font-size: 22px; line-height: 1; }
.extra-info strong { display: block; font-size: 14.5px; font-weight: 600; }
.extra-info small { font-size: 12.5px; color: var(--muted); }
.qty { display: flex; align-items: center; gap: 4px; flex: none; }
.qbtn {
width: 34px; height: 34px;
border: 1.5px solid var(--line-2);
background: #fff; color: var(--sky-d);
border-radius: 9px;
font-size: 19px; font-weight: 600;
cursor: pointer;
display: grid; place-items: center;
transition: .14s;
}
.qbtn:hover { border-color: var(--sky); background: var(--sky-50); }
.qbtn:active { transform: scale(.92); }
.qbtn:disabled { opacity: .4; cursor: not-allowed; }
.qval { min-width: 26px; text-align: center; font-size: 16px; font-weight: 700; }
.opt-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 11px; }
.opt {
text-align: left;
border: 1.5px solid var(--line-2);
background: #fff;
border-radius: var(--r-md);
padding: 13px 13px 12px;
cursor: pointer;
font: inherit;
transition: .16s;
display: flex; flex-direction: column; gap: 4px;
}
.opt:hover { border-color: var(--sky); }
.opt-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.opt strong { font-size: 14px; font-weight: 600; }
.opt-price { font-size: 13px; font-weight: 700; color: var(--sky-d); white-space: nowrap; }
.opt small { font-size: 12px; color: var(--muted); }
.opt[aria-checked="true"] {
border-color: var(--sky);
background: var(--sky-50);
box-shadow: 0 0 0 1px var(--sky) inset;
}
.opt:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--sky-50); }
/* switch */
.toggle-row { cursor: pointer; position: relative; }
.switch { position: absolute; opacity: 0; pointer-events: none; }
.switch-track {
flex: none;
width: 46px; height: 27px;
border-radius: 999px;
background: var(--line-2);
position: relative;
transition: background .2s;
}
.switch-track::after {
content: ""; position: absolute; top: 3px; left: 3px;
width: 21px; height: 21px; border-radius: 50%;
background: #fff; box-shadow: var(--sh-1);
transition: transform .2s;
}
.switch:checked + .switch-track { background: var(--ok); }
.switch:checked + .switch-track::after { transform: translateX(19px); }
.switch:focus-visible + .switch-track { box-shadow: 0 0 0 3px var(--sky-50); }
/* ---------- review ---------- */
.review { margin: 0; display: grid; gap: 0; }
.review .row {
display: flex; justify-content: space-between; gap: 16px;
padding: 13px 2px;
border-bottom: 1px dashed var(--line);
}
.review .row:last-child { border-bottom: 0; }
.review dt { color: var(--muted); font-size: 13px; font-weight: 500; margin: 0; }
.review dd { margin: 0; font-size: 14px; font-weight: 600; text-align: right; }
.review .row.head dt { font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--sky-d); font-weight: 700; }
/* ---------- nav buttons ---------- */
.pane-nav {
display: flex; justify-content: space-between; gap: 12px;
padding: 18px clamp(20px, 3.5vw, 32px);
border-top: 1px solid var(--line);
background: linear-gradient(0deg, var(--cloud), #fff);
}
.btn {
font: inherit; font-size: 14.5px; font-weight: 700;
padding: 12px 22px;
border-radius: var(--r-sm);
border: 1.5px solid transparent;
cursor: pointer;
transition: .16s;
}
.btn-primary {
background: var(--sky); color: #fff;
box-shadow: 0 6px 16px rgba(10,102,194,.28);
}
.btn-primary:hover { background: var(--sky-d); }
.btn-primary:active { transform: translateY(1px); }
.btn-ghost { background: #fff; border-color: var(--line-2); color: var(--ink-2); }
.btn-ghost:hover { border-color: var(--sky); color: var(--sky-d); }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--sky-50); }
.btn-primary:focus-visible { box-shadow: 0 0 0 3px rgba(10,102,194,.3); }
/* ---------- summary sidebar ---------- */
.summary { position: sticky; top: 86px; }
.summary-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: 18px 18px 16px;
}
.sum-flight { padding-bottom: 14px; border-bottom: 1px solid var(--line); margin-bottom: 14px; }
.sum-route { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 800; letter-spacing: -.01em; }
.sum-arrow { color: var(--sky); font-weight: 600; }
.sum-time { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; font-size: 13px; font-weight: 600; color: var(--ink-2); }
.sum-time sup { color: var(--sunrise); font-weight: 700; }
.sum-dur { font-size: 11.5px; color: var(--muted); font-weight: 500; }
.sum-title { margin: 0 0 10px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; }
.sum-lines { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 9px; }
.sum-lines li { display: flex; justify-content: space-between; gap: 12px; font-size: 13.5px; color: var(--ink-2); }
.sum-lines li b { font-weight: 700; color: var(--ink); }
.sum-lines li.sub { color: var(--muted); font-size: 12.5px; padding-left: 10px; }
.sum-lines li.sub b { color: var(--ink-2); font-weight: 600; }
.sum-lines li.muted-free b { color: var(--ok); }
.sum-total {
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
padding-top: 14px; border-top: 1.5px solid var(--line-2);
}
.sum-total span { font-size: 14px; font-weight: 700; display: flex; flex-direction: column; }
.sum-total small { font-size: 11px; color: var(--muted); font-weight: 500; }
.sum-total b { font-size: 24px; font-weight: 800; letter-spacing: -.02em; }
.sum-secure { display: flex; align-items: center; gap: 6px; margin: 14px 0 0; font-size: 11.5px; color: var(--muted); font-weight: 500; }
.sum-secure svg { color: var(--ok); }
.flash { animation: flash .5s ease; }
@keyframes flash { 0% { color: var(--sunrise); } 100% { color: inherit; } }
/* ---------- confirmation ---------- */
.confirm { text-align: center; }
.check-burst {
width: 72px; height: 72px;
margin: 0 auto 16px;
border-radius: 50%;
display: grid; place-items: center;
color: #fff;
background: linear-gradient(135deg, var(--ok), #157a4b);
box-shadow: 0 12px 30px rgba(31,157,98,.34);
animation: pop .45s cubic-bezier(.2,.9,.3,1.4);
}
@keyframes pop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.confirm .pane-sub { margin-bottom: 24px; }
#confEmail { color: var(--sky-d); font-weight: 700; }
.boarding-pass {
display: flex;
text-align: left;
max-width: 480px;
margin: 0 auto 24px;
border-radius: var(--r-md);
box-shadow: var(--sh-3);
overflow: hidden;
background: #fff;
}
.bp-main {
flex: 1;
padding: 18px 20px;
background: linear-gradient(155deg, #fff 60%, var(--sky-50));
}
.bp-airline { display: flex; flex-direction: column; gap: 1px; margin-bottom: 16px; }
.bp-airline strong { font-size: 14px; font-weight: 800; letter-spacing: .08em; color: var(--sky-d); }
.bp-airline span { font-size: 11.5px; color: var(--muted); }
.bp-airline b { color: var(--ink); letter-spacing: .04em; }
.bp-route { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 16px; }
.bp-port b { display: block; font-size: 24px; font-weight: 800; letter-spacing: -.02em; }
.bp-port span { font-size: 11px; color: var(--muted); }
.bp-port:last-child { text-align: right; }
.bp-plane { display: flex; flex-direction: column; align-items: center; color: var(--sky); flex: 1; }
.bp-plane span { font-size: 11px; color: var(--ink-2); font-weight: 600; margin-top: 2px; }
.bp-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 11px 14px; }
.bp-grid small { font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.bp-grid strong { display: block; font-size: 14px; font-weight: 700; }
.bp-stub {
width: 118px; flex: none;
padding: 18px 16px;
background: var(--ink);
color: #fff;
display: flex; flex-direction: column; gap: 12px;
position: relative;
}
.bp-stub::before {
content: ""; position: absolute; left: -7px; top: 0; bottom: 0;
width: 14px;
background:
radial-gradient(circle at 0 7px, transparent 6px, var(--ink) 6px) 0 0 / 14px 16px repeat-y;
}
.bp-stub small { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: rgba(255,255,255,.55); }
.bp-stub strong { display: block; font-size: 16px; font-weight: 700; }
.bp-bars { height: 38px; margin-top: auto;
background: repeating-linear-gradient(90deg, #fff 0 2px, transparent 2px 4px, #fff 4px 5px, transparent 5px 9px);
border-radius: 2px; opacity: .85;
}
.pill {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 700;
padding: 3px 9px; border-radius: 999px;
}
.pill-boarding { background: rgba(31,157,98,.2); color: #7bf0b6; }
.pill-boarding::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #38d98a; box-shadow: 0 0 0 0 rgba(56,217,138,.6); animation: ping 1.6s infinite; }
@keyframes ping { 70%,100% { box-shadow: 0 0 0 6px rgba(56,217,138,0); } }
/* ---------- toast ---------- */
.toast-wrap { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 60; display: flex; flex-direction: column; gap: 8px; align-items: center; width: max-content; max-width: 90vw; }
.toast {
background: var(--ink); color: #fff;
font-size: 13.5px; font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--sh-3);
display: flex; align-items: center; gap: 8px;
animation: toastIn .26s cubic-bezier(.2,.9,.3,1.2);
}
.toast.bad { background: var(--danger); }
.toast.good::before { content: "✓"; }
@keyframes toastIn { from { transform: translateY(14px); opacity: 0; } to { transform: none; opacity: 1; } }
/* ---------- responsive ---------- */
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.summary { position: static; order: -1; }
}
@media (max-width: 520px) {
.stepper .lbl { display: none; }
.stepper { gap: 10px; justify-content: space-between; }
.step { flex: none; }
.grid-2 { grid-template-columns: 1fr; }
.opt-grid { grid-template-columns: 1fr; }
.trip-pill .trip-meta { font-size: 11px; }
.pane-nav { flex-direction: column-reverse; }
.pane-nav .btn { width: 100%; }
.boarding-pass { flex-direction: column; }
.bp-stub { width: auto; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 14px; }
.bp-stub::before { left: 0; right: 0; top: -7px; bottom: auto; width: auto; height: 14px;
background: radial-gradient(circle at 7px 0, transparent 6px, var(--ink) 6px) 0 0 / 16px 14px repeat-x; }
.bp-bars { width: 100%; height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}(function () {
"use strict";
var PAX = 2;
var BASE_FARE = 389; // per passenger
var TAXES = 94; // per passenger
var SEAT_LABELS = { standard: "14A", "extra-legroom": "11C", front: "3A" };
// booking state
var state = {
step: 1,
bags: 0,
seat: "standard",
meal: "none",
insurance: false,
lead: { first: "", last: "", email: "" }
};
var prices = {
bags: 42, // per bag
seat: 0,
meal: 0, // per pax
insurance: 29 // flat
};
// ---- helpers ----
var $ = function (s, c) { return (c || document).querySelector(s); };
var $$ = function (s, c) { return Array.prototype.slice.call((c || document).querySelectorAll(s)); };
var money = function (n) {
return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
};
var toastWrap = $("#toastWrap");
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.style.transition = "opacity .3s, transform .3s";
el.style.opacity = "0";
el.style.transform = "translateY(10px)";
setTimeout(function () { el.remove(); }, 300);
}, 2400);
}
// ---- totals ----
function baseTotal() { return (BASE_FARE + TAXES) * PAX; }
function extrasTotal() {
return (
state.bags * prices.bags +
prices.seat * PAX +
prices.meal * PAX +
(state.insurance ? prices.insurance : 0)
);
}
function grandTotal() { return baseTotal() + extrasTotal(); }
function renderSummary() {
var lines = [];
lines.push({ label: "Fare <span class='tnum'></span>", value: money(BASE_FARE * PAX), sub: false, note: PAX + " × " + money(BASE_FARE) });
lines.push({ label: "Taxes & fees", value: money(TAXES * PAX) });
if (state.bags > 0) lines.push({ label: "Checked bags (" + state.bags + ")", value: money(state.bags * prices.bags) });
if (prices.seat > 0) lines.push({ label: "Seats × " + PAX, value: money(prices.seat * PAX) });
if (prices.meal > 0) lines.push({ label: "Meals × " + PAX, value: money(prices.meal * PAX) });
if (state.insurance) lines.push({ label: "Travel insurance", value: money(prices.insurance) });
var ul = $("#sumLines");
ul.innerHTML = lines.map(function (l) {
var note = l.note ? " <small style='color:var(--muted);font-weight:500'>" + l.note + "</small>" : "";
return "<li><span>" + l.label + note + "</span><b class='tnum'>" + l.value + "</b></li>";
}).join("");
var totalEl = $("#sumTotal");
totalEl.textContent = money(grandTotal());
totalEl.classList.remove("flash");
void totalEl.offsetWidth;
totalEl.classList.add("flash");
}
// ---- stepper UI ----
var STEP_LABELS = {
1: "Continue to extras",
2: "Continue to payment",
3: "Review booking",
4: "Confirm & pay " // total appended
};
function showStep(n) {
state.step = n;
$$(".pane").forEach(function (p) {
p.classList.toggle("is-active", +p.getAttribute("data-pane") === n);
});
$$(".step").forEach(function (s) {
var sn = +s.getAttribute("data-step");
s.classList.toggle("is-active", sn === n);
s.classList.toggle("is-done", sn < n);
});
var nav = $("#paneNav");
var back = $("#backBtn");
var next = $("#nextBtn");
if (n === 5) { nav.style.display = "none"; return; }
nav.style.display = "flex";
back.hidden = n === 1;
if (n === 4) {
next.innerHTML = "Confirm & pay " + money(grandTotal());
} else {
next.innerHTML = STEP_LABELS[n];
}
window.scrollTo({ top: 0, behavior: "smooth" });
}
// ---- validation ----
function setErr(input, msg) {
var wrap = input.closest(".field");
if (!wrap) return;
var err = wrap.querySelector("[data-err]");
input.classList.toggle("invalid", !!msg);
if (err) err.textContent = msg || "";
}
function validatePassengers() {
var form = $("#paxForm");
var ok = true;
var reqs = $$("input[required]", form);
reqs.forEach(function (inp) {
var v = inp.value.trim();
if (!v) { setErr(inp, "Required"); ok = false; return; }
if (inp.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) {
setErr(inp, "Enter a valid email"); ok = false; return;
}
setErr(inp, "");
});
if (ok) {
state.lead.first = form.firstName.value.trim();
state.lead.last = form.lastName.value.trim();
state.lead.email = form.email.value.trim();
}
return ok;
}
function validatePayment() {
var form = $("#payForm");
var ok = true;
setErr(form.cardName, form.cardName.value.trim() ? "" : "Required");
if (!form.cardName.value.trim()) ok = false;
var digits = form.cardNumber.value.replace(/\s/g, "");
if (!/^\d{13,19}$/.test(digits)) { setErr(form.cardNumber, "Enter a valid card number"); ok = false; }
else setErr(form.cardNumber, "");
if (!/^\d{2}\/\d{2}$/.test(form.cardExp.value.trim())) { setErr(form.cardExp, "MM/YY"); ok = false; }
else setErr(form.cardExp, "");
if (!/^\d{3,4}$/.test(form.cardCvc.value.trim())) { setErr(form.cardCvc, "3–4 digits"); ok = false; }
else setErr(form.cardCvc, "");
if (!form.terms.checked) { setErr(form.terms, "Please accept to continue"); ok = false; }
else setErr(form.terms, "");
return ok;
}
// ---- review ----
function renderReview() {
var seatLabel = { standard: "Standard (auto)", "extra-legroom": "Extra legroom", front: "Front cabin" }[state.seat];
var mealLabel = { none: "No meal", chicken: "Grilled chicken", vegan: "Vegan bowl" }[state.meal];
var rows = [
{ head: true, dt: "Itinerary" },
{ dt: "Flight", dd: "Skyward SW 418" },
{ dt: "Route", dd: "JFK → LHR · nonstop" },
{ dt: "Departs", dd: "18 Jun · 19:40" },
{ head: true, dt: "Passengers" },
{ dt: "Lead", dd: (state.lead.first + " " + state.lead.last).trim() || "—" },
{ dt: "Contact", dd: state.lead.email || "—" },
{ head: true, dt: "Extras" },
{ dt: "Checked bags", dd: state.bags ? state.bags + " bag(s)" : "None" },
{ dt: "Seat", dd: seatLabel },
{ dt: "Meal", dd: mealLabel },
{ dt: "Insurance", dd: state.insurance ? "Yes — $29" : "No" },
{ head: true, dt: "Payment" },
{ dt: "Fare + taxes", dd: money(baseTotal()) },
{ dt: "Extras", dd: money(extrasTotal()) },
{ dt: "Total due", dd: money(grandTotal()) }
];
$("#reviewBody").innerHTML = rows.map(function (r) {
if (r.head) return "<div class='row head'><dt>" + r.dt + "</dt><dd></dd></div>";
return "<div class='row'><dt>" + r.dt + "</dt><dd>" + r.dd + "</dd></div>";
}).join("");
}
// ---- confirmation ----
function genRef() {
var c = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var r = "";
for (var i = 0; i < 6; i++) r += c[Math.floor(Math.random() * c.length)];
return r;
}
function confirmBooking() {
var ref = genRef();
$("#confRef").textContent = ref;
$("#confEmail").textContent = state.lead.email || "you@email.com";
$("#confPax").textContent = ((state.lead.first + " " + state.lead.last).trim() || "Guest").toUpperCase();
$("#confSeat").textContent = SEAT_LABELS[state.seat];
$("#confTotal").textContent = money(grandTotal());
showStep(5);
toast("Booking " + ref + " confirmed", "good");
}
// ---- wiring: extras ----
// bags stepper
var bagRow = $(".stepper-row[data-extra='bags']");
var bagVal = $("[data-qval]", bagRow);
function setBags(n) {
state.bags = Math.max(0, Math.min(8, n));
bagVal.textContent = state.bags;
$(".qbtn[data-q='dec']", bagRow).disabled = state.bags === 0;
renderSummary();
}
$(".qbtn[data-q='inc']", bagRow).addEventListener("click", function () { setBags(state.bags + 1); });
$(".qbtn[data-q='dec']", bagRow).addEventListener("click", function () { setBags(state.bags - 1); });
// option groups (seat, meal)
$$(".opt-grid").forEach(function (grid) {
var key = grid.getAttribute("data-extra"); // "seat" | "meal"
grid.addEventListener("click", function (e) {
var btn = e.target.closest(".opt");
if (!btn) return;
$$(".opt", grid).forEach(function (o) { o.setAttribute("aria-checked", "false"); });
btn.setAttribute("aria-checked", "true");
state[key] = btn.getAttribute("data-val");
prices[key] = parseFloat(btn.getAttribute("data-price")) || 0;
renderSummary();
});
grid.addEventListener("keydown", function (e) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
var opts = $$(".opt", grid);
var i = opts.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
var ni = e.key === "ArrowRight" ? (i + 1) % opts.length : (i - 1 + opts.length) % opts.length;
opts[ni].focus();
opts[ni].click();
});
});
$$(".opt").forEach(function (o) { o.tabIndex = 0; });
// insurance switch
var insRow = $(".toggle-row[data-extra='insurance']");
$(".switch", insRow).addEventListener("change", function (e) {
state.insurance = e.target.checked;
renderSummary();
toast(e.target.checked ? "Travel insurance added" : "Insurance removed");
});
// same-contact checkbox feedback
var sc = $("input[name='sameContact']");
if (sc) sc.addEventListener("change", function (e) {
toast(e.target.checked ? "Contact details shared" : "Add separate contact details");
});
// card formatting niceties
var cardNum = $("#payForm [name='cardNumber']");
cardNum.addEventListener("input", function () {
var d = cardNum.value.replace(/\D/g, "").slice(0, 16);
cardNum.value = d.replace(/(.{4})/g, "$1 ").trim();
});
var cardExp = $("#payForm [name='cardExp']");
cardExp.addEventListener("input", function () {
var d = cardExp.value.replace(/\D/g, "").slice(0, 4);
cardExp.value = d.length > 2 ? d.slice(0, 2) + "/" + d.slice(2) : d;
});
// clear field error on input
$$("#paxForm input, #payForm input").forEach(function (inp) {
inp.addEventListener("input", function () { setErr(inp, ""); });
});
// ---- nav buttons ----
$("#nextBtn").addEventListener("click", function () {
var n = state.step;
if (n === 1) {
if (!validatePassengers()) { toast("Please complete passenger details", "bad"); return; }
showStep(2);
} else if (n === 2) {
showStep(3);
} else if (n === 3) {
if (!validatePayment()) { toast("Check your payment details", "bad"); return; }
renderReview();
showStep(4);
} else if (n === 4) {
confirmBooking();
}
});
$("#backBtn").addEventListener("click", function () {
if (state.step > 1) showStep(state.step - 1);
});
// stepper clicks (only backward to completed steps)
$$(".step").forEach(function (s) {
s.addEventListener("click", function () {
var n = +s.getAttribute("data-step");
if (n < state.step) showStep(n);
});
});
$("#restartBtn").addEventListener("click", function () {
state = { step: 1, bags: 0, seat: "standard", meal: "none", insurance: false, lead: { first: "", last: "", email: "" } };
prices = { bags: 42, seat: 0, meal: 0, insurance: 29 };
$("#paxForm").reset();
$("#payForm").reset();
setBags(0);
$$(".opt-grid").forEach(function (g) {
$$(".opt", g).forEach(function (o, i) { o.setAttribute("aria-checked", i === 0 ? "true" : "false"); });
});
$(".switch", insRow).checked = false;
renderSummary();
showStep(1);
toast("Started a new booking");
});
// ---- init ----
setBags(0);
renderSummary();
showStep(1);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skyward — Booking</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>
<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="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5z"/></svg>
</span>
<div>
<strong>Skyward Air</strong>
<span class="brand-sub">Secure booking</span>
</div>
</div>
<div class="trip-pill" aria-label="Selected flight">
<span class="route"><b>JFK</b> <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg> <b>LHR</b></span>
<span class="trip-meta">SW 418 · 18 Jun · 19:40</span>
</div>
</header>
<ol class="stepper" id="stepper" aria-label="Booking steps">
<li class="step is-active" data-step="1"><span class="dot">1</span><span class="lbl">Passengers</span></li>
<li class="step" data-step="2"><span class="dot">2</span><span class="lbl">Extras</span></li>
<li class="step" data-step="3"><span class="dot">3</span><span class="lbl">Payment</span></li>
<li class="step" data-step="4"><span class="dot">4</span><span class="lbl">Review</span></li>
</ol>
<main class="layout">
<div class="panel">
<!-- STEP 1 — PASSENGERS -->
<section class="pane is-active" data-pane="1" aria-labelledby="h1">
<h1 id="h1" class="pane-title">Passenger details</h1>
<p class="pane-sub">Enter names exactly as they appear on each traveller's passport.</p>
<form id="paxForm" novalidate>
<fieldset class="pax-card">
<legend><span class="pax-tag">Adult 1</span> Lead passenger</legend>
<div class="grid-2">
<label class="field">
<span>First name</span>
<input name="firstName" type="text" autocomplete="given-name" required placeholder="Amara" />
<em class="err" data-err></em>
</label>
<label class="field">
<span>Last name</span>
<input name="lastName" type="text" autocomplete="family-name" required placeholder="Okafor" />
<em class="err" data-err></em>
</label>
<label class="field">
<span>Email</span>
<input name="email" type="email" autocomplete="email" required placeholder="you@email.com" />
<em class="err" data-err></em>
</label>
<label class="field">
<span>Date of birth</span>
<input name="dob" type="date" required max="2024-12-31" />
<em class="err" data-err></em>
</label>
</div>
</fieldset>
<fieldset class="pax-card">
<legend><span class="pax-tag alt">Adult 2</span> Travelling companion</legend>
<div class="grid-2">
<label class="field">
<span>First name</span>
<input name="firstName2" type="text" required placeholder="Daniel" />
<em class="err" data-err></em>
</label>
<label class="field">
<span>Last name</span>
<input name="lastName2" type="text" required placeholder="Okafor" />
<em class="err" data-err></em>
</label>
</div>
<label class="field check">
<input name="sameContact" type="checkbox" checked />
<span>Use the same contact details for this passenger</span>
</label>
</fieldset>
</form>
</section>
<!-- STEP 2 — EXTRAS -->
<section class="pane" data-pane="2" aria-labelledby="h2">
<h1 id="h2" class="pane-title">Add extras</h1>
<p class="pane-sub">Tailor your trip. Everything updates your running total instantly.</p>
<div class="extra-block">
<div class="extra-head"><h2>Checked baggage</h2><span class="hint">23 kg per bag</span></div>
<div class="stepper-row" data-extra="bags" data-price="42" aria-label="Checked bags">
<div class="extra-info"><span class="i-emoji" aria-hidden="true">🧳</span><div><strong>Add a checked bag</strong><small>$42 per bag · both passengers</small></div></div>
<div class="qty">
<button type="button" class="qbtn" data-q="dec" aria-label="Remove bag">−</button>
<output class="qval" data-qval>0</output>
<button type="button" class="qbtn" data-q="inc" aria-label="Add bag">+</button>
</div>
</div>
</div>
<div class="extra-block">
<div class="extra-head"><h2>Seat selection</h2><span class="hint">pick a zone</span></div>
<div class="opt-grid" data-extra="seat" role="radiogroup" aria-label="Seat type">
<button type="button" class="opt" data-val="standard" data-price="0" role="radio" aria-checked="true"><span class="opt-top"><strong>Standard</strong><span class="opt-price">Free</span></span><small>Auto-assigned at check-in</small></button>
<button type="button" class="opt" data-val="extra-legroom" data-price="38" role="radio" aria-checked="false"><span class="opt-top"><strong>Extra legroom</strong><span class="opt-price">+$38</span></span><small>Exit row, more space</small></button>
<button type="button" class="opt" data-val="front" data-price="22" role="radio" aria-checked="false"><span class="opt-top"><strong>Front cabin</strong><span class="opt-price">+$22</span></span><small>First off the plane</small></button>
</div>
</div>
<div class="extra-block">
<div class="extra-head"><h2>Onboard meal</h2><span class="hint">per passenger</span></div>
<div class="opt-grid" data-extra="meal" role="radiogroup" aria-label="Meal">
<button type="button" class="opt" data-val="none" data-price="0" role="radio" aria-checked="true"><span class="opt-top"><strong>No meal</strong><span class="opt-price">Free</span></span><small>Snacks for purchase</small></button>
<button type="button" class="opt" data-val="chicken" data-price="18" role="radio" aria-checked="false"><span class="opt-top"><strong>Grilled chicken</strong><span class="opt-price">+$18</span></span><small>With seasonal veg</small></button>
<button type="button" class="opt" data-val="vegan" data-price="16" role="radio" aria-checked="false"><span class="opt-top"><strong>Vegan bowl</strong><span class="opt-price">+$16</span></span><small>Chickpea & quinoa</small></button>
</div>
</div>
<div class="extra-block">
<label class="toggle-row" data-extra="insurance" data-price="29">
<div class="extra-info"><span class="i-emoji" aria-hidden="true">🛡️</span><div><strong>Travel insurance</strong><small>$29 · cancellation & medical cover</small></div></div>
<input type="checkbox" class="switch" aria-label="Add travel insurance" />
<span class="switch-track" aria-hidden="true"></span>
</label>
</div>
</section>
<!-- STEP 3 — PAYMENT -->
<section class="pane" data-pane="3" aria-labelledby="h3">
<h1 id="h3" class="pane-title">Payment</h1>
<p class="pane-sub">Your card is charged once. Encrypted and PCI-compliant.</p>
<form id="payForm" novalidate>
<label class="field">
<span>Name on card</span>
<input name="cardName" type="text" required placeholder="A OKAFOR" autocomplete="cc-name" />
<em class="err" data-err></em>
</label>
<label class="field">
<span>Card number</span>
<input name="cardNumber" type="text" inputmode="numeric" required placeholder="4242 4242 4242 4242" maxlength="19" autocomplete="cc-number" />
<em class="err" data-err></em>
</label>
<div class="grid-2">
<label class="field">
<span>Expiry (MM/YY)</span>
<input name="cardExp" type="text" inputmode="numeric" required placeholder="08/29" maxlength="5" autocomplete="cc-exp" />
<em class="err" data-err></em>
</label>
<label class="field">
<span>CVC</span>
<input name="cardCvc" type="text" inputmode="numeric" required placeholder="123" maxlength="4" autocomplete="cc-csc" />
<em class="err" data-err></em>
</label>
</div>
<label class="field check">
<input name="terms" type="checkbox" required />
<span>I accept the fare conditions & conditions of carriage</span>
<em class="err" data-err></em>
</label>
</form>
</section>
<!-- STEP 4 — REVIEW -->
<section class="pane" data-pane="4" aria-labelledby="h4">
<h1 id="h4" class="pane-title">Review & confirm</h1>
<p class="pane-sub">Last check before we issue your tickets.</p>
<dl class="review" id="reviewBody"></dl>
</section>
<!-- CONFIRMATION -->
<section class="pane confirm" data-pane="5" aria-labelledby="h5">
<div class="check-burst" aria-hidden="true">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<h1 id="h5" class="pane-title">You're booked!</h1>
<p class="pane-sub">Tickets emailed to <span id="confEmail">you@email.com</span></p>
<div class="boarding-pass" id="boardingPass">
<div class="bp-main">
<div class="bp-airline"><strong>SKYWARD AIR</strong><span>Booking <b id="confRef">—</b></span></div>
<div class="bp-route">
<div class="bp-port"><b>JFK</b><span>New York</span></div>
<div class="bp-plane"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5z"/></svg><span>SW 418</span></div>
<div class="bp-port"><b>LHR</b><span>London</span></div>
</div>
<div class="bp-grid">
<div><small>Passenger</small><strong id="confPax">—</strong></div>
<div><small>Date</small><strong>18 Jun</strong></div>
<div><small>Boards</small><strong>19:10</strong></div>
<div><small>Gate</small><strong>B22</strong></div>
</div>
</div>
<div class="bp-stub">
<div class="bp-status"><span class="pill pill-boarding">On time</span></div>
<div><small>Seat</small><strong id="confSeat">14A</strong></div>
<div><small>Total paid</small><strong id="confTotal">$0</strong></div>
<div class="bp-bars" aria-hidden="true"></div>
</div>
</div>
<button type="button" class="btn btn-primary" id="restartBtn">Start a new booking</button>
</section>
<!-- FOOTER NAV -->
<div class="pane-nav" id="paneNav">
<button type="button" class="btn btn-ghost" id="backBtn" hidden>Back</button>
<button type="button" class="btn btn-primary" id="nextBtn">Continue to extras</button>
</div>
</div>
<!-- FARE SUMMARY SIDEBAR -->
<aside class="summary" aria-label="Fare summary">
<div class="summary-card">
<div class="sum-flight">
<div class="sum-route"><b>JFK</b><span class="sum-arrow" aria-hidden="true">→</span><b>LHR</b></div>
<div class="sum-time"><span>19:40</span><span class="sum-dur">7h 05m · nonstop</span><span>07:45<sup>+1</sup></span></div>
</div>
<h3 class="sum-title">Fare summary</h3>
<ul class="sum-lines" id="sumLines"></ul>
<div class="sum-total">
<span>Total <small>2 passengers · USD</small></span>
<b id="sumTotal">$0</b>
</div>
<p class="sum-secure"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg> Price held for 09:58</p>
</div>
</aside>
</main>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Booking Flow
A complete airline checkout for a fictional Skyward Air flight (SW 418, JFK → LHR). The flow moves through four numbered steps — Passengers, Extras, Payment, and Review — using a clickable stepper that lets travellers jump back to completed steps. Each step is a distinct pane with smooth fade transitions, while a sticky fare-summary sidebar tracks the itinerary, the per-passenger breakdown, and a running total that flashes whenever it changes.
The extras step is the interactive heart of the demo: a baggage quantity stepper, radio-style seat and meal pickers (keyboard-navigable with arrow keys), and an animated insurance switch all recalculate the total instantly and push line items into the summary. Forms validate inline — required names, a real email pattern, and a formatted card number, expiry, and CVC — surfacing clear field-level errors and toast feedback rather than blocking silently.
Confirming issues a generated booking reference and renders a perforated boarding pass with airport codes, gate, seat, total paid, a barcode strip, and a pulsing “On time” status pill. Everything is self-contained vanilla HTML, CSS, and JavaScript with Inter, tabular figures for times and prices, and a layout that collapses gracefully to mobile.
Illustrative UI only — fictional airline, not a real booking or flight system.