← Diagrams & maps

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

  1. Paste the snippet just before </head>.
  2. Add data-seat-map-picker to an empty element. It fills the width.
  3. Set the number of rows with data-rows and the seats in each row with data-layout, leaving a space for each aisle.

Options

Add any of these attributes to the same element.

data-rowsHow many rows of seats. Default 14.
data-layoutSeat letters across a row, with a space for each aisle. Default ABC DEF; try AB CDEF GH for a wide-body.
data-takenSeats already taken, such as 1A,1B,4D. Default: a random spread.
data-fullWithout data-taken: the share of seats taken, 0–1. Default 0.45.
data-premiumRows with extra legroom, such as 1-3 or 1,12.
data-maxMost seats someone can pick; picking one more lets the first go. Default 4.
data-colorColour of chosen seats. Default #7c3aed.
data-autoplayPick 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".

1 · Snippet
<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>
2 · Your HTML
<div data-seat-map-picker data-rows="12" data-premium="1-2"
     data-taken="1A,1B,2F,4C,4D,7A,9E"></div>