Airline — Flight Search
A polished, status-forward flight search panel for the fictional Skyloft Air, built in vanilla HTML, CSS and JavaScript. It pairs Inter with tabular figures for airport codes, times and fares across round-trip, one-way and multi-city tabs, a from and to swap, date pickers, a passenger stepper with cabin selector, inline validation, search toasts, and live recent searches alongside a popular routes grid. Fully responsive down to 360px with an aviation blue and sunrise accent palette.
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-sm: 0 1px 2px rgba(19, 35, 59, 0.06), 0 1px 3px rgba(19, 35, 59, 0.05);
--shadow-md: 0 6px 18px rgba(19, 35, 59, 0.08);
--shadow-lg: 0 18px 48px rgba(19, 35, 59, 0.14);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 460px at 80% -120px, var(--sky-50), transparent 70%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.tnum { font-variant-numeric: tabular-nums; }
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 22px;
padding: 14px 22px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: saturate(1.4) blur(10px);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 30;
}
.brand { display: flex; align-items: center; gap: 9px; color: var(--sky); }
.brand-mark {
display: grid;
place-items: center;
width: 34px; height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--sky), var(--sky-d));
color: #fff;
box-shadow: var(--shadow-sm);
}
.brand-name { font-weight: 800; font-size: 17px; color: var(--ink); letter-spacing: -0.01em; }
.brand-name span { color: var(--sky); }
.topnav { display: flex; gap: 6px; margin-left: 8px; }
.topnav a {
text-decoration: none;
color: var(--ink-2);
font-weight: 600;
font-size: 14px;
padding: 8px 12px;
border-radius: var(--r-sm);
transition: background .15s, color .15s;
}
.topnav a:hover { background: var(--sky-50); color: var(--sky-d); }
.topnav a.active { color: var(--sky-d); background: var(--sky-50); }
.account {
margin-left: auto;
display: flex; align-items: center; gap: 9px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 12px 5px 5px;
cursor: pointer;
font: inherit;
color: var(--ink);
box-shadow: var(--shadow-sm);
transition: border-color .15s, box-shadow .15s;
}
.account:hover { border-color: var(--line-2); }
.avatar {
width: 28px; height: 28px;
border-radius: 50%;
display: grid; place-items: center;
background: linear-gradient(150deg, var(--sunrise), #ff9c5e);
color: #fff; font-weight: 700; font-size: 11px;
}
.account-name { font-weight: 600; font-size: 14px; }
/* ---------- Wrap / hero ---------- */
.wrap { max-width: 1040px; margin: 0 auto; padding: 30px 22px 64px; }
.hero { margin-bottom: 22px; }
.hero-kicker {
margin: 0 0 6px;
text-transform: uppercase;
letter-spacing: .14em;
font-size: 12px;
font-weight: 700;
color: var(--sunrise);
}
.hero-title { margin: 0 0 6px; font-size: 34px; font-weight: 800; letter-spacing: -0.02em; }
.hero-sub { margin: 0; color: var(--muted); font-size: 15px; }
/* ---------- Search card ---------- */
.search {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
padding: 8px 8px 18px;
}
.trip-tabs {
display: flex;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--line);
margin-bottom: 4px;
}
.trip-tab {
font: inherit;
font-weight: 600;
font-size: 14px;
color: var(--ink-2);
background: transparent;
border: 0;
border-radius: var(--r-sm);
padding: 9px 16px;
cursor: pointer;
position: relative;
transition: background .15s, color .15s;
}
.trip-tab:hover { background: var(--cloud); }
.trip-tab.active { color: var(--sky-d); background: var(--sky-50); }
.trip-tab.active::after {
content: "";
position: absolute;
left: 16px; right: 16px; bottom: -9px;
height: 2px;
background: var(--sky);
border-radius: 2px;
}
/* ---------- Legs ---------- */
.legs { display: flex; flex-direction: column; gap: 12px; padding: 14px 12px 2px; }
.leg {
display: grid;
grid-template-columns: 1fr 40px 1fr 0.7fr 0.7fr;
align-items: end;
gap: 10px;
}
.leg.no-return { grid-template-columns: 1fr 40px 1fr 0.9fr; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.airport-input {
display: flex;
align-items: center;
gap: 8px;
background: var(--cloud);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 0 12px;
height: 52px;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.airport-input:focus-within {
border-color: var(--sky);
background: #fff;
box-shadow: 0 0 0 3px var(--sky-50);
}
.ai-icon { color: var(--sky); display: flex; }
.airport-input input {
flex: 1;
border: 0;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 15px;
color: var(--ink);
min-width: 0;
outline: none;
}
.airport-input input::placeholder { color: var(--muted); font-weight: 500; }
.ap-code {
font-variant-numeric: tabular-nums;
font-weight: 800;
font-size: 13px;
letter-spacing: .04em;
color: var(--ink-2);
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 3px 7px;
min-width: 44px;
text-align: center;
}
.swap {
align-self: center;
margin-bottom: 4px;
width: 40px; height: 40px;
border-radius: 50%;
border: 1px solid var(--line);
background: #fff;
color: var(--sky);
cursor: pointer;
display: grid; place-items: center;
box-shadow: var(--shadow-sm);
transition: transform .25s ease, border-color .15s, color .15s;
}
.swap:hover { border-color: var(--sky); color: var(--sky-d); }
.swap.spin { transform: rotate(180deg); }
.date {
height: 52px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--cloud);
font: inherit;
font-weight: 600;
color: var(--ink);
padding: 0 12px;
outline: none;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.date:focus { border-color: var(--sky); background: #fff; box-shadow: 0 0 0 3px var(--sky-50); }
.date.disabled { opacity: .45; pointer-events: none; }
.leg-remove {
align-self: center;
margin-bottom: 4px;
width: 34px; height: 34px;
border-radius: 50%;
border: 1px solid var(--line);
background: #fff;
color: var(--danger);
cursor: pointer;
display: grid; place-items: center;
transition: border-color .15s, background .15s;
}
.leg-remove:hover { border-color: var(--danger); background: #fff5f4; }
.add-leg {
margin: 14px 12px 0;
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px dashed var(--line-2);
border-radius: var(--r-sm);
color: var(--sky-d);
font: inherit;
font-weight: 600;
padding: 9px 14px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.add-leg:hover { background: var(--sky-50); border-color: var(--sky); }
/* ---------- Controls ---------- */
.controls {
display: flex;
align-items: stretch;
gap: 12px;
padding: 16px 12px 0;
flex-wrap: wrap;
}
.pax-wrap { position: relative; flex: 1; min-width: 240px; }
.pax-trigger {
width: 100%;
height: 52px;
display: flex;
align-items: center;
gap: 9px;
background: var(--cloud);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 0 14px;
font: inherit;
font-weight: 600;
color: var(--ink);
cursor: pointer;
text-align: left;
transition: border-color .15s, box-shadow .15s;
}
.pax-trigger:hover { border-color: var(--line-2); }
.pax-trigger[aria-expanded="true"] { border-color: var(--sky); box-shadow: 0 0 0 3px var(--sky-50); background: #fff; }
.pax-ico { color: var(--sky); display: flex; }
.pax-label { flex: 1; }
.chev { color: var(--muted); transition: transform .2s; }
.pax-trigger[aria-expanded="true"] .chev { transform: rotate(180deg); }
.pax-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(360px, 92vw);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-lg);
padding: 8px;
z-index: 20;
animation: pop .14s ease;
}
@keyframes pop { from { opacity: 0; transform: translateY(-4px) scale(.98); } }
.stepper-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 8px;
border-radius: var(--r-sm);
}
.stepper-row:hover { background: var(--cloud); }
.stepper-row strong { display: block; font-size: 14px; }
.stepper-row small { color: var(--muted); font-size: 12px; }
.stepper { display: flex; align-items: center; gap: 4px; }
.step {
width: 34px; height: 34px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: #fff;
color: var(--sky-d);
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: grid; place-items: center;
transition: border-color .15s, background .15s, opacity .15s;
}
.step:hover:not(:disabled) { border-color: var(--sky); background: var(--sky-50); }
.step:disabled { opacity: .35; cursor: not-allowed; }
.step-val { width: 30px; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.cabin-row { padding: 12px 8px 6px; border-top: 1px solid var(--line); margin-top: 6px; }
.cabin-label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.cabin-seg { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
.cabin {
font: inherit;
font-weight: 600;
font-size: 13px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: #fff;
color: var(--ink-2);
cursor: pointer;
transition: border-color .15s, background .15s, color .15s;
}
.cabin:hover { border-color: var(--sky); }
.cabin.active { border-color: var(--sky); background: var(--sky-50); color: var(--sky-d); }
.pax-done {
width: 100%;
margin-top: 8px;
padding: 11px;
border: 0;
border-radius: var(--r-sm);
background: var(--ink);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.pax-done:hover { background: #0c1a2e; }
.search-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
height: 52px;
padding: 0 28px;
border: 0;
border-radius: var(--r-md);
background: linear-gradient(150deg, var(--sunrise), #ff9248);
color: #fff;
font: inherit;
font-weight: 700;
font-size: 15px;
cursor: pointer;
box-shadow: 0 8px 20px rgba(255, 122, 51, 0.32);
transition: transform .12s, box-shadow .15s, filter .15s;
white-space: nowrap;
}
.search-btn:hover { filter: brightness(1.03); box-shadow: 0 10px 26px rgba(255, 122, 51, 0.4); }
.search-btn:active { transform: translateY(1px); }
.form-error {
margin: 12px 12px 0;
color: var(--danger);
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.form-error::before { content: "⚠"; }
/* ---------- Columns ---------- */
.columns {
display: grid;
grid-template-columns: minmax(260px, 0.8fr) 1.2fr;
gap: 18px;
margin-top: 26px;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
padding: 18px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 14px;
}
.panel-head h2 { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -0.01em; }
.head-note { color: var(--muted); font-size: 13px; }
.link-btn {
background: none;
border: 0;
color: var(--sky);
font: inherit;
font-weight: 600;
font-size: 13px;
cursor: pointer;
padding: 0;
}
.link-btn:hover { color: var(--sky-d); text-decoration: underline; }
/* recent */
.recent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.recent-item {
display: grid;
gap: 3px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
cursor: pointer;
transition: border-color .15s, background .15s, transform .12s;
}
.recent-item:hover { border-color: var(--sky); background: var(--sky-50); transform: translateY(-1px); }
.ri-route { display: flex; align-items: center; gap: 8px; font-variant-numeric: tabular-nums; font-weight: 800; font-size: 15px; }
.ri-route .arrow, .rc-codes .arrow { color: var(--sunrise); font-weight: 700; }
.ri-meta { color: var(--muted); font-size: 13px; }
.ri-date { color: var(--ink-2); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.recent-empty { color: var(--muted); font-size: 14px; padding: 6px 2px; }
/* popular */
.route-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.route-card {
width: 100%;
text-align: left;
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: #fff;
font: inherit;
cursor: pointer;
transition: border-color .15s, box-shadow .15s, transform .12s;
}
.route-card:hover { border-color: var(--sky); box-shadow: var(--shadow-md); transform: translateY(-2px); }
.rc-codes { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 16px; display: flex; gap: 7px; align-items: center; color: var(--ink); }
.rc-cities { color: var(--muted); font-size: 13px; }
.rc-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 2px; }
.rc-pill {
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
letter-spacing: .02em;
}
.rc-pill.ontime { background: rgba(31, 157, 98, 0.12); color: var(--ok); }
.rc-pill.warn { background: rgba(224, 150, 42, 0.14); color: var(--warn); }
.rc-price { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 15px; color: var(--sky-d); }
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 60;
width: min(440px, calc(100vw - 28px));
}
.toast {
display: flex;
align-items: flex-start;
gap: 12px;
background: var(--ink);
color: #fff;
border-radius: var(--r-md);
padding: 13px 16px;
box-shadow: var(--shadow-lg);
animation: toastIn .26s cubic-bezier(.16, 1, .3, 1);
}
.toast.out { animation: toastOut .25s forwards ease; }
@keyframes toastIn { from { opacity: 0; transform: translateY(14px) scale(.97); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(10px); } }
.toast .t-ico {
width: 30px; height: 30px;
border-radius: 8px;
display: grid; place-items: center;
flex-shrink: 0;
background: linear-gradient(150deg, var(--sky), var(--sky-d));
}
.toast.success .t-ico { background: linear-gradient(150deg, var(--boarding), #167a4c); }
.toast .t-body { font-size: 14px; line-height: 1.35; }
.toast .t-body b { font-weight: 700; }
.toast .t-body span { display: block; color: rgba(255, 255, 255, 0.72); font-size: 12.5px; margin-top: 2px; }
/* ---------- Responsive ---------- */
@media (max-width: 820px) {
.columns { grid-template-columns: 1fr; }
.leg, .leg.no-return { grid-template-columns: 1fr 1fr; grid-auto-flow: row; }
.leg .swap { grid-column: 1 / -1; justify-self: center; margin: 2px 0; }
.field-from, .field-to { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
.wrap { padding: 22px 14px 56px; }
.hero-title { font-size: 27px; }
.topnav { display: none; }
.account-name { display: none; }
.trip-tab { padding: 9px 11px; font-size: 13px; }
.leg, .leg.no-return { grid-template-columns: 1fr; }
.field-depart, .field-return { grid-column: 1 / -1; }
.controls { flex-direction: column; }
.pax-wrap, .search-btn { width: 100%; }
.route-grid { grid-template-columns: 1fr; }
.cabin-seg { grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
var AIRPORTS = {
"new york": "JFK", "newyork": "JFK", "nyc": "JFK",
"london": "LHR", "paris": "CDG", "tokyo": "HND", "madrid": "MAD",
"los angeles": "LAX", "san francisco": "SFO", "miami": "MIA",
"dubai": "DXB", "singapore": "SIN", "sydney": "SYD",
"são paulo": "GRU", "sao paulo": "GRU", "berlin": "BER",
"rome": "FCO", "amsterdam": "AMS", "barcelona": "BCN",
"toronto": "YYZ", "chicago": "ORD", "hong kong": "HKG",
"delhi": "DEL", "mumbai": "BOM", "doha": "DOH", "istanbul": "IST"
};
var $ = function (s, c) { return (c || document).querySelector(s); };
var $$ = function (s, c) { return Array.prototype.slice.call((c || document).querySelectorAll(s)); };
/* ---------- Toast ---------- */
var toastWrap = $("#toastWrap");
function toast(title, sub, type) {
var el = document.createElement("div");
el.className = "toast" + (type ? " " + type : "");
var ok = type === "success";
var icon = ok
? '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>'
: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#fff" 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-1L15 22v-1.5L13 19v-5.5z"/></svg>';
el.innerHTML = '<span class="t-ico">' + icon + '</span><div class="t-body"><b>' +
title + '</b>' + (sub ? '<span>' + sub + '</span>' : '') + '</div>';
toastWrap.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 260);
}, 3400);
}
/* ---------- Dates ---------- */
function iso(d) { return d.toISOString().slice(0, 10); }
function plusDays(n) { var d = new Date(); d.setDate(d.getDate() + n); return d; }
function fmtDate(str) {
if (!str) return "";
var d = new Date(str + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function initDates() {
var today = iso(new Date());
$$(".date").forEach(function (inp) { inp.min = today; });
var firstDepart = $(".depart");
var firstReturn = $(".return");
if (firstDepart) firstDepart.value = iso(plusDays(14));
if (firstReturn) firstReturn.value = iso(plusDays(21));
}
/* ---------- Airport code resolution ---------- */
function syncCode(input) {
var codeEl = input.parentNode.querySelector(".ap-code");
var key = input.value.trim().toLowerCase();
var code = AIRPORTS[key];
if (code) {
input.setAttribute("data-code", code);
if (codeEl) codeEl.textContent = code;
} else if (input.value.trim().length >= 3 && /^[a-z]{3}$/i.test(input.value.trim())) {
var up = input.value.trim().toUpperCase();
input.setAttribute("data-code", up);
if (codeEl) codeEl.textContent = up;
} else {
input.setAttribute("data-code", "");
if (codeEl) codeEl.textContent = "—";
}
}
document.addEventListener("input", function (e) {
if (e.target.classList.contains("ap")) syncCode(e.target);
});
/* ---------- Swap ---------- */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".swap");
if (!btn) return;
var leg = btn.closest(".leg");
var from = $(".field-from .ap", leg);
var to = $(".field-to .ap", leg);
var fv = from.value, fc = from.getAttribute("data-code");
from.value = to.value; from.setAttribute("data-code", to.getAttribute("data-code"));
to.value = fv; to.setAttribute("data-code", fc);
syncCode(from); syncCode(to);
btn.classList.toggle("spin");
toast("Airports swapped", from.getAttribute("data-code") + " → " + to.getAttribute("data-code"));
});
/* ---------- Trip tabs ---------- */
var tripType = "round";
var legs = $("#legs");
var addLegBtn = $("#addLeg");
function applyTripType(type) {
tripType = type;
$$(".trip-tab").forEach(function (t) {
var on = t.getAttribute("data-trip") === type;
t.classList.toggle("active", on);
t.setAttribute("aria-selected", on ? "true" : "false");
});
var multi = type === "multi";
addLegBtn.hidden = !multi;
// Remove extra legs when leaving multi-city
if (!multi) {
$$(".leg", legs).slice(1).forEach(function (l) { l.remove(); });
}
$$(".leg", legs).forEach(function (leg, i) {
var ret = $(".field-return", leg);
var removeBtn = $(".leg-remove", leg);
if (type === "round") {
ret.style.display = "";
leg.classList.remove("no-return");
} else {
ret.style.display = "none";
leg.classList.add("no-return");
}
removeBtn.hidden = !(multi && i > 0);
});
if (multi && $$(".leg", legs).length === 1) addLeg();
}
$$(".trip-tab").forEach(function (t) {
t.addEventListener("click", function () { applyTripType(t.getAttribute("data-trip")); });
});
var legCounter = 1;
function addLeg() {
if ($$(".leg", legs).length >= 5) {
toast("Maximum reached", "Up to 5 flights per multi-city search.");
return;
}
var i = legCounter++;
var prev = $$(".leg", legs).pop();
var prevTo = prev ? $(".field-to .ap", prev) : null;
var startCity = prevTo ? prevTo.value : "";
var startCode = prevTo ? prevTo.getAttribute("data-code") : "";
var div = document.createElement("div");
div.className = "leg no-return";
div.setAttribute("data-leg", i);
div.innerHTML =
'<div class="field field-from"><label for="from-' + i + '">From</label>' +
'<div class="airport-input"><span class="ai-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 16l7-2 4-9 2 1-1 7 5-2 1 2-7 4-1 4-2-1 1-4z"/></svg></span>' +
'<input id="from-' + i + '" class="ap" type="text" autocomplete="off" placeholder="City or airport" value="' + startCity + '" data-code="' + startCode + '" />' +
'<span class="ap-code">' + (startCode || "—") + '</span></div></div>' +
'<button class="swap" type="button" aria-label="Swap origin and destination"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4l-4 4 4 4"/><path d="M3 8h13"/><path d="M17 20l4-4-4-4"/><path d="M21 16H8"/></svg></button>' +
'<div class="field field-to"><label for="to-' + i + '">To</label>' +
'<div class="airport-input"><span class="ai-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16l-7-2-4-9-2 1 1 7-5-2-1 2 7 4 1 4 2-1-1-4z"/></svg></span>' +
'<input id="to-' + i + '" class="ap" type="text" autocomplete="off" placeholder="City or airport" data-code="" />' +
'<span class="ap-code">—</span></div></div>' +
'<div class="field field-depart"><label for="depart-' + i + '">Depart</label>' +
'<input id="depart-' + i + '" class="date depart" type="date" /></div>' +
'<div class="field field-return" style="display:none"><label>Return</label><input class="date return" type="date" disabled /></div>' +
'<button class="leg-remove" type="button" aria-label="Remove flight"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>';
legs.appendChild(div);
var newDepart = $(".depart", div);
newDepart.min = iso(new Date());
newDepart.value = iso(plusDays(14 + i * 7));
}
addLegBtn.addEventListener("click", addLeg);
document.addEventListener("click", function (e) {
var rm = e.target.closest(".leg-remove");
if (!rm) return;
var leg = rm.closest(".leg");
if ($$(".leg", legs).length <= 1) return;
leg.remove();
});
/* ---------- Passengers + cabin ---------- */
var paxTrigger = $("#paxTrigger");
var paxPanel = $("#paxPanel");
var paxLabel = $("#paxLabel");
var counts = { adults: 1, children: 0, infants: 0 };
var limits = { adults: [1, 9], children: [0, 8], infants: [0, 4] };
var cabin = "Economy";
function renderPax() {
$$(".stepper-row").forEach(function (row) {
var type = row.getAttribute("data-type");
var val = counts[type];
$(".step-val", row).textContent = val;
$(".minus", row).disabled = val <= limits[type][0];
$(".plus", row).disabled = val >= limits[type][1] ||
(type === "infants" && val >= counts.adults);
});
var total = counts.adults + counts.children + counts.infants;
paxLabel.textContent = total + " passenger" + (total !== 1 ? "s" : "") + " · " + cabin;
}
$$(".stepper").forEach(function (st) {
var row = st.closest(".stepper-row");
var type = row.getAttribute("data-type");
$(".plus", st).addEventListener("click", function () {
if (counts[type] < limits[type][1] && !(type === "infants" && counts[type] >= counts.adults)) {
counts[type]++; renderPax();
}
});
$(".minus", st).addEventListener("click", function () {
if (counts[type] > limits[type][0]) {
counts[type]--;
if (counts.infants > counts.adults) counts.infants = counts.adults;
renderPax();
}
});
});
$$(".cabin").forEach(function (c) {
c.addEventListener("click", function () {
cabin = c.getAttribute("data-cabin");
$$(".cabin").forEach(function (x) {
var on = x === c;
x.classList.toggle("active", on);
x.setAttribute("aria-checked", on ? "true" : "false");
});
renderPax();
});
});
function openPax(open) {
paxPanel.hidden = !open;
paxTrigger.setAttribute("aria-expanded", open ? "true" : "false");
}
paxTrigger.addEventListener("click", function () { openPax(paxPanel.hidden); });
$("#paxDone").addEventListener("click", function () { openPax(false); paxTrigger.focus(); });
document.addEventListener("click", function (e) {
if (!paxPanel.hidden && !e.target.closest(".pax-wrap")) openPax(false);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !paxPanel.hidden) { openPax(false); paxTrigger.focus(); }
});
/* ---------- Validation + search ---------- */
var form = $("#searchForm");
var formError = $("#formError");
function showError(msg) {
formError.textContent = msg;
formError.hidden = false;
}
form.addEventListener("submit", function (e) {
e.preventDefault();
formError.hidden = true;
var legEls = $$(".leg", legs);
for (var i = 0; i < legEls.length; i++) {
var leg = legEls[i];
var from = $(".field-from .ap", leg);
var to = $(".field-to .ap", leg);
var depart = $(".depart", leg);
if (!from.value.trim() || !from.getAttribute("data-code")) {
showError("Please enter a valid origin airport."); from.focus(); return;
}
if (!to.value.trim() || !to.getAttribute("data-code")) {
showError("Please enter a valid destination airport."); to.focus(); return;
}
if (from.getAttribute("data-code") === to.getAttribute("data-code")) {
showError("Origin and destination must be different."); to.focus(); return;
}
if (!depart.value) {
showError("Please choose a departure date."); depart.focus(); return;
}
if (tripType === "round") {
var ret = $(".return", leg);
if (!ret.value) { showError("Please choose a return date."); ret.focus(); return; }
if (ret.value < depart.value) {
showError("Return date can't be before departure."); ret.focus(); return;
}
}
}
var first = legEls[0];
var fc = $(".field-from .ap", first).getAttribute("data-code");
var tc = $(".field-to .ap", first).getAttribute("data-code");
var total = counts.adults + counts.children + counts.infants;
var depV = $(".depart", first).value;
var dateLabel = tripType === "round"
? fmtDate(depV) + " – " + fmtDate($(".return", first).value)
: tripType === "multi"
? legEls.length + " flights"
: fmtDate(depV) + " · One way";
toast(
"Searching " + fc + " → " + tc,
total + " passenger" + (total !== 1 ? "s" : "") + " · " + cabin + " · " + dateLabel,
"success"
);
addRecent({
from: fc,
fc: $(".field-from .ap", first).value,
to: tc,
tc: $(".field-to .ap", first).value,
pax: total,
date: dateLabel
});
});
/* ---------- Recent searches ---------- */
var recentList = $("#recentList");
function addRecent(s) {
// de-dup
$$(".recent-item", recentList).forEach(function (li) {
if (li.dataset.from === s.from && li.dataset.to === s.to) li.remove();
});
var li = document.createElement("li");
li.className = "recent-item";
li.dataset.from = s.from; li.dataset.fc = s.fc;
li.dataset.to = s.to; li.dataset.tc = s.tc;
li.innerHTML =
'<span class="ri-route"><b>' + s.from + '</b><span class="arrow" aria-hidden="true">→</span><b>' + s.to + '</b></span>' +
'<span class="ri-meta">' + s.fc + ' · ' + s.tc + ' · ' + s.pax + ' pax</span>' +
'<span class="ri-date">' + s.date + '</span>';
var empty = $(".recent-empty", recentList);
if (empty) empty.remove();
recentList.insertBefore(li, recentList.firstChild);
while ($$(".recent-item", recentList).length > 5) recentList.lastChild.remove();
}
$("#clearRecent").addEventListener("click", function () {
recentList.innerHTML = '<li class="recent-empty">No recent searches yet — search a route to see it here.</li>';
toast("Recent searches cleared", "");
});
/* ---------- Fill from recent / popular ---------- */
function fillRoute(from, fc, to, tc) {
var first = $$(".leg", legs)[0];
var fromI = $(".field-from .ap", first);
var toI = $(".field-to .ap", first);
fromI.value = fc; fromI.setAttribute("data-code", from);
toI.value = tc; toI.setAttribute("data-code", to);
syncCode(fromI); syncCode(toI);
// ensure displayed code matches dataset even without dictionary hit
$(".field-from .ap-code", first).textContent = from;
$(".field-to .ap-code", first).textContent = to;
fromI.setAttribute("data-code", from);
toI.setAttribute("data-code", to);
window.scrollTo({ top: 0, behavior: "smooth" });
}
recentList.addEventListener("click", function (e) {
var item = e.target.closest(".recent-item");
if (!item) return;
fillRoute(item.dataset.from, item.dataset.fc, item.dataset.to, item.dataset.tc);
toast("Search filled", item.dataset.from + " → " + item.dataset.to + " — review and search.");
});
$("#routeGrid").addEventListener("click", function (e) {
var card = e.target.closest(".route-card");
if (!card) return;
fillRoute(card.dataset.from, card.dataset.fc, card.dataset.to, card.dataset.tc);
toast("Popular route selected", card.dataset.fc + " → " + card.dataset.tc);
});
/* ---------- Init ---------- */
initDates();
renderPax();
$$(".ap").forEach(syncCode);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skyloft Air — Flight Search</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" aria-label="Skyloft Air">
<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-1L15 22v-1.5L13 19v-5.5z"/></svg>
</span>
<span class="brand-name">Skyloft<span>Air</span></span>
</div>
<nav class="topnav" aria-label="Main">
<a href="#" class="active">Book</a>
<a href="#">My trips</a>
<a href="#">Check-in</a>
<a href="#">Status</a>
</nav>
<button class="account" type="button" aria-label="Account menu">
<span class="avatar" aria-hidden="true">AR</span>
<span class="account-name">A. Reyes</span>
</button>
</header>
<main class="wrap">
<section class="hero">
<p class="hero-kicker">Where to next?</p>
<h1 class="hero-title">Find your flight</h1>
<p class="hero-sub">Search fares across 240+ destinations. Fictional schedules, real-feeling experience.</p>
</section>
<section class="search" aria-label="Flight search">
<div class="trip-tabs" role="tablist" aria-label="Trip type">
<button class="trip-tab active" role="tab" aria-selected="true" data-trip="round">Round trip</button>
<button class="trip-tab" role="tab" aria-selected="false" data-trip="oneway">One way</button>
<button class="trip-tab" role="tab" aria-selected="false" data-trip="multi">Multi-city</button>
</div>
<form id="searchForm" novalidate>
<div class="legs" id="legs">
<div class="leg" data-leg="0">
<div class="field field-from">
<label for="from-0">From</label>
<div class="airport-input">
<span class="ai-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 16l7-2 4-9 2 1-1 7 5-2 1 2-7 4-1 4-2-1 1-4z"/></svg>
</span>
<input id="from-0" class="ap" type="text" autocomplete="off" placeholder="City or airport" value="New York" data-code="JFK" />
<span class="ap-code">JFK</span>
</div>
</div>
<button class="swap" type="button" aria-label="Swap origin and destination" title="Swap">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4l-4 4 4 4"/><path d="M3 8h13"/><path d="M17 20l4-4-4-4"/><path d="M21 16H8"/></svg>
</button>
<div class="field field-to">
<label for="to-0">To</label>
<div class="airport-input">
<span class="ai-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16l-7-2-4-9-2 1 1 7-5-2-1 2 7 4 1 4 2-1-1-4z"/></svg>
</span>
<input id="to-0" class="ap" type="text" autocomplete="off" placeholder="City or airport" value="London" data-code="LHR" />
<span class="ap-code">LHR</span>
</div>
</div>
<div class="field field-depart">
<label for="depart-0">Depart</label>
<input id="depart-0" class="date depart" type="date" />
</div>
<div class="field field-return">
<label for="return-0">Return</label>
<input id="return-0" class="date return" type="date" />
</div>
<button class="leg-remove" type="button" aria-label="Remove flight" hidden>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
</div>
<button class="add-leg" type="button" id="addLeg" hidden>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Add another flight
</button>
<div class="controls">
<div class="pax-wrap">
<button class="pax-trigger" type="button" id="paxTrigger" aria-haspopup="true" aria-expanded="false">
<span class="pax-ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="3"/><path d="M3 20a6 6 0 0 1 12 0"/><path d="M16 7h5M18.5 4.5v5"/></svg>
</span>
<span class="pax-label" id="paxLabel">1 passenger · Economy</span>
<span class="chev" aria-hidden="true">▾</span>
</button>
<div class="pax-panel" id="paxPanel" hidden role="dialog" aria-label="Passengers and cabin">
<div class="stepper-row" data-type="adults">
<div><strong>Adults</strong><small>12+ years</small></div>
<div class="stepper">
<button type="button" class="step minus" aria-label="Fewer adults">−</button>
<output class="step-val">1</output>
<button type="button" class="step plus" aria-label="More adults">+</button>
</div>
</div>
<div class="stepper-row" data-type="children">
<div><strong>Children</strong><small>2–11 years</small></div>
<div class="stepper">
<button type="button" class="step minus" aria-label="Fewer children">−</button>
<output class="step-val">0</output>
<button type="button" class="step plus" aria-label="More children">+</button>
</div>
</div>
<div class="stepper-row" data-type="infants">
<div><strong>Infants</strong><small>Under 2, on lap</small></div>
<div class="stepper">
<button type="button" class="step minus" aria-label="Fewer infants">−</button>
<output class="step-val">0</output>
<button type="button" class="step plus" aria-label="More infants">+</button>
</div>
</div>
<div class="cabin-row">
<span class="cabin-label">Cabin</span>
<div class="cabin-seg" role="radiogroup" aria-label="Cabin class">
<button type="button" class="cabin active" data-cabin="Economy" role="radio" aria-checked="true">Economy</button>
<button type="button" class="cabin" data-cabin="Premium" role="radio" aria-checked="false">Premium</button>
<button type="button" class="cabin" data-cabin="Business" role="radio" aria-checked="false">Business</button>
<button type="button" class="cabin" data-cabin="First" role="radio" aria-checked="false">First</button>
</div>
</div>
<button type="button" class="pax-done" id="paxDone">Done</button>
</div>
</div>
<button class="search-btn" type="submit">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>
Search flights
</button>
</div>
<p class="form-error" id="formError" role="alert" hidden></p>
</form>
</section>
<div class="columns">
<section class="panel recent" aria-label="Recent searches">
<div class="panel-head">
<h2>Recent searches</h2>
<button class="link-btn" type="button" id="clearRecent">Clear</button>
</div>
<ul class="recent-list" id="recentList">
<li class="recent-item" data-from="SFO" data-fc="San Francisco" data-to="NRT" data-tc="Tokyo">
<span class="ri-route"><b>SFO</b><span class="arrow" aria-hidden="true">→</span><b>NRT</b></span>
<span class="ri-meta">San Francisco · Tokyo · 2 pax</span>
<span class="ri-date">Jul 12 – Jul 26</span>
</li>
<li class="recent-item" data-from="MAD" data-fc="Madrid" data-to="JFK" data-tc="New York">
<span class="ri-route"><b>MAD</b><span class="arrow" aria-hidden="true">→</span><b>JFK</b></span>
<span class="ri-meta">Madrid · New York · 1 pax</span>
<span class="ri-date">Aug 03 – Aug 11</span>
</li>
<li class="recent-item" data-from="DXB" data-fc="Dubai" data-to="SIN" data-tc="Singapore">
<span class="ri-route"><b>DXB</b><span class="arrow" aria-hidden="true">→</span><b>SIN</b></span>
<span class="ri-meta">Dubai · Singapore · 3 pax</span>
<span class="ri-date">Sep 20 · One way</span>
</li>
</ul>
</section>
<section class="panel popular" aria-label="Popular routes">
<div class="panel-head">
<h2>Popular routes</h2>
<span class="head-note">from</span>
</div>
<ul class="route-grid" id="routeGrid">
<li>
<button class="route-card" type="button" data-from="JFK" data-fc="New York" data-to="LHR" data-tc="London">
<span class="rc-codes"><b>JFK</b><span class="arrow" aria-hidden="true">→</span><b>LHR</b></span>
<span class="rc-cities">New York → London</span>
<span class="rc-foot"><span class="rc-pill ontime">Daily</span><span class="rc-price">$489</span></span>
</button>
</li>
<li>
<button class="route-card" type="button" data-from="LAX" data-fc="Los Angeles" data-to="CDG" data-tc="Paris">
<span class="rc-codes"><b>LAX</b><span class="arrow" aria-hidden="true">→</span><b>CDG</b></span>
<span class="rc-cities">Los Angeles → Paris</span>
<span class="rc-foot"><span class="rc-pill ontime">Daily</span><span class="rc-price">$612</span></span>
</button>
</li>
<li>
<button class="route-card" type="button" data-from="SFO" data-fc="San Francisco" data-to="HND" data-tc="Tokyo">
<span class="rc-codes"><b>SFO</b><span class="arrow" aria-hidden="true">→</span><b>HND</b></span>
<span class="rc-cities">San Francisco → Tokyo</span>
<span class="rc-foot"><span class="rc-pill warn">3×/wk</span><span class="rc-price">$744</span></span>
</button>
</li>
<li>
<button class="route-card" type="button" data-from="MIA" data-fc="Miami" data-to="GRU" data-tc="São Paulo">
<span class="rc-codes"><b>MIA</b><span class="arrow" aria-hidden="true">→</span><b>GRU</b></span>
<span class="rc-cities">Miami → São Paulo</span>
<span class="rc-foot"><span class="rc-pill ontime">Daily</span><span class="rc-price">$398</span></span>
</button>
</li>
<li>
<button class="route-card" type="button" data-from="SIN" data-fc="Singapore" data-to="SYD" data-tc="Sydney">
<span class="rc-codes"><b>SIN</b><span class="arrow" aria-hidden="true">→</span><b>SYD</b></span>
<span class="rc-cities">Singapore → Sydney</span>
<span class="rc-foot"><span class="rc-pill ontime">Daily</span><span class="rc-price">$305</span></span>
</button>
</li>
<li>
<button class="route-card" type="button" data-from="DXB" data-fc="Dubai" data-to="LHR" data-tc="London">
<span class="rc-codes"><b>DXB</b><span class="arrow" aria-hidden="true">→</span><b>LHR</b></span>
<span class="rc-cities">Dubai → London</span>
<span class="rc-foot"><span class="rc-pill ontime">Daily</span><span class="rc-price">$527</span></span>
</button>
</li>
</ul>
</section>
</div>
</main>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Flight Search
A clean, aviation-styled flight search panel for the fictional Skyloft Air. The hero gives way to a raised search card with trip-type tabs (round trip, one way, multi-city), origin and destination inputs that resolve city names to IATA codes (New York → JFK, London → LHR), a circular swap control, native date pickers, and a passengers-and-cabin popover. Times, flight numbers, codes and fares use tabular figures, and the palette leans on aviation blue with a sunrise-orange accent and clear status pills.
Everything is interactive and vanilla. The swap button trades origin and destination with a satisfying rotation, the trip-type tabs reshape the form — hiding the return date for one-way, and growing into stacked legs with add and remove controls for multi-city. The passenger stepper enforces sensible limits (infants can’t exceed adults), the cabin segment updates the summary label, and the search button validates each leg inline before confirming with a toast. Recent searches record every successful query and can be replayed with a tap, while the popular routes grid pre-fills the form from real-feeling fares.
The layout is mobile-first and responsive down to ~360px: columns stack, the leg grid collapses, and the passenger popover stays within the viewport. Keyboard users get focus rings, Escape closes the popover, and ARIA roles describe the tabs, radio group and live toast region.
Illustrative UI only — fictional airline, not a real booking or flight system.