Museum — Virtual Tour / Gallery Walk
A refined, full-viewport virtual gallery walk for the fictional Meridian Museum. Step between five curated rooms with prev/next arrows or arrow keys, watch smooth crossfades carry you from one wing to the next, and read a live room caption and stop counter. Brass hotspots on each framed artwork open a curatorial popover with artist, date, medium, dimensions and accession number, while a thumbnail filmstrip jumps rooms and a per-stop audio-guide track label rounds out the experience.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow-sm: 0 1px 2px rgba(28, 27, 25, 0.06), 0 2px 8px rgba(28, 27, 25, 0.05);
--shadow-md: 0 8px 28px rgba(28, 27, 25, 0.14);
--shadow-lg: 0 24px 64px rgba(28, 27, 25, 0.28);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
font-family: var(--sans);
background: var(--paper);
color: var(--ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button {
font-family: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
/* ---------- Layout shell ---------- */
.tour {
height: 100vh;
min-height: 540px;
display: grid;
grid-template-rows: auto 1fr auto;
background: var(--paper);
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 24px;
background: var(--wall);
border-bottom: 1px solid var(--line);
z-index: 4;
}
.brand {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.brand__mark {
color: var(--gold);
font-size: 18px;
}
.brand__name {
font-family: var(--serif);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.01em;
color: var(--charcoal);
}
.brand__sub {
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
.topbar__meta {
display: flex;
align-items: center;
gap: 14px;
}
.stop-counter {
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-2);
white-space: nowrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink);
padding: 8px 14px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 500;
letter-spacing: 0.04em;
transition: background 0.18s, border-color 0.18s, color 0.18s, box-shadow 0.18s;
}
.btn--ghost:hover {
border-color: var(--gold);
color: var(--gold-d);
}
.btn .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
transition: background 0.2s;
}
.btn[aria-pressed="true"] {
background: var(--charcoal);
border-color: var(--charcoal);
color: var(--paper);
}
.btn[aria-pressed="true"] .dot {
background: var(--gold);
box-shadow: 0 0 0 4px rgba(169, 129, 64, 0.25);
animation: pulse 1.6s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(169, 129, 64, 0.28); }
50% { box-shadow: 0 0 0 7px rgba(169, 129, 64, 0); }
}
/* ---------- Stage ---------- */
.stage {
position: relative;
overflow: hidden;
background: var(--charcoal);
}
.rooms {
position: absolute;
inset: 0;
}
.room {
position: absolute;
inset: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.7s ease;
display: grid;
place-items: center;
padding: clamp(24px, 6vw, 88px);
}
.room.is-active {
opacity: 1;
visibility: visible;
}
/* room ambient floor + wall */
.room::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 26%;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.45));
pointer-events: none;
}
.wall {
position: relative;
width: min(100%, 1100px);
display: flex;
align-items: center;
justify-content: center;
gap: clamp(20px, 5vw, 64px);
flex-wrap: wrap;
z-index: 1;
}
/* ---------- Artwork frame + hotspot ---------- */
.art {
position: relative;
border: 0;
background: none;
padding: 0;
display: block;
filter: drop-shadow(0 22px 30px rgba(0, 0, 0, 0.45));
transition: transform 0.25s ease;
}
.art:hover,
.art:focus-visible {
transform: translateY(-4px);
}
.art__mat {
background: #efece4;
padding: clamp(14px, 2.2vw, 26px);
border: 2px solid #2c2922;
box-shadow: inset 0 0 0 6px #cdbf9e, inset 0 0 26px rgba(0, 0, 0, 0.18);
}
.art__canvas {
width: clamp(120px, 22vw, 230px);
height: clamp(150px, 28vw, 290px);
border: 1px solid rgba(0, 0, 0, 0.25);
}
.art--wide .art__canvas {
width: clamp(180px, 34vw, 360px);
height: clamp(120px, 20vw, 220px);
}
.art__plate {
margin-top: 10px;
text-align: center;
color: #e9e4d8;
}
.art__plate-title {
font-family: var(--serif);
font-size: 15px;
font-weight: 600;
line-height: 1.25;
}
.art__plate-meta {
font-size: 11px;
color: #b9b1a2;
letter-spacing: 0.04em;
}
/* hotspot */
.hotspot {
position: absolute;
top: -12px;
right: -12px;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--gold);
color: #fff;
border: 2px solid #fff;
font-size: 15px;
display: grid;
place-items: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
animation: hsPulse 2.2s infinite;
}
.art--wide .hotspot {
top: 50%;
right: -14px;
transform: translateY(-50%);
}
@keyframes hsPulse {
0%, 100% { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 0 rgba(169, 129, 64, 0.5); }
50% { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 0 10px rgba(169, 129, 64, 0); }
}
/* ---------- Room caption ---------- */
.room-caption {
position: absolute;
left: clamp(18px, 4vw, 40px);
top: clamp(18px, 4vw, 36px);
z-index: 3;
max-width: 340px;
background: rgba(246, 244, 239, 0.92);
backdrop-filter: blur(8px);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px;
box-shadow: var(--shadow-md);
}
.room-caption__wing {
font-size: 10.5px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.room-caption__title {
font-family: var(--serif);
font-weight: 700;
font-size: clamp(22px, 3vw, 30px);
margin: 4px 0 6px;
color: var(--charcoal);
line-height: 1.1;
}
.room-caption__desc {
margin: 0;
font-size: 13px;
color: var(--ink-2);
}
/* ---------- Audio guide ---------- */
.audioguide {
position: absolute;
left: clamp(18px, 4vw, 40px);
bottom: clamp(18px, 4vw, 32px);
z-index: 3;
display: flex;
align-items: center;
gap: 12px;
background: rgba(28, 27, 25, 0.82);
backdrop-filter: blur(8px);
color: #f1ede4;
padding: 8px 14px 8px 8px;
border-radius: 999px;
box-shadow: var(--shadow-md);
}
.audioguide__btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
background: var(--gold);
color: #fff;
font-size: 13px;
display: grid;
place-items: center;
flex: none;
}
.audioguide__btn[aria-pressed="true"] {
background: var(--gold-d);
}
.audioguide__meta {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.audioguide__label {
font-size: 9.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #c7bfae;
}
.audioguide__track {
font-size: 13px;
font-weight: 500;
}
.audioguide__time {
font-size: 11px;
color: #b6ae9d;
font-variant-numeric: tabular-nums;
padding-left: 4px;
}
/* ---------- Nav arrows ---------- */
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(28, 27, 25, 0.6);
color: #fff;
font-size: 28px;
line-height: 1;
display: grid;
place-items: center;
backdrop-filter: blur(6px);
transition: background 0.18s, transform 0.18s;
}
.nav:hover {
background: var(--gold);
}
.nav--prev { left: clamp(12px, 2vw, 22px); }
.nav--next { right: clamp(12px, 2vw, 22px); }
/* ---------- Filmstrip ---------- */
.filmstrip {
background: var(--wall);
border-top: 1px solid var(--line);
padding: 12px 16px;
z-index: 4;
}
.filmstrip__rail {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 2px 2px 6px;
scrollbar-width: thin;
}
.thumb {
flex: none;
width: 132px;
text-align: left;
background: none;
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 6px;
transition: border-color 0.18s, box-shadow 0.18s, transform 0.18s;
}
.thumb:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.thumb.is-active {
border-color: var(--gold);
box-shadow: 0 0 0 2px var(--gold-50), var(--shadow-sm);
}
.thumb__img {
height: 58px;
border-radius: 4px;
border: 1px solid var(--line);
}
.thumb__n {
margin-top: 6px;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.thumb__name {
font-family: var(--serif);
font-size: 14px;
font-weight: 600;
color: var(--ink);
line-height: 1.2;
}
/* ---------- Popover ---------- */
.popover-layer {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 18px;
background: rgba(28, 27, 25, 0.55);
backdrop-filter: blur(3px);
animation: fade 0.2s ease;
}
@keyframes fade {
from { opacity: 0; }
}
.popover {
width: min(560px, 100%);
max-height: 92vh;
overflow: auto;
background: var(--wall);
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
border: 1px solid var(--line);
animation: rise 0.26s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes rise {
from { transform: translateY(14px) scale(0.985); opacity: 0; }
}
.popover__frame {
height: 220px;
border-bottom: 1px solid var(--line);
position: relative;
}
.popover__frame::after {
content: "";
position: absolute;
inset: 16px;
border: 6px solid #cdbf9e;
box-shadow: inset 0 0 0 2px #2c2922;
}
.popover__body {
padding: 20px 24px 24px;
}
.popover__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.badge {
font-size: 10.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 600;
color: var(--ok);
background: rgba(63, 125, 86, 0.12);
border: 1px solid rgba(63, 125, 86, 0.3);
padding: 4px 10px;
border-radius: 999px;
}
.badge.is-loan {
color: var(--warn);
background: rgba(184, 132, 44, 0.12);
border-color: rgba(184, 132, 44, 0.3);
}
.popover__close {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: var(--paper);
color: var(--ink-2);
font-size: 14px;
}
.popover__close:hover {
background: var(--charcoal);
color: var(--paper);
}
.popover__title {
font-family: var(--serif);
font-weight: 700;
font-size: 28px;
margin: 2px 0 2px;
color: var(--charcoal);
line-height: 1.12;
}
.popover__artist {
margin: 0 0 14px;
font-size: 14px;
color: var(--gold-d);
font-weight: 500;
}
.popover__facts {
display: grid;
grid-template-columns: auto 1fr;
gap: 6px 18px;
margin: 0 0 14px;
padding: 14px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.popover__facts dt {
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
align-self: center;
}
.popover__facts dd {
margin: 0;
font-size: 13.5px;
color: var(--ink);
}
.popover__note {
font-size: 14px;
color: var(--ink-2);
margin: 0 0 14px;
}
.popover__cat {
font-size: 11.5px;
letter-spacing: 0.08em;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---------- Toast ---------- */
.toast-host {
position: fixed;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
z-index: 40;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
background: var(--charcoal);
color: var(--paper);
padding: 10px 18px;
border-radius: 999px;
font-size: 13px;
box-shadow: var(--shadow-md);
animation: toastIn 0.25s ease, toastOut 0.3s ease 2.4s forwards;
}
.toast__k {
color: var(--gold);
font-weight: 600;
}
@keyframes toastIn {
from { transform: translateY(10px); opacity: 0; }
}
@keyframes toastOut {
to { transform: translateY(8px); opacity: 0; }
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.topbar {
padding: 12px 14px;
flex-wrap: wrap;
gap: 8px;
}
.brand__sub { display: none; }
.brand__name { font-size: 18px; }
.room-caption {
left: 12px;
right: 12px;
top: 12px;
max-width: none;
padding: 12px 14px;
}
.wall { gap: 22px; }
.audioguide {
left: 12px;
right: 12px;
bottom: 12px;
justify-content: flex-start;
}
.audioguide__time { margin-left: auto; }
.nav { width: 42px; height: 42px; font-size: 22px; }
.popover__frame { height: 160px; }
.popover__title { font-size: 22px; }
.toast-host { bottom: 110px; }
}
@media (prefers-reduced-motion: reduce) {
.room { transition: opacity 0.01ms; }
.hotspot, .btn[aria-pressed="true"] .dot { animation: none; }
}// Meridian Museum — Virtual Tour / Gallery Walk
(function () {
"use strict";
// ---------- Demo data (fictional) ----------
const ROOMS = [
{
wing: "East Wing",
name: "Gallery I — Origins",
desc: "Early figuration and the birth of line. Works on loan from the Aldworth bequest.",
track: "Track 01 · Origins",
dur: "03:12",
wall: "linear-gradient(180deg,#f0ebe0,#e3dccc)",
art: [
{
grad: "linear-gradient(135deg,#c9783f,#7a3b2e 70%)",
title: "Dusk over Carrow Fen",
artist: "Eluned Marris",
year: "1887",
medium: "Oil on linen",
dims: "92 × 71 cm",
cat: "MM.1887.041",
status: "On View",
note: "A late-summer marshland rendered in burnt sienna, exemplary of Marris's tonal massing of the horizon line.",
},
{
grad: "linear-gradient(160deg,#3f5b6b,#1f2c33 75%)",
title: "The Cartographer",
artist: "Henri Vasseau",
year: "1901",
medium: "Oil on panel",
dims: "61 × 48 cm",
cat: "MM.1901.117",
status: "On View",
note: "A solitary figure bent over charts; Vasseau's restrained palette anticipates his later interior series.",
},
],
},
{
wing: "East Wing",
name: "Gallery II — Light & Atmosphere",
desc: "Impressionist studies of weather, water and the changing hour.",
track: "Track 02 · Light & Atmosphere",
dur: "04:05",
wall: "linear-gradient(180deg,#f4f0e6,#e8e1d0)",
art: [
{
wide: true,
grad: "linear-gradient(90deg,#a7c4d6,#dfe7d6 50%,#f3e6c8)",
title: "Estuary, Morning",
artist: "Greta Lindqvist",
year: "1894",
medium: "Oil on canvas",
dims: "120 × 74 cm",
cat: "MM.1894.203",
status: "On View",
note: "Broken brushwork dissolves the shoreline into shimmering air — a touchstone of the museum's collection.",
},
{
grad: "linear-gradient(150deg,#e0b66a,#b06a3a 80%)",
title: "Lanterns, Rue Sainte-Foy",
artist: "Henri Vasseau",
year: "1908",
medium: "Pastel on paper",
dims: "54 × 41 cm",
cat: "MM.1908.066",
status: "On Loan",
note: "Loaned by the Devereaux Foundation. A nocturne of gaslight smeared across wet cobblestones.",
},
],
},
{
wing: "Central Court",
name: "Gallery III — Abstraction",
desc: "The dismantling of the figure: geometry, colour field and pure form.",
track: "Track 03 · Abstraction",
dur: "02:48",
wall: "linear-gradient(180deg,#efeae0,#d8d0c0)",
art: [
{
grad: "conic-gradient(from 40deg,#b4493a,#a98140,#3f7d56,#3f5b6b,#b4493a)",
title: "Composition in Four Keys",
artist: "Saul Brenner",
year: "1949",
medium: "Acrylic on board",
dims: "100 × 100 cm",
cat: "MM.1949.312",
status: "On View",
note: "Brenner's interlocking quadrants test the optical limits of complementary colour.",
},
{
grad: "linear-gradient(115deg,#1c1b19 0 45%,#a98140 45% 55%,#f6f4ef 55%)",
title: "Threshold",
artist: "Ito Nakagawa",
year: "1962",
medium: "Lacquer on wood",
dims: "180 × 90 cm",
cat: "MM.1962.008",
status: "On View",
note: "A single brass seam divides darkness from paper-white void — minimalism at its most austere.",
},
],
},
{
wing: "West Wing",
name: "Gallery IV — Portraiture",
desc: "The human face across three centuries, from oil to silver gelatin.",
track: "Track 04 · Portraiture",
dur: "03:40",
wall: "linear-gradient(180deg,#efe9df,#ddd4c4)",
art: [
{
grad: "radial-gradient(120% 90% at 50% 30%,#d9c4a6,#6b4f37 75%)",
title: "Lady in the Conservatory",
artist: "Eluned Marris",
year: "1879",
medium: "Oil on canvas",
dims: "110 × 84 cm",
cat: "MM.1879.019",
status: "On View",
note: "Believed to depict the artist's patron, Mrs. Aldworth, amid her prized orchids.",
},
{
grad: "linear-gradient(180deg,#9a958c,#3a3733)",
title: "Self-Portrait with Pipe",
artist: "Tomas Реč (after)",
year: "1931",
medium: "Silver gelatin print",
dims: "30 × 24 cm",
cat: "MM.1931.155",
status: "On Loan",
note: "A contemplative study in grey; the only photographic work in the historic galleries.",
},
],
},
{
wing: "West Wing",
name: "Gallery V — Contemporary",
desc: "Living artists and recent acquisitions. Rotating installation.",
track: "Track 05 · Contemporary",
dur: "05:01",
wall: "linear-gradient(180deg,#f2efe8,#e2dccf)",
art: [
{
wide: true,
grad: "linear-gradient(60deg,#3f7d56,#a98140 55%,#b4493a)",
title: "Fault Lines (Triptych)",
artist: "Mara Solano",
year: "2021",
medium: "Mixed media on canvas",
dims: "210 × 120 cm",
cat: "MM.2021.477",
status: "On View",
note: "Solano's seismic abstractions map invisible pressures beneath the everyday surface.",
},
{
grad: "repeating-linear-gradient(45deg,#1c1b19 0 14px,#2a2825 14px 28px)",
title: "Quiet Machine",
artist: "Devon Achebe",
year: "2023",
medium: "Powder-coated steel",
dims: "150 × 60 × 60 cm",
cat: "MM.2023.014",
status: "On View",
note: "A standing sculpture whose ribbed surface seems to hum with stored potential.",
},
],
},
];
// ---------- Refs ----------
const $ = (s, r = document) => r.querySelector(s);
const roomsEl = $("#rooms");
const filmstrip = $("#filmstrip");
const stopCounter = $("#stopCounter");
const capWing = $("#capWing");
const capTitle = $("#capTitle");
const capDesc = $("#capDesc");
const audioTrack = $("#audioTrack");
const audioTime = $("#audioTime");
const audioBtn = $("#audioBtn");
const audioIcon = $("#audioIcon");
const autoBtn = $("#autoBtn");
const toastHost = $("#toastHost");
let current = 0;
let autoTimer = null;
let audioPlaying = false;
// ---------- Toast helper ----------
function toast(msg, key) {
const t = document.createElement("div");
t.className = "toast";
t.innerHTML = key ? `<span class="toast__k">${key}</span> ${msg}` : msg;
toastHost.appendChild(t);
setTimeout(() => t.remove(), 2800);
}
// ---------- Build rooms ----------
ROOMS.forEach((room, ri) => {
const r = document.createElement("section");
r.className = "room";
r.style.background = room.wall;
r.dataset.index = ri;
const wall = document.createElement("div");
wall.className = "wall";
room.art.forEach((a, ai) => {
const art = document.createElement("button");
art.type = "button";
art.className = "art" + (a.wide ? " art--wide" : "");
art.setAttribute("aria-label", `${a.title} by ${a.artist}, ${a.year}. Open details.`);
art.innerHTML = `
<span class="hotspot" aria-hidden="true">+</span>
<span class="art__mat">
<span class="art__canvas" style="background:${a.grad}"></span>
</span>
<span class="art__plate">
<span class="art__plate-title">${a.title}</span>
<span class="art__plate-meta">${a.artist}, ${a.year}</span>
</span>`;
art.addEventListener("click", () => openArt(ri, ai));
wall.appendChild(art);
});
r.appendChild(wall);
roomsEl.appendChild(r);
// filmstrip thumb
const thumb = document.createElement("button");
thumb.type = "button";
thumb.className = "thumb";
thumb.dataset.index = ri;
thumb.innerHTML = `
<span class="thumb__img" style="background:${room.art[0].grad}"></span>
<span class="thumb__n">Stop ${String(ri + 1).padStart(2, "0")}</span>
<span class="thumb__name">${room.name.replace(/^Gallery [IVX]+ — /, "")}</span>`;
thumb.addEventListener("click", () => goto(ri, true));
filmstrip.appendChild(thumb);
});
const roomNodes = Array.from(roomsEl.children);
const thumbNodes = Array.from(filmstrip.children);
// ---------- Navigation ----------
function render() {
const room = ROOMS[current];
roomNodes.forEach((n, i) => n.classList.toggle("is-active", i === current));
thumbNodes.forEach((n, i) => n.classList.toggle("is-active", i === current));
stopCounter.textContent = `Room ${current + 1} of ${ROOMS.length}`;
capWing.textContent = room.wing;
capTitle.textContent = room.name;
capDesc.textContent = room.desc;
audioTrack.textContent = room.track;
audioTime.textContent = (audioPlaying ? "00:14" : "00:00") + " / " + room.dur;
// keep active thumb in view
thumbNodes[current].scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
}
function goto(i, announce) {
current = (i + ROOMS.length) % ROOMS.length;
render();
if (announce) toast(ROOMS[current].name, "Now in");
}
$("#nextBtn").addEventListener("click", () => goto(current + 1, true));
$("#prevBtn").addEventListener("click", () => goto(current - 1, true));
// ---------- Auto-walk ----------
autoBtn.addEventListener("click", () => {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
autoBtn.setAttribute("aria-pressed", "false");
toast("Auto-walk paused", "Guide");
} else {
autoBtn.setAttribute("aria-pressed", "true");
toast("Walking you through the galleries…", "Guide");
autoTimer = setInterval(() => goto(current + 1, false), 4200);
}
});
// ---------- Audio guide ----------
audioBtn.addEventListener("click", () => {
audioPlaying = !audioPlaying;
audioBtn.setAttribute("aria-pressed", String(audioPlaying));
audioIcon.textContent = audioPlaying ? "❚❚" : "▶";
toast(ROOMS[current].track, audioPlaying ? "Playing" : "Paused");
render();
});
// ---------- Popover ----------
const layer = $("#popoverLayer");
const pop = $("#popover");
let lastFocus = null;
function openArt(ri, ai) {
const a = ROOMS[ri].art[ai];
lastFocus = document.activeElement;
$("#popFrame").style.background = a.grad;
const badge = $("#popBadge");
badge.textContent = a.status;
badge.classList.toggle("is-loan", a.status === "On Loan");
$("#popTitle").textContent = a.title;
$("#popArtist").textContent = `${a.artist} · ${a.year}`;
$("#popFacts").innerHTML = `
<dt>Medium</dt><dd>${a.medium}</dd>
<dt>Dimensions</dt><dd>${a.dims}</dd>
<dt>Gallery</dt><dd>${ROOMS[ri].name}</dd>`;
$("#popNote").textContent = a.note;
$("#popCat").textContent = `Accession no. ${a.cat}`;
layer.hidden = false;
$("#popClose").focus();
}
function closePop() {
layer.hidden = true;
if (lastFocus) lastFocus.focus();
}
$("#popClose").addEventListener("click", closePop);
layer.addEventListener("click", (e) => {
if (e.target === layer) closePop();
});
// ---------- Keyboard ----------
document.addEventListener("keydown", (e) => {
if (!layer.hidden) {
if (e.key === "Escape") closePop();
return;
}
if (e.key === "ArrowRight") goto(current + 1, true);
else if (e.key === "ArrowLeft") goto(current - 1, true);
});
// ---------- Init ----------
render();
toast("Welcome to the Meridian Museum", "◈");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian Museum — Virtual Tour</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=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="tour" id="tour">
<!-- Top bar -->
<header class="topbar">
<div class="brand">
<span class="brand__mark" aria-hidden="true">◈</span>
<span class="brand__name">Meridian Museum</span>
<span class="brand__sub">Virtual Gallery Walk</span>
</div>
<div class="topbar__meta">
<span class="stop-counter" id="stopCounter" aria-live="polite">Room 1 of 5</span>
<button class="btn btn--ghost" id="autoBtn" aria-pressed="false">
<span class="dot" aria-hidden="true"></span> Auto-walk
</button>
</div>
</header>
<!-- Stage -->
<main class="stage" aria-label="Gallery room view">
<div class="rooms" id="rooms"><!-- rooms injected by JS --></div>
<!-- Room caption -->
<div class="room-caption" id="roomCaption">
<span class="room-caption__wing" id="capWing">East Wing</span>
<h1 class="room-caption__title" id="capTitle">Gallery I — Origins</h1>
<p class="room-caption__desc" id="capDesc"></p>
</div>
<!-- Audio guide chip -->
<div class="audioguide" id="audioguide">
<button class="audioguide__btn" id="audioBtn" aria-pressed="false" aria-label="Toggle audio guide">
<span class="audioguide__icon" id="audioIcon" aria-hidden="true">▶</span>
</button>
<div class="audioguide__meta">
<span class="audioguide__label">Audio Guide</span>
<span class="audioguide__track" id="audioTrack">Track 01 · Origins</span>
</div>
<span class="audioguide__time" id="audioTime">00:00 / 03:12</span>
</div>
<!-- Nav arrows -->
<button class="nav nav--prev" id="prevBtn" aria-label="Previous room">‹</button>
<button class="nav nav--next" id="nextBtn" aria-label="Next room">›</button>
</main>
<!-- Filmstrip -->
<footer class="filmstrip" aria-label="Jump to room">
<div class="filmstrip__rail" id="filmstrip"><!-- thumbs injected --></div>
</footer>
</div>
<!-- Artwork info popover -->
<div class="popover-layer" id="popoverLayer" hidden>
<div class="popover" id="popover" role="dialog" aria-modal="true" aria-labelledby="popTitle">
<div class="popover__frame" id="popFrame" aria-hidden="true"></div>
<div class="popover__body">
<div class="popover__head">
<span class="badge" id="popBadge">On View</span>
<button class="popover__close" id="popClose" aria-label="Close">✕</button>
</div>
<h2 class="popover__title" id="popTitle">—</h2>
<p class="popover__artist" id="popArtist">—</p>
<dl class="popover__facts" id="popFacts"></dl>
<p class="popover__note" id="popNote">—</p>
<div class="popover__cat" id="popCat">—</div>
</div>
</div>
</div>
<div class="toast-host" id="toastHost" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Virtual Tour / Gallery Walk
A full-viewport gallery walk through the fictional Meridian Museum. The stage renders one room at a time as a softly lit wall hung with matted, gold-bordered artworks, and stepping between the five galleries — Origins, Light & Atmosphere, Abstraction, Portraiture and Contemporary — triggers a slow crossfade so each transition feels like turning a corner. A floating caption names the current wing and gallery, and a stop counter keeps your place in the sequence.
Every artwork carries a pulsing brass hotspot. Activating an artwork opens a curatorial popover with the title, artist, date, medium, dimensions, gallery and accession number, plus a short wall-text note and an On View / On Loan status badge. Navigate with the prev/next arrows or the left and right arrow keys; press Escape to dismiss the popover. A thumbnail filmstrip along the bottom lets you jump straight to any room, with the active stop highlighted and auto-scrolled into view.
Two ambient controls complete the walk: an audio-guide chip that shows the per-stop track label and running time, and an auto-walk toggle that gently advances through the galleries on a timer. A small toast() helper surfaces status messages — arriving in a room, playing a track, pausing the guide — so the experience stays guided rather than mechanical. The whole layout is keyboard-usable, AA-contrast and responsive down to roughly 360px.
Illustrative UI only — demo data; not a real museum system.