Ticketing — Seat Selection Map
An interactive event-ticketing seat picker built with vanilla JavaScript and an SVG venue chart. Fans pan and zoom across a tiered arena, filter sections by price using a color legend, and tap open seats to add them to a live cart that totals subtotal, service fee, and grand total. Seat states cover available, selected, taken, and accessible, with a six-seat max guard, keyboard support, toast feedback, and a countdown to showtime.
MCP
Code
:root {
--brand: #7c3aed;
--brand-d: #6d28d9;
--ink: #0e0e16;
--ink-2: #3a3a4d;
--muted: #6c6c80;
--bg: #f5f4f9;
--surface: #ffffff;
--line: rgba(14, 14, 22, 0.1);
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--accent: #ff3d81;
--t-vip: #ff3d81;
--t-lower: #7c3aed;
--t-mid: #2563eb;
--t-upper: #0891b2;
--taken: #c9c9d6;
--accessible: #f59e0b;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-sm: 0 2px 8px rgba(14, 14, 22, 0.08);
--shadow-md: 0 12px 30px rgba(14, 14, 22, 0.14);
--shadow-lg: 0 24px 60px rgba(14, 14, 22, 0.22);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.skip {
position: absolute;
left: -999px;
top: 0;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 0 0 var(--r-sm) 0;
z-index: 100;
}
.skip:focus {
left: 0;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 22px;
flex-wrap: wrap;
padding: 14px 26px;
background: linear-gradient(110deg, #14101f 0%, #1f1233 55%, #2a0e3d 100%);
color: #fff;
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 30;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 800;
letter-spacing: 0.5px;
}
.brand-mark {
font-size: 24px;
color: var(--accent);
transform: rotate(-12deg);
}
.brand-name span {
color: var(--accent);
}
.event-meta {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
min-width: 240px;
}
.event-poster {
width: 56px;
height: 56px;
border-radius: var(--r-md);
flex-shrink: 0;
background:
radial-gradient(circle at 30% 20%, rgba(255, 61, 129, 0.9), transparent 60%),
linear-gradient(135deg, var(--brand), #2563eb 70%, #0891b2);
box-shadow: 0 6px 18px rgba(255, 61, 129, 0.35);
}
.event-info h1 {
font-size: 18px;
margin: 0;
font-weight: 800;
letter-spacing: -0.2px;
}
.event-sub {
margin: 2px 0 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.78);
display: flex;
align-items: center;
gap: 6px;
}
.event-date {
margin: 1px 0 0;
font-size: 12.5px;
color: var(--accent);
font-weight: 600;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.dot.ok {
background: #4ade80;
box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.22);
}
.countdown {
display: flex;
gap: 8px;
}
.cd-unit {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--r-sm);
padding: 7px 10px;
text-align: center;
min-width: 48px;
}
.cd-unit span {
display: block;
font-size: 19px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.cd-unit small {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.6);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 1fr 340px;
gap: 20px;
padding: 20px 26px 40px;
align-items: start;
max-width: 1320px;
margin: 0 auto;
}
/* ---------- Map panel ---------- */
.map-panel {
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--line);
overflow: hidden;
}
.map-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.legend {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.legend-chip {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: var(--bg);
border: 1.5px solid transparent;
border-radius: 999px;
padding: 6px 12px;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s, background 0.15s;
}
.legend-chip:hover {
transform: translateY(-1px);
}
.legend-chip[aria-pressed="true"] {
border-color: var(--ink);
background: #fff;
box-shadow: var(--shadow-sm);
}
.legend.has-filter .legend-chip:not([aria-pressed="true"]) {
opacity: 0.45;
}
.swatch {
width: 14px;
height: 14px;
border-radius: 4px;
display: inline-block;
}
.t-vip { background: var(--t-vip); }
.t-lower { background: var(--t-lower); }
.t-mid { background: var(--t-mid); }
.t-upper { background: var(--t-upper); }
.zoom-controls {
display: flex;
align-items: center;
gap: 6px;
}
.zbtn {
width: 34px;
height: 34px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
font-size: 18px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
}
.zbtn:hover {
background: var(--bg);
}
.zbtn:active {
transform: scale(0.94);
}
.zbtn.reset {
width: auto;
padding: 0 12px;
font-size: 12.5px;
font-weight: 600;
}
.zoom-label {
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
min-width: 42px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.state-legend {
display: flex;
gap: 18px;
flex-wrap: wrap;
padding: 10px 16px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--line);
}
.state-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sl {
width: 13px;
height: 13px;
border-radius: 3px;
display: inline-block;
}
.sl.available { background: #fff; border: 1.5px solid var(--brand); }
.sl.selected { background: var(--brand); box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.3); }
.sl.taken { background: var(--taken); }
.sl.accessible { background: var(--accessible); }
/* ---------- Stage + viewport ---------- */
.stage-wrap {
padding: 16px;
}
.viewport {
position: relative;
width: 100%;
height: 460px;
border-radius: var(--r-md);
overflow: hidden;
background:
radial-gradient(circle at 50% -10%, rgba(124, 58, 237, 0.12), transparent 55%),
#f0eff6;
border: 1px solid var(--line);
cursor: grab;
touch-action: none;
}
.viewport:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
.viewport.panning {
cursor: grabbing;
}
.canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
will-change: transform;
}
.stage {
position: absolute;
left: 60px;
top: 14px;
width: 540px;
height: 40px;
background: linear-gradient(180deg, #1c1430, #0e0e16);
color: #fff;
border-radius: 0 0 60px 60px / 0 0 40px 40px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 6px;
font-size: 13px;
box-shadow: 0 10px 28px rgba(14, 14, 22, 0.35);
}
#seatSvg {
display: block;
}
/* seat styling lives in SVG attrs but cursor/transition here */
.seat {
cursor: pointer;
transition: transform 0.1s ease, filter 0.1s ease;
}
.seat:hover {
filter: brightness(1.12);
}
.seat.is-taken {
cursor: not-allowed;
}
.seat.is-selected {
filter: drop-shadow(0 2px 5px rgba(124, 58, 237, 0.55));
}
.seat.dim {
opacity: 0.22;
pointer-events: none;
}
.row-label {
font-size: 9px;
font-weight: 700;
fill: var(--muted);
font-family: "Inter", sans-serif;
}
.hint {
margin: 10px 2px 0;
font-size: 11.5px;
color: var(--muted);
text-align: center;
}
/* ---------- Cart ---------- */
.cart {
position: sticky;
top: 96px;
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--line);
padding: 18px;
}
.cart-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 12px;
}
.cart-head h2 {
margin: 0;
font-size: 17px;
font-weight: 800;
}
.max-note {
font-size: 12px;
color: var(--muted);
}
.max-note strong {
color: var(--ink);
}
.seat-list {
list-style: none;
margin: 0 0 8px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
}
.seat-list .empty {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 22px 10px;
border: 1.5px dashed var(--line);
border-radius: var(--r-md);
}
.seat-row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border-radius: var(--r-md);
background: var(--bg);
border: 1px solid var(--line);
animation: pop 0.18s ease;
}
@keyframes pop {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.seat-tier-tag {
width: 10px;
height: 30px;
border-radius: 4px;
flex-shrink: 0;
}
.seat-row .meta {
flex: 1;
min-width: 0;
}
.seat-row .meta strong {
display: block;
font-size: 13.5px;
font-weight: 700;
}
.seat-row .meta small {
font-size: 11.5px;
color: var(--muted);
}
.seat-row .price {
font-weight: 700;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
}
.seat-row .rm {
border: none;
background: transparent;
color: var(--muted);
font-size: 18px;
cursor: pointer;
line-height: 1;
padding: 2px 4px;
border-radius: 6px;
}
.seat-row .rm:hover {
background: #ffe1ec;
color: var(--danger);
}
/* ticket stub perforation */
.ticket-stub {
margin: 6px 0 4px;
}
.perf {
height: 1px;
border-top: 2px dashed var(--line);
position: relative;
}
.perf::before,
.perf::after {
content: "";
position: absolute;
top: -9px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--bg);
border: 1px solid var(--line);
}
.perf::before { left: -27px; }
.perf::after { right: -27px; }
.totals {
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
}
.totals .row {
display: flex;
justify-content: space-between;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
}
.totals .row.muted {
color: var(--muted);
}
.totals .row.total {
font-size: 18px;
font-weight: 800;
padding-top: 8px;
border-top: 1px solid var(--line);
margin-top: 2px;
}
.continue {
width: 100%;
border: none;
border-radius: var(--r-md);
background: linear-gradient(120deg, var(--accent), var(--brand));
color: #fff;
font-weight: 700;
font-size: 15px;
padding: 14px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
box-shadow: 0 10px 24px rgba(124, 58, 237, 0.35);
transition: transform 0.12s, box-shadow 0.12s, filter 0.12s;
}
.continue:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 16px 32px rgba(124, 58, 237, 0.42);
}
.continue:active:not(:disabled) {
transform: translateY(0);
}
.continue:disabled {
filter: grayscale(0.65);
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.continue .seat-count {
font-size: 11.5px;
font-weight: 500;
opacity: 0.85;
}
.reassure {
margin: 9px 0 0;
font-size: 11.5px;
color: var(--muted);
text-align: center;
}
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 80;
pointer-events: none;
}
.toast {
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
box-shadow: var(--shadow-lg);
animation: toastIn 0.22s ease, toastOut 0.3s ease 2.4s forwards;
}
.toast.warn { background: var(--warn); }
@keyframes toastIn {
from { transform: translateY(14px); opacity: 0; }
}
@keyframes toastOut {
to { transform: translateY(14px); opacity: 0; }
}
/* ---------- Responsive ---------- */
@media (max-width: 960px) {
.layout {
grid-template-columns: 1fr;
}
.cart {
position: static;
}
.countdown {
order: 3;
width: 100%;
justify-content: flex-start;
}
}
@media (max-width: 520px) {
.topbar {
padding: 12px 14px;
gap: 12px;
}
.layout {
padding: 14px 12px 30px;
}
.event-info h1 {
font-size: 15.5px;
}
.cd-unit {
min-width: 42px;
padding: 6px 7px;
}
.cd-unit span { font-size: 16px; }
.viewport {
height: 380px;
}
.map-toolbar {
gap: 10px;
}
.legend-chip {
font-size: 11.5px;
padding: 5px 9px;
}
.state-legend {
gap: 12px;
font-size: 11px;
}
}(function () {
"use strict";
// ---------- Config ----------
var MAX_SEATS = 6;
var FEE_RATE = 0.14; // 14% service fee
var SVGNS = "http://www.w3.org/2000/svg";
var TIERS = {
vip: { label: "VIP Pit", price: 240, color: "#ff3d81" },
lower: { label: "Lower Bowl", price: 160, color: "#7c3aed" },
mid: { label: "Mezzanine", price: 95, color: "#2563eb" },
upper: { label: "Upper Deck", price: 55, color: "#0891b2" }
};
// Section blocks: each is a band of rows. taken seats are pseudo-random but stable.
var SECTIONS = [
{ id: "VIP", tier: "vip", rows: 3, cols: 16, x: 70, y: 80, gap: 22 },
{ id: "A", tier: "lower", rows: 5, cols: 18, x: 50, y: 175, gap: 22 },
{ id: "B", tier: "mid", rows: 5, cols: 22, x: 28, y: 305, gap: 21 },
{ id: "C", tier: "upper", rows: 4, cols: 26, x: 10, y: 430, gap: 20 }
];
var SEAT_R = 7;
// ---------- State ----------
var selected = {}; // id -> seat data
var activeFilter = null;
var scale = 1;
var panX = 0;
var panY = 0;
var MIN_SCALE = 0.55;
var MAX_SCALE = 2.4;
// ---------- DOM ----------
var svg = document.getElementById("seatSvg");
var canvas = document.getElementById("canvas");
var viewport = document.getElementById("viewport");
var seatList = document.getElementById("seatList");
var emptyState = document.getElementById("emptyState");
var zoomLabel = document.getElementById("zoomLabel");
var continueBtn = document.getElementById("continueBtn");
// deterministic pseudo-random for "taken" seats
function seeded(n) {
var x = Math.sin(n * 12.9898) * 43758.5453;
return x - Math.floor(x);
}
// ---------- Build seat map ----------
var seatIndex = {}; // id -> element + data
var maxX = 0;
var maxY = 0;
var counter = 0;
function buildSeats() {
SECTIONS.forEach(function (sec) {
var rowLetters = "ABCDEFGHIJ";
for (var r = 0; r < sec.rows; r++) {
var rowY = sec.y + r * sec.gap;
// section is centered: indent so blocks look like an arc-ish bowl
var indent = (26 - sec.cols) * (sec.gap / 2);
var startX = sec.x + indent / 1.4;
// row label
var label = document.createElementNS(SVGNS, "text");
label.setAttribute("x", startX - 16);
label.setAttribute("y", rowY + 4);
label.setAttribute("class", "row-label");
label.textContent = sec.id + rowLetters[r];
svg.appendChild(label);
for (var c = 0; c < sec.cols; c++) {
counter++;
var cx = startX + c * sec.gap;
var cy = rowY;
if (cx + SEAT_R > maxX) maxX = cx + SEAT_R;
if (cy + SEAT_R > maxY) maxY = cy + SEAT_R;
var rnd = seeded(counter);
var isTaken = rnd > 0.74;
// a couple accessible seats at row ends of lower/vip
var isAccessible =
(sec.tier === "vip" || sec.tier === "lower") &&
(c === 0 || c === sec.cols - 1) &&
r === sec.rows - 1;
var id = sec.id + rowLetters[r] + "-" + (c + 1);
var tier = TIERS[sec.tier];
var circle = document.createElementNS(SVGNS, "circle");
circle.setAttribute("cx", cx);
circle.setAttribute("cy", cy);
circle.setAttribute("r", SEAT_R);
circle.setAttribute("class", "seat");
circle.setAttribute("tabindex", isTaken ? "-1" : "0");
circle.setAttribute("role", "button");
circle.setAttribute("data-id", id);
circle.setAttribute("data-tier", sec.tier);
var fill, stroke;
if (isTaken) {
fill = "#c9c9d6";
stroke = "#b4b4c4";
circle.classList.add("is-taken");
} else if (isAccessible) {
fill = "#f59e0b";
stroke = "#d97706";
} else {
fill = "#ffffff";
stroke = tier.color;
}
circle.setAttribute("fill", fill);
circle.setAttribute("stroke", stroke);
circle.setAttribute("stroke-width", "2");
var aria =
"Seat " + id + ", " + tier.label + ", $" + tier.price +
(isTaken ? ", unavailable" : isAccessible ? ", accessible, available" : ", available");
circle.setAttribute("aria-label", aria);
svg.appendChild(circle);
seatIndex[id] = {
el: circle,
id: id,
section: sec.id,
tier: sec.tier,
price: tier.price,
color: tier.color,
taken: isTaken,
accessible: isAccessible,
baseFill: fill
};
}
}
});
var w = maxX + 40;
var h = maxY + 30;
svg.setAttribute("width", w);
svg.setAttribute("height", h);
svg.setAttribute("viewBox", "0 0 " + w + " " + h);
}
// ---------- Selection ----------
function toggleSeat(seat) {
if (seat.taken) {
toast("That seat is already taken.", true);
return;
}
if (selected[seat.id]) {
deselect(seat);
return;
}
if (Object.keys(selected).length >= MAX_SEATS) {
toast("Max " + MAX_SEATS + " seats per order.", true);
return;
}
selected[seat.id] = seat;
seat.el.classList.add("is-selected");
seat.el.setAttribute("fill", seat.accessible ? "#b45309" : seat.color);
seat.el.setAttribute("aria-pressed", "true");
render();
}
function deselect(seat) {
delete selected[seat.id];
seat.el.classList.remove("is-selected");
seat.el.setAttribute("fill", seat.baseFill);
seat.el.setAttribute("aria-pressed", "false");
render();
}
function render() {
var ids = Object.keys(selected);
var subtotal = 0;
// clear list (keep empty node reference)
seatList.querySelectorAll(".seat-row").forEach(function (n) {
n.remove();
});
if (ids.length === 0) {
emptyState.style.display = "";
} else {
emptyState.style.display = "none";
}
ids
.sort()
.forEach(function (id) {
var s = selected[id];
subtotal += s.price;
var li = document.createElement("li");
li.className = "seat-row";
li.innerHTML =
'<span class="seat-tier-tag" style="background:' +
(s.accessible ? "#f59e0b" : s.color) +
'"></span>' +
'<span class="meta"><strong>Seat ' +
s.id +
"</strong><small>" +
TIERS[s.tier].label +
(s.accessible ? " · Accessible" : "") +
"</small></span>" +
'<span class="price">$' +
s.price.toFixed(2) +
"</span>" +
'<button class="rm" aria-label="Remove seat ' +
s.id +
'">×</button>';
li.querySelector(".rm").addEventListener("click", function () {
deselect(s);
});
seatList.appendChild(li);
});
var fee = subtotal * FEE_RATE;
document.getElementById("subtotal").textContent = "$" + subtotal.toFixed(2);
document.getElementById("fee").textContent = "$" + fee.toFixed(2);
document.getElementById("total").textContent =
"$" + (subtotal + fee).toFixed(2);
var count = ids.length;
document.getElementById("seatCount").textContent =
count + " seat" + (count === 1 ? "" : "s");
continueBtn.disabled = count === 0;
}
// ---------- Tier filter ----------
function setFilter(tier) {
activeFilter = activeFilter === tier ? null : tier;
var legend = document.querySelector(".legend");
legend.classList.toggle("has-filter", !!activeFilter);
document.querySelectorAll(".legend-chip").forEach(function (chip) {
chip.setAttribute(
"aria-pressed",
chip.dataset.tier === activeFilter ? "true" : "false"
);
});
Object.keys(seatIndex).forEach(function (id) {
var s = seatIndex[id];
var dim = activeFilter && s.tier !== activeFilter;
s.el.classList.toggle("dim", !!dim);
});
}
// ---------- Zoom / Pan ----------
function applyTransform() {
canvas.style.transform =
"translate(" + panX + "px," + panY + "px) scale(" + scale + ")";
zoomLabel.textContent = Math.round(scale * 100) + "%";
}
function clampPan() {
var vw = viewport.clientWidth;
var vh = viewport.clientHeight;
var cw = (maxX + 60) * scale;
var ch = (maxY + 60) * scale;
var minX = Math.min(0, vw - cw);
var minY = Math.min(0, vh - ch);
panX = Math.max(minX, Math.min(20, panX));
panY = Math.max(minY, Math.min(20, panY));
}
function zoomTo(newScale, originX, originY) {
newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale));
var rect = viewport.getBoundingClientRect();
var ox = originX == null ? rect.width / 2 : originX - rect.left;
var oy = originY == null ? rect.height / 2 : originY - rect.top;
// keep point under cursor stable
panX = ox - ((ox - panX) / scale) * newScale;
panY = oy - ((oy - panY) / scale) * newScale;
scale = newScale;
clampPan();
applyTransform();
}
document.getElementById("zoomIn").addEventListener("click", function () {
zoomTo(scale * 1.25);
});
document.getElementById("zoomOut").addEventListener("click", function () {
zoomTo(scale / 1.25);
});
document.getElementById("zoomReset").addEventListener("click", function () {
scale = 1;
panX = 0;
panY = 0;
applyTransform();
});
viewport.addEventListener(
"wheel",
function (e) {
e.preventDefault();
var dir = e.deltaY < 0 ? 1.12 : 1 / 1.12;
zoomTo(scale * dir, e.clientX, e.clientY);
},
{ passive: false }
);
// pan with pointer drag (but allow click on seats)
var dragging = false;
var moved = false;
var startPX = 0;
var startPY = 0;
var startPanX = 0;
var startPanY = 0;
viewport.addEventListener("pointerdown", function (e) {
dragging = true;
moved = false;
startPX = e.clientX;
startPY = e.clientY;
startPanX = panX;
startPanY = panY;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener("pointermove", function (e) {
if (!dragging) return;
var dx = e.clientX - startPX;
var dy = e.clientY - startPY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
moved = true;
viewport.classList.add("panning");
}
panX = startPanX + dx;
panY = startPanY + dy;
clampPan();
applyTransform();
});
viewport.addEventListener("pointerup", function () {
dragging = false;
viewport.classList.remove("panning");
});
viewport.addEventListener("pointercancel", function () {
dragging = false;
viewport.classList.remove("panning");
});
// seat click (suppress if user was panning)
svg.addEventListener("click", function (e) {
if (moved) return;
var t = e.target;
if (!t.classList || !t.classList.contains("seat")) return;
var seat = seatIndex[t.getAttribute("data-id")];
if (seat) toggleSeat(seat);
});
// keyboard seat selection
svg.addEventListener("keydown", function (e) {
if (e.key !== "Enter" && e.key !== " ") return;
var t = e.target;
if (!t.classList || !t.classList.contains("seat")) return;
e.preventDefault();
var seat = seatIndex[t.getAttribute("data-id")];
if (seat) toggleSeat(seat);
});
// legend filters
document.querySelectorAll(".legend-chip").forEach(function (chip) {
chip.addEventListener("click", function () {
setFilter(chip.dataset.tier);
});
});
// continue
continueBtn.addEventListener("click", function () {
var n = Object.keys(selected).length;
if (!n) return;
var total = document.getElementById("total").textContent;
toast("Holding " + n + " seat" + (n === 1 ? "" : "s") + " · " + total + " — demo only");
});
// ---------- Toast ----------
var toastWrap = document.getElementById("toastWrap");
function toast(msg, warn) {
var el = document.createElement("div");
el.className = "toast" + (warn ? " warn" : "");
el.textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.remove();
}, 2800);
}
// ---------- Countdown ----------
var eventTime = new Date("2026-07-18T20:00:00").getTime();
function tick() {
var diff = eventTime - Date.now();
if (diff < 0) diff = 0;
var d = Math.floor(diff / 86400000);
var h = Math.floor((diff % 86400000) / 3600000);
var m = Math.floor((diff % 3600000) / 60000);
var s = Math.floor((diff % 60000) / 1000);
set("d", d);
set("h", h);
set("m", m);
set("s", s);
}
function set(k, v) {
var el = document.querySelector('[data-cd="' + k + '"]');
if (el) el.textContent = String(v).padStart(2, "0");
}
// ---------- Init ----------
buildSeats();
render();
applyTransform();
tick();
setInterval(tick, 1000);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Seat Selection — Aurora Pulse Live</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>
<a class="skip" href="#seatmap">Skip to seat map</a>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◑</span>
<span class="brand-name">PULSE<span>TIX</span></span>
</div>
<div class="event-meta">
<div class="event-poster" aria-hidden="true"></div>
<div class="event-info">
<h1>Aurora Pulse — World Tour</h1>
<p class="event-sub">
<span class="dot ok" aria-hidden="true"></span>
The Helix Arena · Portland, OR
</p>
<p class="event-date">Sat, Jul 18 2026 · 8:00 PM</p>
</div>
</div>
<div class="countdown" id="countdown" aria-live="polite" aria-label="Time until doors open">
<div class="cd-unit"><span data-cd="d">00</span><small>days</small></div>
<div class="cd-unit"><span data-cd="h">00</span><small>hrs</small></div>
<div class="cd-unit"><span data-cd="m">00</span><small>min</small></div>
<div class="cd-unit"><span data-cd="s">00</span><small>sec</small></div>
</div>
</header>
<main class="layout">
<section class="map-panel" aria-label="Seat map">
<div class="map-toolbar">
<div class="legend" role="group" aria-label="Price tiers — click to filter">
<button class="legend-chip" data-tier="vip" aria-pressed="false">
<span class="swatch t-vip" aria-hidden="true"></span> VIP Pit · $240
</button>
<button class="legend-chip" data-tier="lower" aria-pressed="false">
<span class="swatch t-lower" aria-hidden="true"></span> Lower Bowl · $160
</button>
<button class="legend-chip" data-tier="mid" aria-pressed="false">
<span class="swatch t-mid" aria-hidden="true"></span> Mezzanine · $95
</button>
<button class="legend-chip" data-tier="upper" aria-pressed="false">
<span class="swatch t-upper" aria-hidden="true"></span> Upper Deck · $55
</button>
</div>
<div class="zoom-controls" role="group" aria-label="Zoom controls">
<button id="zoomOut" class="zbtn" aria-label="Zoom out">−</button>
<span id="zoomLabel" class="zoom-label">100%</span>
<button id="zoomIn" class="zbtn" aria-label="Zoom in">+</button>
<button id="zoomReset" class="zbtn reset" aria-label="Reset view">Reset</button>
</div>
</div>
<div class="state-legend" aria-hidden="true">
<span><i class="sl available"></i> Available</span>
<span><i class="sl selected"></i> Selected</span>
<span><i class="sl taken"></i> Taken</span>
<span><i class="sl accessible"></i> Accessible</span>
</div>
<div class="stage-wrap" id="seatmap">
<div class="viewport" id="viewport" tabindex="0" aria-label="Zoomable, pannable seat map. Drag to pan.">
<div class="canvas" id="canvas">
<div class="stage">STAGE</div>
<svg id="seatSvg" role="img" aria-label="Venue seating chart"></svg>
</div>
</div>
<p class="hint">Drag to pan · scroll or use + / − to zoom · click a seat to select</p>
</div>
</section>
<aside class="cart" aria-label="Your selection">
<div class="cart-head">
<h2>Your seats</h2>
<span class="max-note" id="maxNote">Up to <strong>6</strong> seats</span>
</div>
<ul class="seat-list" id="seatList" aria-live="polite">
<li class="empty" id="emptyState">No seats selected yet. Tap an open seat on the map to start.</li>
</ul>
<div class="ticket-stub" aria-hidden="true">
<div class="perf"></div>
</div>
<div class="totals">
<div class="row"><span>Subtotal</span><span id="subtotal">$0.00</span></div>
<div class="row muted"><span>Service fee</span><span id="fee">$0.00</span></div>
<div class="row total"><span>Total</span><span id="total">$0.00</span></div>
</div>
<button class="continue" id="continueBtn" disabled>
Continue to checkout
<span class="seat-count" id="seatCount">0 seats</span>
</button>
<p class="reassure">Seats held for 7:00 while you check out.</p>
</aside>
</main>
<div class="toast-wrap" id="toastWrap" aria-live="assertive"></div>
<script src="script.js"></script>
</body>
</html>Seat Selection Map
A high-contrast event-ticketing seat picker for the fictional Aurora Pulse — World Tour date at The Helix Arena. The venue is drawn as a live SVG chart with a darkened stage, four banked sections, and hundreds of individually addressable seats. Each seat carries a price tier (VIP Pit, Lower Bowl, Mezzanine, Upper Deck) and a state — available, selected, taken, or wheelchair-accessible — distinguished by both fill and outline so the chart reads clearly without relying on color alone.
The map is fully zoomable and pannable: scroll or use the +/− controls to scale between 55% and 240%, drag to pan, and hit Reset to recenter. Clicking the color-coded legend chips filters the chart down to a single price tier, dimming the rest so a fan can scan only the seats in their budget. Tapping an open seat drops it into the selection rail, which animates each row in, shows the tier tag and price, and recomputes subtotal, a 14% service fee, and the running total instantly. A six-seat-per-order guard and a taken-seat block both fire friendly toast messages.
Everything is plain HTML, CSS custom properties, and a single dependency-free script. Seats are generated deterministically so the “taken” pattern stays stable, the selection cart is keyboard-operable, and a ticket-stub perforation plus a live countdown to doors complete the energetic ticketing feel — all responsive down to a 360px phone.
Illustrative UI only — fictional events, not a real ticketing service.