Seat Map Picker
An aircraft seat map you can pick from. Taken seats are hatched out, extra-legroom rows are tinted, and each seat you choose lights up with a pop, a tick and a ripple, while the line underneath lists your seats. Left alone, a pretend finger books a couple of seats side by side, then lets them go.
How to use it
- Paste the snippet just before
</head>. - Add
data-seat-map-pickerto an empty element. It fills the width. - Set the number of rows with
data-rowsand the seats in each row withdata-layout, leaving a space for each aisle.
Options
Add any of these attributes to the same element.
data-rows | How many rows of seats. Default 14. |
data-layout | Seat letters across a row, with a space for each aisle. Default ABC DEF; try AB CDEF GH for a wide-body. |
data-taken | Seats already taken, such as 1A,1B,4D. Default: a random spread. |
data-full | Without data-taken: the share of seats taken, 0–1. Default 0.45. |
data-premium | Rows with extra legroom, such as 1-3 or 1,12. |
data-max | Most seats someone can pick; picking one more lets the first go. Default 4. |
data-color | Colour of chosen seats. Default #7c3aed. |
data-autoplay | Pick seats by itself while in view, pausing whenever someone taps. |
Seats are real buttons with aria-pressed, so they work with a keyboard and screen readers, and the summary line is announced as it changes. The nose is on the left. Read the chosen seats from the buttons with aria-pressed="true".
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Seat Map Picker: an aircraft seat map where seats light up as they are
// picked. Taken seats are greyed out, a summary lists your choice, and in
// autoplay a pretend finger picks seats by itself.
// Usage: <div data-seat-map-picker></div>
// Optional: data-rows (rows of seats, default 14), data-layout (seat
// letters per row with a space for each aisle, default "ABC DEF"),
// data-taken (seats already taken, e.g. "1A,1B,4D"; default: a random
// spread), data-full (share of seats taken when data-taken isn't set,
// 0-1, default 0.45), data-premium (rows with extra legroom, e.g. "1-3"),
// data-max (most seats you can pick, default 4), data-color (selected
// colour, default #7c3aed), data-autoplay (pick seats by itself while in
// view, pausing when someone taps).
var DEFAULTS = { rows: 14, layout: 'ABC DEF', full: 0.45, max: 4, color: '#7c3aed', idle: 7000 };
var css =
'.stm{position:relative;display:block;width:100%;line-height:1.2;font-size:inherit}' +
'.stm-body{position:relative;background:#fff;border:.18em solid #ddd5ea;border-radius:5.5em 1.4em 1.4em 5.5em/50% 1.4em 1.4em 50%;padding:1em 1.1em 1em 4.6em;box-shadow:0 .4em 1.4em rgba(40,20,80,.1)}' +
'.stm-body::before{content:"";position:absolute;left:1.3em;top:50%;width:1.3em;height:2.6em;margin-top:-1.3em;border-radius:60% 0 0 60%/50% 0 0 50%;background:linear-gradient(90deg,#c9d8f5,#e6eefc);opacity:.9}' +
'.stm-grid{display:grid;gap:.22em;align-items:center}' +
'.stm-seat{all:unset;box-sizing:border-box;position:relative;display:block;aspect-ratio:1;border-radius:.32em .5em .5em .32em;background:#ede9f6;box-shadow:inset -.22em 0 0 #d7cfe8;cursor:pointer;transition:background .25s,box-shadow .25s}' +
'.stm-seat.stm-prem{background:#e0edff;box-shadow:inset -.22em 0 0 #bcd2f5}' +
'.stm-seat:hover:not(.stm-taken):not(.stm-on){background:color-mix(in srgb,var(--stm-c) 25%,#fff)}' +
'.stm-seat:focus-visible{outline:2px solid var(--stm-c);outline-offset:2px}' +
'.stm-seat.stm-taken{background:repeating-linear-gradient(135deg,#d9d5df 0 .2em,#e8e5ec .2em .4em);box-shadow:none;cursor:not-allowed}' +
'.stm-seat.stm-on{background:var(--stm-c);box-shadow:inset -.22em 0 0 color-mix(in srgb,var(--stm-c) 70%,#000),0 0 0 .14em #fff,0 0 .7em color-mix(in srgb,var(--stm-c) 70%,transparent)}' +
'.stm-seat svg{position:absolute;inset:18%;width:64%;height:64%;opacity:0}' +
'.stm-seat.stm-on svg{opacity:1}' +
'.stm-ring{position:absolute;inset:-.1em;border-radius:.45em;border:.15em solid var(--stm-c);opacity:0;pointer-events:none}' +
'.stm-num,.stm-let{font-size:.62em;font-weight:700;color:#9b91ad;text-align:center;font-variant-numeric:tabular-nums}' +
'.stm-sum{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:.4em .9em;margin-top:.9em;font-size:.85em;color:#4b4060;min-height:1.6em}' +
'.stm-sum b{color:var(--stm-c)}' +
'.stm-key{display:inline-flex;align-items:center;gap:.35em;color:#7a7090}' +
'.stm-key i{display:inline-block;width:.9em;height:.9em;border-radius:.2em}' +
'.stm-finger{position:absolute;left:0;top:0;width:1.6em;height:1.6em;margin:-.8em 0 0 -.8em;border-radius:50%;background:rgba(30,15,60,.22);border:.15em solid rgba(255,255,255,.9);box-shadow:0 .2em .5em rgba(30,15,60,.25);pointer-events:none;opacity:0;z-index:2}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function ranges(text) {
var out = {};
(text || '').split(',').forEach(function (part) {
var m = part.trim().match(/^(\d+)(?:\s*-\s*(\d+))?$/);
if (!m) return;
for (var i = +m[1]; i <= +(m[2] || m[1]); i++) out[i] = true;
});
return out;
}
function setup(el) {
var d = el.dataset;
var s = (el._stm = { seats: [], picked: [], idleUntil: 0 });
el.classList.add('stm');
el.style.setProperty('--stm-c', d.color || DEFAULTS.color);
s.live = !el.closest('[aria-hidden="true"]');
s.max = +d.max || DEFAULTS.max;
var rows = Math.max(1, +d.rows || DEFAULTS.rows);
var layout = (d.layout || DEFAULTS.layout).toUpperCase().replace(/\s+/g, ' ').trim();
var prem = ranges(d.premium);
var taken = {};
if (d.taken) d.taken.split(',').forEach(function (t) { taken[t.trim().toUpperCase()] = true; });
var full = d.full !== undefined ? +d.full : DEFAULTS.full;
var body = document.createElement('div');
body.className = 'stm-body';
var grid = document.createElement('div');
grid.className = 'stm-grid';
grid.style.gridTemplateColumns = '1.1em repeat(' + rows + ', minmax(0, 1fr))';
grid.setAttribute('role', 'group');
grid.setAttribute('aria-label', 'Seat map, ' + rows + ' rows');
// One grid row per seat letter, with a slim row of numbers for each aisle.
layout.split('').forEach(function (ch) {
var cell = document.createElement('span');
if (ch === ' ') {
cell.className = 'stm-let';
grid.appendChild(cell);
for (var r = 1; r <= rows; r++) {
var n = document.createElement('span');
n.className = 'stm-num';
n.textContent = r;
n.setAttribute('aria-hidden', 'true');
grid.appendChild(n);
}
return;
}
cell.className = 'stm-let';
cell.textContent = ch;
cell.setAttribute('aria-hidden', 'true');
grid.appendChild(cell);
for (var row = 1; row <= rows; row++) {
var id = row + ch;
var seat = document.createElement(s.live ? 'button' : 'span');
var isTaken = d.taken ? !!taken[id] : Math.random() < full;
seat.className = 'stm-seat' + (prem[row] ? ' stm-prem' : '') + (isTaken ? ' stm-taken' : '');
seat.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="stm-ring"></span>';
if (s.live) {
seat.type = 'button';
seat.setAttribute('aria-label', 'Seat ' + id + (prem[row] ? ', extra legroom' : '') + (isTaken ? ', taken' : ''));
seat.setAttribute('aria-pressed', 'false');
if (isTaken) seat.disabled = true;
}
var info = { el: seat, id: id, row: row, col: ch, taken: isTaken };
seat._stmSeat = info;
s.seats.push(info);
grid.appendChild(seat);
}
});
body.appendChild(grid);
el.appendChild(body);
if (s.live) {
s.sum = document.createElement('div');
s.sum.className = 'stm-sum';
s.sum.setAttribute('aria-live', 'polite');
el.appendChild(s.sum);
grid.addEventListener('click', function (e) {
var b = e.target.closest('.stm-seat');
if (!b || b._stmSeat.taken) return;
s.idleUntil = Date.now() + DEFAULTS.idle;
toggle(el, b._stmSeat);
});
summary(el);
}
s.finger = document.createElement('span');
s.finger.className = 'stm-finger';
s.finger.setAttribute('aria-hidden', 'true');
el.appendChild(s.finger);
}
function summary(el) {
var s = el._stm;
if (!s.sum) return;
if (!s.picked.length) {
s.sum.innerHTML = '<span class="stm-key"><i style="background:#ede9f6"></i>Free</span>' +
(el.querySelector('.stm-prem') ? '<span class="stm-key"><i style="background:#e0edff"></i>Extra legroom</span>' : '') +
'<span class="stm-key"><i style="background:repeating-linear-gradient(135deg,#d9d5df 0 2px,#e8e5ec 2px 4px)"></i>Taken</span>' +
'<span class="stm-key"><i style="background:var(--stm-c)"></i>Yours</span>';
return;
}
var ids = s.picked.slice().sort(function (a, b) { return a.row - b.row || (a.col < b.col ? -1 : 1); }).map(function (p) { return p.id; });
s.sum.innerHTML = '<span>' + ids.length + (ids.length === 1 ? ' seat' : ' seats') + ': <b>' + ids.join(', ') + '</b></span>';
}
function toggle(el, seat, on) {
var s = el._stm, idx = s.picked.indexOf(seat);
if (on === undefined) on = idx < 0;
if (on && idx >= 0) return;
if (!on && idx < 0) return;
if (on) {
if (s.picked.length >= s.max) toggle(el, s.picked[0], false);
s.picked.push(seat);
} else s.picked.splice(idx, 1);
seat.el.classList.toggle('stm-on', on);
if (s.live) seat.el.setAttribute('aria-pressed', on ? 'true' : 'false');
if (!reduced()) {
if (on) {
anime.animate(seat.el, { scale: [0.7, 1.22, 1], duration: 520, ease: 'out(3)' });
anime.animate(seat.el.querySelector('.stm-ring'), { scale: [1, 2.1], opacity: [0.9, 0], duration: 650, ease: 'out(3)' });
anime.animate(seat.el.querySelector('svg'), { scale: [0, 1], duration: 380, delay: 80, ease: 'outBack(3)' });
} else {
anime.animate(seat.el, { scale: [0.85, 1], duration: 300, ease: 'out(2)' });
}
}
summary(el);
}
// Autoplay: a pretend finger picks a few seats side by side, then clears them.
function center(el, seat) {
var a = el.getBoundingClientRect(), b = seat.el.getBoundingClientRect();
return { x: b.left - a.left + b.width / 2, y: b.top - a.top + b.height / 2 };
}
function choose(s) {
var want = 2 + Math.floor(Math.random() * 2), tries = 40;
while (tries--) {
var start = s.seats[Math.floor(Math.random() * s.seats.length)], run = [];
// Seats in the same row next to each other, across an aisle is fine.
var row = s.seats.filter(function (x) { return x.row === start.row; });
var at = row.indexOf(start);
for (var i = at; i < row.length && run.length < want; i++) {
if (row[i].taken) break;
run.push(row[i]);
}
if (run.length >= 2) return run;
}
return s.seats.filter(function (x) { return !x.taken; }).slice(0, 2);
}
function tour(el) {
var s = el._stm;
clearTimeout(s.timer);
if (!s.visible) return;
if (Date.now() < s.idleUntil) { s.timer = setTimeout(function () { tour(el); }, 800); return; }
var picks = choose(s), t = 0, f = s.finger;
var tl = anime.createTimeline();
var first = center(el, picks[0]);
if (f.style.opacity !== '1') {
anime.utils.set(f, { x: first.x + 30, y: first.y + 40 });
tl.add(f, { opacity: [0, 1], duration: 300 }, 0);
t = 200;
}
picks.forEach(function (p, i) {
var c = center(el, p);
tl.add(f, { x: c.x, y: c.y, duration: i ? 360 : 620, ease: 'inOut(3)' }, t);
t += i ? 360 : 620;
tl.add(f, { scale: [1, 0.75, 1], duration: 260, ease: 'out(2)' }, t);
tl.call(function () { if (Date.now() >= s.idleUntil) toggle(el, p, true); }, t + 80);
t += 330;
});
tl.add(f, { opacity: 0, duration: 400, ease: 'in(2)' }, t + 300);
s.timer = setTimeout(function () {
if (Date.now() < s.idleUntil) { tour(el); return; }
s.picked.slice().forEach(function (p, i) { setTimeout(function () { if (Date.now() >= s.idleUntil) toggle(el, p, false); }, i * 90); });
s.timer = setTimeout(function () { tour(el); }, 900);
}, t + 2400);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._stm;
s.visible = e.isIntersecting;
if (!e.isIntersecting) { clearTimeout(s.timer); return; }
if (!s.played) {
s.played = true;
anime.animate(el.querySelectorAll('.stm-seat'), { opacity: [0, 1], scale: [0.5, 1], duration: 420, delay: anime.stagger(12), ease: 'out(3)' });
}
if (el.dataset.autoplay !== undefined) {
clearTimeout(s.timer);
s.timer = setTimeout(function () { tour(el); }, 1100);
}
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-seat-map-picker]').forEach(function (el) {
if (el._stm) return;
setup(el);
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-seat-map-picker data-rows="12" data-premium="1-2"
data-taken="1A,1B,2F,4C,4D,7A,9E"></div>