Ticketing — Digital Ticket
A self-contained mobile digital event ticket with a perforated stub layout, photographic gradient hero, prominent date and venue, and a seat-tier color legend. It features an animated SVG-style QR code on canvas that refreshes on a security timer, a countdown to doors, and a brightness boost for scanning. Tap to flip the card for full event details, add the pass to a wallet, or open a validated transfer modal that voids your QR and reissues a fresh one.
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;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-1: 0 2px 8px rgba(14, 14, 22, 0.08);
--shadow-2: 0 18px 48px rgba(14, 14, 22, 0.22);
--shadow-glow: 0 24px 70px rgba(124, 58, 237, 0.35);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 520px at 80% -10%, rgba(124, 58, 237, 0.18), transparent 60%),
radial-gradient(900px 480px at 0% 0%, rgba(255, 61, 129, 0.12), transparent 55%),
var(--bg);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: filter 0.3s ease, background 0.3s ease;
}
body.bright {
filter: brightness(1.18) contrast(1.04);
}
.skip-link {
position: absolute;
left: -999px;
top: 0;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: var(--r-sm);
z-index: 60;
}
.skip-link:focus {
left: 12px;
top: 12px;
}
/* Topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px clamp(16px, 4vw, 40px);
max-width: 720px;
margin: 0 auto;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
letter-spacing: -0.02em;
}
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 9px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-size: 16px;
box-shadow: var(--shadow-glow);
}
.brand-name {
font-size: 18px;
}
.bright-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink-2);
font: inherit;
font-weight: 600;
font-size: 14px;
padding: 9px 14px;
border-radius: 999px;
cursor: pointer;
box-shadow: var(--shadow-1);
transition: transform 0.12s ease, border-color 0.2s ease, color 0.2s ease;
}
.bright-toggle:hover {
transform: translateY(-1px);
border-color: var(--brand);
color: var(--ink);
}
.bright-toggle[aria-pressed="true"] {
background: linear-gradient(135deg, var(--brand), var(--brand-d));
color: #fff;
border-color: transparent;
}
.bright-icon {
font-size: 15px;
}
/* Stage */
.stage {
max-width: 480px;
margin: 8px auto 64px;
padding: 0 clamp(16px, 4vw, 24px);
}
.stage-head {
text-align: center;
margin-bottom: 22px;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
font-weight: 700;
color: var(--brand);
}
.stage-title {
margin: 6px 0 8px;
font-size: clamp(26px, 6vw, 34px);
font-weight: 800;
letter-spacing: -0.03em;
}
.countdown {
margin: 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 14px;
color: var(--ink-2);
}
.countdown strong {
color: var(--accent);
}
/* Ticket flip mechanics */
.ticket-wrap {
perspective: 1600px;
}
.flip-btn {
display: block;
margin: 0 auto 14px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--brand-d);
font: inherit;
font-weight: 700;
font-size: 13px;
padding: 8px 18px;
border-radius: 999px;
cursor: pointer;
box-shadow: var(--shadow-1);
transition: transform 0.12s ease, color 0.2s ease;
}
.flip-btn:hover {
transform: translateY(-1px);
color: var(--accent);
}
.ticket {
position: relative;
transform-style: preserve-3d;
transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
min-height: 560px;
}
.ticket.flipped {
transform: rotateY(180deg);
}
.ticket-face {
position: relative;
border-radius: var(--r-lg);
background: var(--surface);
box-shadow: var(--shadow-2);
overflow: hidden;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.ticket-front {
position: relative;
}
.ticket-back {
position: absolute;
inset: 0;
transform: rotateY(180deg);
padding: 26px 24px;
}
/* Hero */
.hero {
position: relative;
min-height: 188px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: flex-end;
color: #fff;
background:
linear-gradient(180deg, rgba(14, 14, 22, 0) 30%, rgba(14, 14, 22, 0.78) 100%),
radial-gradient(120% 120% at 80% 0%, var(--accent), transparent 55%),
linear-gradient(135deg, var(--brand) 0%, var(--brand-d) 55%, #1d1240 100%);
}
.hero::after {
content: "";
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
115deg,
rgba(255, 255, 255, 0.06) 0 2px,
transparent 2px 9px
);
pointer-events: none;
}
.hero-badge {
position: absolute;
top: 16px;
left: 16px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 5px 11px;
border-radius: 999px;
z-index: 1;
}
.hero-badge--low {
background: var(--accent);
color: #fff;
box-shadow: 0 6px 18px rgba(255, 61, 129, 0.5);
}
.hero-meta {
position: relative;
z-index: 1;
}
.hero-kicker {
margin: 0 0 4px;
font-weight: 700;
font-size: 13px;
opacity: 0.92;
}
.hero-title {
margin: 0 0 4px;
font-size: 27px;
font-weight: 800;
letter-spacing: -0.03em;
}
.hero-venue {
margin: 0;
font-size: 13px;
opacity: 0.9;
}
/* Stub */
.stub {
padding: 16px 20px 14px;
}
.stub-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stub-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.stub-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-weight: 700;
}
.stub-value {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.tier-legend {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--line);
}
.tier {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: var(--ink-2);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot--ga {
background: var(--accent);
}
.dot--lower {
background: var(--brand);
}
.dot--vip {
background: var(--warn);
}
/* Perforation */
.perf {
position: relative;
display: flex;
align-items: center;
height: 24px;
}
.notch {
position: absolute;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg);
box-shadow: inset 0 0 0 1px var(--line);
top: 0;
}
.notch--l {
left: -12px;
}
.notch--r {
right: -12px;
}
.dash {
flex: 1;
margin: 0 16px;
border-top: 2px dashed var(--line);
}
/* QR */
.qr-zone {
padding: 6px 20px 24px;
text-align: center;
}
.qr {
margin: 0 auto;
width: 220px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
#qrCanvas {
width: 200px;
height: 200px;
max-width: 60vw;
border-radius: var(--r-md);
background: #fff;
box-shadow: var(--shadow-1);
border: 1px solid var(--line);
image-rendering: pixelated;
}
.qr-cap {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.04em;
color: var(--ink);
}
.qr-hint {
margin: 10px 0 2px;
font-size: 12px;
color: var(--muted);
}
.qr-hint #qrTimer {
font-family: "JetBrains Mono", monospace;
font-weight: 700;
color: var(--brand);
}
.holder {
margin: 6px 0 0;
font-size: 13px;
color: var(--ink-2);
}
/* Back face */
.back-title {
margin: 0 0 14px;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
}
.detail-list {
margin: 0 0 18px;
display: grid;
gap: 0;
}
.detail-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 11px 0;
border-bottom: 1px solid var(--line);
}
.detail-row dt {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.detail-row dd {
margin: 0;
font-weight: 700;
font-size: 14px;
text-align: right;
}
.rules {
margin: 0 0 18px;
padding-left: 18px;
display: grid;
gap: 7px;
}
.rules li {
font-size: 13px;
color: var(--ink-2);
}
.back-foot {
margin: 0;
font-size: 12px;
color: var(--muted);
}
/* Actions */
.actions {
display: flex;
gap: 12px;
margin-top: 18px;
}
.btn {
flex: 1;
font: inherit;
font-weight: 700;
font-size: 15px;
padding: 14px 16px;
border-radius: var(--r-md);
cursor: pointer;
border: 1px solid transparent;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.btn:active {
transform: translateY(1px) scale(0.99);
}
.btn--primary {
color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: var(--shadow-glow);
}
.btn--primary:hover {
box-shadow: 0 18px 40px rgba(124, 58, 237, 0.45);
transform: translateY(-1px);
}
.btn--ghost {
background: var(--surface);
color: var(--ink);
border-color: var(--line);
box-shadow: var(--shadow-1);
}
.btn--ghost:hover {
border-color: var(--brand);
color: var(--brand-d);
transform: translateY(-1px);
}
/* Modal */
.modal {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 18px;
}
.modal[hidden] {
display: none;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(14, 14, 22, 0.55);
backdrop-filter: blur(3px);
animation: fade 0.2s ease;
}
.modal-card {
position: relative;
width: min(420px, 100%);
background: var(--surface);
border-radius: var(--r-lg);
padding: 26px 24px 24px;
box-shadow: var(--shadow-2);
animation: pop 0.24s cubic-bezier(0.2, 0.9, 0.2, 1);
}
.modal-x {
position: absolute;
top: 14px;
right: 14px;
border: none;
background: var(--bg);
color: var(--ink-2);
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 14px;
cursor: pointer;
}
.modal-x:hover {
background: var(--line);
}
.modal-title {
margin: 0 0 6px;
font-size: 21px;
font-weight: 800;
letter-spacing: -0.02em;
}
.modal-sub {
margin: 0 0 18px;
font-size: 14px;
color: var(--muted);
}
.modal-form {
display: grid;
gap: 14px;
}
.field {
display: grid;
gap: 6px;
}
.field-label {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
}
.field input {
font: inherit;
padding: 12px 14px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--bg);
color: var(--ink);
}
.field input:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 1px;
background: var(--surface);
}
.field-error {
font-size: 12px;
font-weight: 600;
color: var(--danger);
min-height: 14px;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 6px;
}
/* Toast */
.toast-host {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
display: grid;
gap: 10px;
z-index: 70;
width: min(380px, calc(100% - 32px));
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--ink);
color: #fff;
padding: 13px 16px;
border-radius: var(--r-md);
box-shadow: var(--shadow-2);
font-size: 14px;
font-weight: 600;
animation: toastin 0.28s cubic-bezier(0.2, 0.9, 0.2, 1);
}
.toast--ok {
border-left: 4px solid var(--ok);
}
.toast--info {
border-left: 4px solid var(--brand);
}
.toast.out {
animation: toastout 0.3s ease forwards;
}
.toast-emoji {
font-size: 16px;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
@keyframes fade {
from { opacity: 0; }
}
@keyframes pop {
from { opacity: 0; transform: translateY(10px) scale(0.97); }
}
@keyframes toastin {
from { opacity: 0; transform: translateY(14px); }
}
@keyframes toastout {
to { opacity: 0; transform: translateY(10px); }
}
@media (max-width: 520px) {
.stub-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px 10px;
}
.actions {
flex-direction: column;
}
.hero-title {
font-size: 23px;
}
.bright-label {
display: none;
}
.ticket {
min-height: 600px;
}
}
@media (prefers-reduced-motion: reduce) {
.ticket,
.btn,
.bright-toggle,
.flip-btn {
transition: none;
}
}(function () {
"use strict";
/* ---------- Toast helper ---------- */
var toastHost = document.getElementById("toastHost");
function toast(msg, kind) {
if (!toastHost) return;
var el = document.createElement("div");
el.className = "toast toast--" + (kind === "ok" ? "ok" : "info");
el.setAttribute("role", "status");
var emoji = document.createElement("span");
emoji.className = "toast-emoji";
emoji.setAttribute("aria-hidden", "true");
emoji.textContent = kind === "ok" ? "✓" : "🎟";
var text = document.createElement("span");
text.textContent = msg;
el.appendChild(emoji);
el.appendChild(text);
toastHost.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 320);
}, 2600);
}
/* ---------- Animated QR (deterministic-ish noise pattern) ---------- */
var canvas = document.getElementById("qrCanvas");
var qrCap = document.getElementById("qrCap");
var ctx = canvas ? canvas.getContext("2d") : null;
var GRID = 25;
var seed = 1337;
function rand() {
// simple xorshift for repeatable-but-changing patterns
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
return ((seed >>> 0) % 1000) / 1000;
}
function drawFinder(x, y, cell) {
ctx.fillStyle = "#0e0e16";
ctx.fillRect(x, y, cell * 7, cell * 7);
ctx.fillStyle = "#ffffff";
ctx.fillRect(x + cell, y + cell, cell * 5, cell * 5);
ctx.fillStyle = "#0e0e16";
ctx.fillRect(x + cell * 2, y + cell * 2, cell * 3, cell * 3);
}
function inFinder(c, r) {
return (
(c < 7 && r < 7) ||
(c >= GRID - 7 && r < 7) ||
(c < 7 && r >= GRID - 7)
);
}
function renderQR(token) {
if (!ctx) return;
// seed from token + minute slice so it "rotates"
var s = 2166136261;
for (var i = 0; i < token.length; i++) {
s ^= token.charCodeAt(i);
s = (s * 16777619) >>> 0;
}
seed = (s ^ (Date.now() >> 14)) >>> 0 || 1;
var size = canvas.width;
var cell = Math.floor(size / GRID);
var pad = Math.floor((size - cell * GRID) / 2);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = "#0e0e16";
for (var r = 0; r < GRID; r++) {
for (var c = 0; c < GRID; c++) {
if (inFinder(c, r)) continue;
if (rand() > 0.52) {
ctx.fillRect(pad + c * cell, pad + r * cell, cell, cell);
}
}
}
// finder squares
drawFinder(pad, pad, cell);
drawFinder(pad + cell * (GRID - 7), pad, cell);
drawFinder(pad, pad + cell * (GRID - 7), cell);
}
var tokenBase = "NPF-7K42-Q9";
function freshToken() {
var suffix = Math.floor(rand() * 9000 + 1000);
return tokenBase + "-" + suffix;
}
var TTL = 15;
var remaining = TTL;
var qrTimerEl = document.getElementById("qrTimer");
if (ctx) {
renderQR(tokenBase);
setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
remaining = TTL;
renderQR(freshToken());
}
if (qrTimerEl) qrTimerEl.textContent = remaining;
}, 1000);
}
/* ---------- Flip ---------- */
var flipBtn = document.getElementById("flipBtn");
var ticket = document.getElementById("ticket");
var frontFace = document.querySelector(".ticket-front");
var backFace = document.querySelector(".ticket-back");
function setFlip(flipped) {
ticket.classList.toggle("flipped", flipped);
flipBtn.setAttribute("aria-pressed", String(flipped));
flipBtn.textContent = flipped ? "Flip to QR code" : "Flip for details";
if (frontFace) frontFace.setAttribute("aria-hidden", String(flipped));
if (backFace) backFace.setAttribute("aria-hidden", String(!flipped));
}
if (flipBtn && ticket) {
flipBtn.addEventListener("click", function () {
setFlip(!ticket.classList.contains("flipped"));
});
}
/* ---------- Brightness boost ---------- */
var brightBtn = document.getElementById("brightBtn");
if (brightBtn) {
brightBtn.addEventListener("click", function () {
var on = document.body.classList.toggle("bright");
brightBtn.setAttribute("aria-pressed", String(on));
brightBtn.querySelector(".bright-label").textContent = on
? "Brightness boosted"
: "Boost brightness";
toast(on ? "Screen brightness boosted for scanning" : "Brightness back to normal", "info");
});
}
/* ---------- Add to wallet ---------- */
var walletBtn = document.getElementById("walletBtn");
if (walletBtn) {
walletBtn.addEventListener("click", function () {
walletBtn.disabled = true;
walletBtn.style.opacity = "0.7";
toast("Adding GA Floor pass to your wallet…", "info");
setTimeout(function () {
toast("Saved to Wallet — find it under Passes", "ok");
walletBtn.disabled = false;
walletBtn.style.opacity = "";
}, 1100);
});
}
/* ---------- Transfer modal ---------- */
var transferBtn = document.getElementById("transferBtn");
var modal = document.getElementById("transferModal");
var form = document.getElementById("transferForm");
var recName = document.getElementById("recName");
var recContact = document.getElementById("recContact");
var recError = document.getElementById("recError");
var lastFocused = null;
function openModal() {
lastFocused = document.activeElement;
modal.hidden = false;
document.addEventListener("keydown", onKey);
setTimeout(function () {
if (recName) recName.focus();
}, 30);
}
function closeModal() {
modal.hidden = true;
document.removeEventListener("keydown", onKey);
if (recError) recError.textContent = "";
if (form) form.reset();
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function onKey(e) {
if (e.key === "Escape") closeModal();
}
if (transferBtn) transferBtn.addEventListener("click", openModal);
if (modal) {
modal.addEventListener("click", function (e) {
if (e.target.hasAttribute("data-close")) closeModal();
});
}
function validContact(v) {
var isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
var isPhone = /^[+]?[\d\s()-]{7,}$/.test(v);
return isEmail || isPhone;
}
if (form) {
form.addEventListener("submit", function (e) {
e.preventDefault();
var name = recName.value.trim();
var contact = recContact.value.trim();
if (!name) {
recError.textContent = "Add a recipient name.";
recName.focus();
return;
}
if (!validContact(contact)) {
recError.textContent = "Enter a valid email or phone number.";
recContact.focus();
return;
}
recError.textContent = "";
var who = name.split(" ")[0];
closeModal();
toast("Transfer request sent…", "info");
setTimeout(function () {
toast("Ticket transferred to " + who + " — your QR is now void", "ok");
}, 1000);
});
}
/* ---------- Countdown to doors ---------- */
var countdownEl = document.getElementById("countdown");
// Sat Jul 19 2026, 17:30 local
var target = new Date(2026, 6, 19, 17, 30, 0).getTime();
function tickCountdown() {
if (!countdownEl) return;
var diff = target - Date.now();
if (diff <= 0) {
countdownEl.innerHTML = "Doors are <strong>open</strong> — head to Gate C";
return;
}
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);
countdownEl.innerHTML =
"Doors open in <strong>" +
d + "d " + h + "h " + m + "m " + s + "s</strong>";
}
tickCountdown();
setInterval(tickCountdown, 1000);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Digital Ticket — Neon Pulse Festival</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&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to ticket</a>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◈</span>
<span class="brand-name">PulsePass</span>
</div>
<div class="topbar-actions">
<button class="bright-toggle" id="brightBtn" type="button" aria-pressed="false">
<span class="bright-icon" aria-hidden="true">☀</span>
<span class="bright-label">Boost brightness</span>
</button>
</div>
</header>
<main id="main" class="stage">
<div class="stage-head">
<p class="eyebrow">Your ticket is ready</p>
<h1 class="stage-title">Scan at the gate</h1>
<p class="countdown" id="countdown" aria-live="polite">Calculating time to doors…</p>
</div>
<div class="ticket-wrap" id="ticketWrap">
<button class="flip-btn" id="flipBtn" type="button" aria-pressed="false" aria-controls="ticket">
Flip for details
</button>
<div class="ticket" id="ticket">
<!-- FRONT -->
<article class="ticket-face ticket-front" aria-label="Ticket front">
<div class="hero" role="img" aria-label="Festival main stage photo placeholder">
<span class="hero-badge hero-badge--low">Final tickets</span>
<div class="hero-meta">
<p class="hero-kicker">Sat • Jul 19, 2026</p>
<h2 class="hero-title">Neon Pulse Festival</h2>
<p class="hero-venue">Harborline Arena — Lisbon</p>
</div>
</div>
<div class="stub">
<div class="stub-grid">
<div class="stub-cell">
<span class="stub-label">Section</span>
<span class="stub-value">GA Floor</span>
</div>
<div class="stub-cell">
<span class="stub-label">Entrance</span>
<span class="stub-value">Gate C</span>
</div>
<div class="stub-cell">
<span class="stub-label">Row</span>
<span class="stub-value">—</span>
</div>
<div class="stub-cell">
<span class="stub-label">Seat</span>
<span class="stub-value">Standing</span>
</div>
</div>
<div class="tier-legend" aria-hidden="true">
<span class="tier"><i class="dot dot--ga"></i>GA Floor</span>
<span class="tier"><i class="dot dot--lower"></i>Lower</span>
<span class="tier"><i class="dot dot--vip"></i>VIP</span>
</div>
</div>
<div class="perf" aria-hidden="true">
<span class="notch notch--l"></span>
<span class="dash"></span>
<span class="notch notch--r"></span>
</div>
<div class="qr-zone">
<figure class="qr" id="qr" aria-label="Entry QR code">
<canvas id="qrCanvas" width="220" height="220"></canvas>
<figcaption class="qr-cap" id="qrCap">Ticket #NPF-7K42-Q9</figcaption>
</figure>
<p class="qr-hint">Refreshes every <span id="qrTimer">15</span>s for security</p>
<p class="holder">Holder: <strong>Mara Velasquez</strong></p>
</div>
</article>
<!-- BACK -->
<article class="ticket-face ticket-back" aria-label="Ticket details" aria-hidden="true">
<h2 class="back-title">Event details</h2>
<dl class="detail-list">
<div class="detail-row"><dt>Doors open</dt><dd>5:30 PM WEST</dd></div>
<div class="detail-row"><dt>Headline set</dt><dd>9:15 PM WEST</dd></div>
<div class="detail-row"><dt>Order</dt><dd>#PP-2026-558109</dd></div>
<div class="detail-row"><dt>Price paid</dt><dd>€84.00 incl. fees</dd></div>
<div class="detail-row"><dt>Delivery</dt><dd>Mobile entry only</dd></div>
</dl>
<ul class="rules">
<li>Screenshots will not scan — use the live QR.</li>
<li>Re-entry permitted at Gate C until 10:00 PM.</li>
<li>No professional cameras or glass containers.</li>
</ul>
<p class="back-foot">Powered by PulsePass • Transfers tracked end-to-end.</p>
</article>
</div>
<div class="actions">
<button class="btn btn--primary" id="walletBtn" type="button">
<span aria-hidden="true">+</span> Add to Wallet
</button>
<button class="btn btn--ghost" id="transferBtn" type="button">
Transfer ticket
</button>
</div>
</div>
</main>
<!-- Transfer modal -->
<div class="modal" id="transferModal" hidden>
<div class="modal-backdrop" data-close></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="transferTitle">
<button class="modal-x" type="button" data-close aria-label="Close transfer dialog">✕</button>
<h2 id="transferTitle" class="modal-title">Transfer this ticket</h2>
<p class="modal-sub">Send GA Floor entry to a friend. They will get a fresh QR; yours stops working.</p>
<form id="transferForm" class="modal-form" novalidate>
<label class="field">
<span class="field-label">Recipient name</span>
<input type="text" id="recName" name="recName" placeholder="Jordan Okafor" autocomplete="name" required />
</label>
<label class="field">
<span class="field-label">Email or phone</span>
<input type="text" id="recContact" name="recContact" placeholder="jordan@example.com" required />
<span class="field-error" id="recError" aria-live="polite"></span>
</label>
<div class="modal-actions">
<button class="btn btn--ghost" type="button" data-close>Cancel</button>
<button class="btn btn--primary" type="submit">Send ticket</button>
</div>
</form>
</div>
</div>
<div class="toast-host" id="toastHost" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Digital Ticket
A pocket-sized digital pass for the fictional Neon Pulse Festival. The card uses a classic perforated-stub silhouette: a photographic gradient hero with a low-stock badge sits above the seat stub, a dashed perforation line with rounded notches separates the stub from the canvas QR zone, and a tier color legend maps GA Floor, Lower, and VIP to dots. A live countdown to doors and a security timer that re-renders the QR every fifteen seconds keep the ticket feeling current.
Every control is interactive. Add to Wallet shows an optimistic loading toast then a success confirmation; Boost brightness lifts the whole screen via a CSS filter so gate scanners can read the code; and Flip for details rotates the card in 3D to reveal door times, the order number, the price paid, and entry rules. Transfer ticket opens an accessible modal (focus management, Escape to close, backdrop dismiss) that validates the recipient’s name and email-or-phone before sending and voiding your QR.
It is plain HTML, CSS, and vanilla JavaScript with no dependencies. The QR is drawn on a <canvas>
with finder squares and a seeded noise fill, so it looks like a real scannable code while staying
fully offline. The layout is responsive down to about 360px, respects reduced-motion preferences,
and meets WCAG AA contrast.
Illustrative UI only — fictional events, not a real ticketing service.