Real Estate — Virtual Tour Viewer
An editorial 360-style virtual tour viewer for the fictional Maison Laurel listing. A large stage renders each room as warm CSS-gradient photography with pulsing hotspots, drag-to-pan and keyboard panning, a play-autotour mode that gently sweeps and advances, and a fullscreen toggle. A thumbnail rail and a synced floor-plan mini-map with a you-are-here marker let visitors jump between the Living Room, Kitchen, Primary Suite, and Spa Bath, with a per-room detail panel and agent booking card.
MCP
Codice
:root {
--ivory: #f7f4ec;
--paper: #fffdf8;
--white: #ffffff;
--green: #1f3d34;
--green-d: #16302a;
--green-700: #26493e;
--green-50: #e8efea;
--brass: #b08d57;
--brass-d: #94733f;
--brass-50: #f3ead9;
--ink: #1c2a25;
--ink-2: #33433d;
--muted: #6b7a72;
--line: rgba(31, 61, 52, 0.12);
--line-2: rgba(31, 61, 52, 0.22);
--ok: #2f9e6f;
--warn: #c98a2b;
--danger: #c4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-sm: 0 1px 2px rgba(28, 42, 37, 0.06), 0 2px 6px rgba(28, 42, 37, 0.05);
--sh-md: 0 6px 18px rgba(28, 42, 37, 0.1), 0 2px 6px rgba(28, 42, 37, 0.06);
--sh-lg: 0 24px 60px rgba(22, 48, 42, 0.22), 0 8px 22px rgba(22, 48, 42, 0.14);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, sans-serif;
line-height: 1.55;
color: var(--ink);
background:
radial-gradient(1200px 600px at 85% -10%, var(--brass-50), transparent 60%),
radial-gradient(900px 500px at -5% 110%, var(--green-50), transparent 55%),
var(--ivory);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2 {
font-family: "Cormorant Garamond", Georgia, serif;
margin: 0;
letter-spacing: 0.2px;
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 38px 22px 64px;
}
/* ---------- Listing header ---------- */
.lhead {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 18px 28px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
margin-bottom: 26px;
}
.eyebrow {
display: inline-block;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--brass-d);
margin-bottom: 6px;
}
.lhead h1 {
font-size: clamp(34px, 5vw, 52px);
font-weight: 600;
line-height: 1.02;
color: var(--green-d);
}
.addr {
margin: 6px 0 0;
color: var(--muted);
font-size: 14.5px;
}
.lhead__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
background: var(--paper);
border: 1px solid var(--line);
box-shadow: var(--sh-sm);
}
.badge--price {
color: var(--white);
background: linear-gradient(160deg, var(--green-700), var(--green-d));
border-color: transparent;
}
.badge--status {
color: var(--green-700);
background: var(--green-50);
border-color: rgba(31, 61, 52, 0.18);
}
.badge--status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px rgba(47, 158, 111, 0.18);
}
/* ---------- Layout grid ---------- */
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 24px;
align-items: start;
}
/* ---------- Stage ---------- */
.stage-wrap {
min-width: 0;
}
.stage {
position: relative;
aspect-ratio: 16 / 10;
border-radius: var(--r-lg);
overflow: hidden;
background: #20312b;
box-shadow: var(--sh-lg);
cursor: grab;
outline: none;
isolation: isolate;
}
.stage:focus-visible {
box-shadow: var(--sh-lg), 0 0 0 3px var(--brass), 0 0 0 6px rgba(176, 141, 87, 0.3);
}
.stage.dragging {
cursor: grabbing;
}
/* the panoramic "photo" — wider than the stage so it can pan */
.pano {
position: absolute;
inset: 0;
width: 230%;
height: 100%;
left: -65%;
transform: translateX(0);
transition: background 0.7s ease;
will-change: transform;
}
.stage.no-anim .pano {
transition: none;
}
/* subtle vignette + light bloom over every room */
.stage::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background:
radial-gradient(120% 90% at 50% 18%, rgba(255, 244, 224, 0.16), transparent 55%),
radial-gradient(140% 120% at 50% 120%, rgba(8, 18, 14, 0.55), transparent 60%);
}
/* room "photographs" as layered gradients */
.pano[data-room="living"] {
background:
radial-gradient(46% 70% at 78% 30%, rgba(255, 236, 198, 0.55), transparent 60%),
linear-gradient(110deg, #2b4338 0%, #3c5547 38%, #6f7e63 60%, #c8b083 100%),
linear-gradient(0deg, rgba(20, 30, 24, 0.6), transparent 55%);
}
.pano[data-room="kitchen"] {
background:
radial-gradient(40% 60% at 30% 26%, rgba(255, 248, 230, 0.6), transparent 58%),
linear-gradient(105deg, #e7ddc7 0%, #cdbf9f 30%, #8c7a5a 62%, #3a4a3f 100%),
linear-gradient(0deg, rgba(28, 38, 30, 0.45), transparent 50%);
}
.pano[data-room="primary"] {
background:
radial-gradient(50% 75% at 64% 36%, rgba(255, 226, 196, 0.5), transparent 62%),
linear-gradient(115deg, #3a2e2a 0%, #5f4c41 36%, #94785f 64%, #d8c1a0 100%),
linear-gradient(0deg, rgba(24, 18, 16, 0.55), transparent 52%);
}
.pano[data-room="bath"] {
background:
radial-gradient(44% 64% at 50% 24%, rgba(244, 252, 255, 0.55), transparent 60%),
linear-gradient(120deg, #cdd9d6 0%, #a9bdba 34%, #6e8a86 66%, #2f4640 100%),
linear-gradient(0deg, rgba(18, 32, 30, 0.4), transparent 50%);
}
/* hotspots */
.hotspots {
position: absolute;
inset: 0;
z-index: 3;
}
.hotspot {
position: absolute;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
border: 0;
padding: 0;
border-radius: 50%;
background: rgba(255, 253, 248, 0.92);
box-shadow:
0 4px 14px rgba(0, 0, 0, 0.35),
0 0 0 0 rgba(176, 141, 87, 0.55);
cursor: pointer;
color: var(--green-d);
display: grid;
place-items: center;
animation: hsPulse 2.6s ease-out infinite;
transition: transform 0.16s ease, background 0.16s ease;
}
.hotspot::before {
content: "";
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--brass);
}
.hotspot:hover,
.hotspot:focus-visible {
background: var(--white);
transform: translate(-50%, -50%) scale(1.18);
outline: none;
}
.hotspot .tip {
position: absolute;
bottom: 130%;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 11px;
font-weight: 600;
color: var(--white);
background: rgba(22, 48, 42, 0.92);
padding: 4px 9px;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.hotspot:hover .tip,
.hotspot:focus-visible .tip {
opacity: 1;
}
@keyframes hsPulse {
0% {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(176, 141, 87, 0.5);
}
70% {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 14px rgba(176, 141, 87, 0);
}
100% {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(176, 141, 87, 0);
}
}
/* top overlay */
.stage__top {
position: absolute;
top: 14px;
left: 16px;
z-index: 4;
display: flex;
align-items: center;
gap: 10px;
}
.room-tag {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--white);
background: rgba(22, 48, 42, 0.6);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 6px 13px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.18);
}
.live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(47, 158, 111, 0.7);
animation: live 1.8s ease-out infinite;
}
@keyframes live {
0% { box-shadow: 0 0 0 0 rgba(47, 158, 111, 0.6); }
70% { box-shadow: 0 0 0 9px rgba(47, 158, 111, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 158, 111, 0); }
}
/* drag hint */
.drag-hint {
position: absolute;
z-index: 4;
left: 50%;
bottom: 78px;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 500;
color: var(--white);
background: rgba(22, 48, 42, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 7px 13px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.16);
transition: opacity 0.4s ease;
}
.drag-hint.hide {
opacity: 0;
pointer-events: none;
}
/* controls */
.controls {
position: absolute;
z-index: 5;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border-radius: 999px;
background: rgba(20, 36, 31, 0.66);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.32);
}
.controls__sep {
width: 1px;
height: 22px;
background: rgba(255, 255, 255, 0.2);
margin: 0 2px;
}
.ctrl {
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
border-radius: 999px;
padding: 9px;
color: var(--white);
background: transparent;
cursor: pointer;
transition: background 0.16s ease, transform 0.12s ease;
}
.ctrl:hover {
background: rgba(255, 255, 255, 0.14);
}
.ctrl:active {
transform: scale(0.94);
}
.ctrl:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 2px;
}
.ctrl--play {
padding: 9px 15px;
background: linear-gradient(160deg, var(--brass), var(--brass-d));
font-weight: 600;
font-size: 13px;
}
.ctrl--play:hover {
background: linear-gradient(160deg, #bd9963, var(--brass-d));
}
.ctrl__label {
letter-spacing: 0.01em;
}
.ic-pause,
.ic-collapse {
display: none;
}
.stage.is-playing .ctrl--play .ic-play {
display: none;
}
.stage.is-playing .ctrl--play .ic-pause {
display: block;
}
.stage.is-fs .ic-expand {
display: none;
}
.stage.is-fs .ic-collapse {
display: block;
}
/* fullscreen tweaks */
.stage:fullscreen {
aspect-ratio: auto;
width: 100%;
height: 100%;
border-radius: 0;
}
/* ---------- Thumbnail rail ---------- */
.rail {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 14px;
}
.thumb {
position: relative;
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
padding: 0;
cursor: pointer;
background: var(--paper);
aspect-ratio: 16 / 11;
box-shadow: var(--sh-sm);
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.thumb:hover {
transform: translateY(-2px);
box-shadow: var(--sh-md);
}
.thumb:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 2px;
}
.thumb__img {
position: absolute;
inset: 0;
}
.thumb__img[data-room="living"] {
background: linear-gradient(120deg, #2b4338, #6f7e63 60%, #c8b083);
}
.thumb__img[data-room="kitchen"] {
background: linear-gradient(120deg, #e7ddc7, #8c7a5a 62%, #3a4a3f);
}
.thumb__img[data-room="primary"] {
background: linear-gradient(120deg, #3a2e2a, #94785f 64%, #d8c1a0);
}
.thumb__img[data-room="bath"] {
background: linear-gradient(120deg, #cdd9d6, #6e8a86 66%, #2f4640);
}
.thumb__label {
position: absolute;
left: 9px;
bottom: 8px;
z-index: 2;
font-size: 11.5px;
font-weight: 600;
color: var(--white);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
.thumb::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(0deg, rgba(10, 20, 16, 0.5), transparent 55%);
}
.thumb.is-active {
border-color: var(--brass);
box-shadow: 0 0 0 2px var(--brass), var(--sh-md);
}
.thumb.is-active::before {
content: "Viewing";
position: absolute;
top: 7px;
right: 7px;
z-index: 3;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--green-d);
background: rgba(255, 253, 248, 0.95);
padding: 2px 7px;
border-radius: 999px;
}
/* ---------- Side panel ---------- */
.side {
display: grid;
gap: 16px;
position: sticky;
top: 18px;
}
.card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--sh-sm);
}
.card__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.card__head h2 {
font-size: 23px;
font-weight: 600;
color: var(--green-d);
}
.muted {
color: var(--muted);
font-size: 13px;
}
.pill {
font-size: 12px;
font-weight: 600;
color: var(--brass-d);
background: var(--brass-50);
padding: 3px 10px;
border-radius: 999px;
}
/* floor plan */
.floorplan {
border-radius: var(--r-md);
background:
repeating-linear-gradient(45deg, rgba(31, 61, 52, 0.04) 0 8px, transparent 8px 16px),
var(--green-50);
padding: 10px;
border: 1px solid var(--line);
}
.fp-svg {
display: block;
width: 100%;
height: auto;
}
.fp-wall {
fill: none;
stroke: var(--green-700);
stroke-width: 2.5;
}
.fp-room {
fill: rgba(255, 253, 248, 0.7);
stroke: var(--line-2);
stroke-width: 1.2;
cursor: pointer;
transition: fill 0.2s ease;
}
.fp-room:hover {
fill: var(--brass-50);
}
.fp-room.is-active {
fill: rgba(176, 141, 87, 0.32);
stroke: var(--brass);
}
.fp-label {
font-family: "Inter", sans-serif;
font-size: 8px;
font-weight: 600;
fill: var(--ink-2);
text-anchor: middle;
pointer-events: none;
}
.fp-here {
fill: var(--brass);
stroke: var(--white);
stroke-width: 2;
transition: cx 0.5s cubic-bezier(0.4, 0, 0.2, 1), cy 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* detail card */
.detail-desc {
margin: 0 0 14px;
font-size: 13.5px;
color: var(--ink-2);
}
.feat {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 9px;
}
.feat li {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
color: var(--ink-2);
}
.feat li::before {
content: "";
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fffdf8' stroke-width='3'%3E%3Cpath d='M5 13l4 4L19 7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")
center / 11px no-repeat,
var(--green-700);
}
/* agent card */
.agent {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.agent__avatar {
flex: none;
width: 46px;
height: 46px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 15px;
color: var(--white);
background: linear-gradient(160deg, var(--green-700), var(--green-d));
}
.agent__name {
margin: 0;
font-weight: 600;
font-size: 14.5px;
color: var(--ink);
}
.agent__role {
margin: 1px 0 0;
font-size: 12px;
color: var(--muted);
}
.btn-primary {
width: 100%;
border: 0;
cursor: pointer;
border-radius: 999px;
padding: 12px 16px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
color: var(--white);
background: linear-gradient(160deg, var(--green-700), var(--green-d));
box-shadow: var(--sh-sm);
transition: transform 0.12s ease, box-shadow 0.16s ease, filter 0.16s ease;
}
.btn-primary:hover {
filter: brightness(1.06);
box-shadow: var(--sh-md);
}
.btn-primary:active {
transform: scale(0.985);
}
.btn-primary:focus-visible {
outline: 2px solid var(--brass);
outline-offset: 2px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
z-index: 50;
background: var(--green-d);
color: var(--paper);
font-size: 13.5px;
font-weight: 500;
padding: 12px 18px;
border-radius: 999px;
box-shadow: var(--sh-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.24s ease, transform 0.24s ease;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
.side {
position: static;
}
}
@media (max-width: 520px) {
.shell {
padding: 26px 14px 48px;
}
.lhead {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.stage {
aspect-ratio: 4 / 3;
border-radius: var(--r-md);
}
.rail {
grid-template-columns: repeat(2, 1fr);
}
.controls {
bottom: 12px;
gap: 5px;
padding: 6px 7px;
}
.ctrl--play .ctrl__label {
display: none;
}
.ctrl--play {
padding: 9px;
}
.drag-hint {
bottom: 66px;
font-size: 11px;
}
.card__head h2 {
font-size: 21px;
}
}
@media (prefers-reduced-motion: reduce) {
.pano,
.fp-here {
transition: none;
}
.hotspot,
.live-dot {
animation: none;
}
}(function () {
"use strict";
/* ---------- Room data ---------- */
var ROOMS = [
{
id: "living",
name: "Living Room",
dim: "21 × 18 ft",
desc: "Sun-drenched great room with double-height ceilings, white-oak floors, and a limestone fireplace anchoring the lounge.",
feat: ["Gas fireplace", "12 ft ceilings", "South-facing windows"],
// floor-plan marker position (svg viewBox coords)
here: { cx: 56, cy: 49 },
// hotspots: x/y are % across the panoramic image
hotspots: [
{ x: 30, y: 58, label: "Step into Kitchen", to: "kitchen" },
{ x: 72, y: 50, label: "Fireplace detail", info: "Honed limestone surround." }
]
},
{
id: "kitchen",
name: "Chef's Kitchen",
dim: "16 × 14 ft",
desc: "Bespoke walnut cabinetry, a marble waterfall island, and integrated appliances open to the dining terrace.",
feat: ["Marble island", "Walk-in pantry", "Pro range"],
here: { cx: 150, cy: 38 },
hotspots: [
{ x: 24, y: 56, label: "Back to Living", to: "living" },
{ x: 70, y: 60, label: "To Primary Suite", to: "primary" }
]
},
{
id: "primary",
name: "Primary Suite",
dim: "19 × 16 ft",
desc: "A private retreat with a sitting nook, custom dressing room, and a spa bath behind pocket doors.",
feat: ["Walk-in closet", "Private balcony", "Spa ensuite"],
here: { cx: 150, cy: 107 },
hotspots: [
{ x: 32, y: 54, label: "Back to Kitchen", to: "kitchen" },
{ x: 74, y: 64, label: "Into Bath", to: "bath" }
]
},
{
id: "bath",
name: "Spa Bath",
dim: "12 × 10 ft",
desc: "Floor-to-ceiling porcelain, a freestanding soaking tub, and a glass rain shower with brass fixtures.",
feat: ["Soaking tub", "Heated floors", "Double vanity"],
here: { cx: 56, cy: 118 },
hotspots: [{ x: 36, y: 56, label: "Back to Primary", to: "primary" }]
}
];
var byId = {};
ROOMS.forEach(function (r) {
byId[r.id] = r;
});
/* ---------- Refs ---------- */
var stage = document.getElementById("stage");
var pano = document.getElementById("pano");
var hotspotsEl = document.getElementById("hotspots");
var rail = document.getElementById("rail");
var roomTag = document.getElementById("roomTag");
var mapLabel = document.getElementById("mapLabel");
var dragHint = document.getElementById("dragHint");
var fpHere = document.getElementById("fpHere");
var detailName = document.getElementById("detailName");
var detailDim = document.getElementById("detailDim");
var detailDesc = document.getElementById("detailDesc");
var detailFeat = document.getElementById("detailFeat");
var toastEl = document.getElementById("toast");
var prevBtn = document.getElementById("prevBtn");
var nextBtn = document.getElementById("nextBtn");
var autoBtn = document.getElementById("autoBtn");
var fsBtn = document.getElementById("fsBtn");
var bookBtn = document.getElementById("bookBtn");
var current = 0;
var panX = 0; // current pan offset in px
var maxPan = 0; // how far we can pan each side
var autoTimer = null;
var autoPanRAF = null;
/* ---------- Toast helper ---------- */
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
/* ---------- Build thumbnail rail ---------- */
ROOMS.forEach(function (r, i) {
var btn = document.createElement("button");
btn.className = "thumb";
btn.type = "button";
btn.setAttribute("role", "tab");
btn.dataset.index = String(i);
btn.setAttribute("aria-label", "View " + r.name);
btn.innerHTML =
'<span class="thumb__img" data-room="' +
r.id +
'"></span><span class="thumb__label">' +
r.name +
"</span>";
btn.addEventListener("click", function () {
goTo(i, true);
});
rail.appendChild(btn);
});
var thumbs = Array.prototype.slice.call(rail.children);
/* ---------- Floor-plan rooms ---------- */
var fpRooms = Array.prototype.slice.call(
document.querySelectorAll(".fp-room")
);
fpRooms.forEach(function (rect) {
rect.addEventListener("click", function () {
var idx = ROOMS.findIndex(function (r) {
return r.id === rect.dataset.room;
});
if (idx >= 0) goTo(idx, true);
});
});
/* ---------- Pan helpers ---------- */
function computeMaxPan() {
// pano is 230% wide; overflow each side = (230-100)/2 = 65% of stage width
maxPan = stage.clientWidth * 0.6;
}
function applyPan() {
pano.style.transform = "translateX(" + panX + "px)";
}
function clampPan(v) {
if (v > maxPan) return maxPan;
if (v < -maxPan) return -maxPan;
return v;
}
/* ---------- Render hotspots for current room ---------- */
function renderHotspots(room) {
hotspotsEl.innerHTML = "";
room.hotspots.forEach(function (h) {
var b = document.createElement("button");
b.className = "hotspot";
b.type = "button";
b.style.left = h.x + "%";
b.style.top = h.y + "%";
b.setAttribute("aria-label", h.label);
b.innerHTML = '<span class="tip">' + h.label + "</span>";
b.addEventListener("click", function (e) {
e.stopPropagation();
if (h.to && byId[h.to]) {
var idx = ROOMS.findIndex(function (r) {
return r.id === h.to;
});
goTo(idx, true);
} else if (h.info) {
toast(h.info);
}
});
hotspotsEl.appendChild(b);
});
}
/* ---------- Go to a room ---------- */
function goTo(index, userInitiated) {
if (index < 0) index = ROOMS.length - 1;
if (index >= ROOMS.length) index = 0;
current = index;
var room = ROOMS[index];
pano.dataset.room = room.id;
roomTag.textContent = room.name;
mapLabel.textContent = room.name;
detailName.textContent = room.name;
detailDim.textContent = room.dim;
detailDesc.textContent = room.desc;
detailFeat.innerHTML = "";
room.feat.forEach(function (f) {
var li = document.createElement("li");
li.textContent = f;
detailFeat.appendChild(li);
});
// reset pan to centre on room change
panX = 0;
applyPan();
renderHotspots(room);
// active states
thumbs.forEach(function (t, i) {
t.classList.toggle("is-active", i === index);
t.setAttribute("aria-selected", i === index ? "true" : "false");
});
fpRooms.forEach(function (rect) {
rect.classList.toggle("is-active", rect.dataset.room === room.id);
});
// move you-are-here marker
fpHere.setAttribute("cx", room.here.cx);
fpHere.setAttribute("cy", room.here.cy);
if (userInitiated) toast("Now viewing · " + room.name);
}
/* ---------- Drag to pan ---------- */
var dragging = false;
var startX = 0;
var startPan = 0;
var hintDismissed = false;
function dismissHint() {
if (hintDismissed) return;
hintDismissed = true;
dragHint.classList.add("hide");
}
function pointerDown(e) {
if (e.target.closest(".hotspot")) return;
dragging = true;
startX = e.clientX;
startPan = panX;
stage.classList.add("dragging", "no-anim");
stage.setPointerCapture && stage.setPointerCapture(e.pointerId);
dismissHint();
stopAuto();
}
function pointerMove(e) {
if (!dragging) return;
var dx = e.clientX - startX;
panX = clampPan(startPan + dx);
applyPan();
}
function pointerUp() {
if (!dragging) return;
dragging = false;
stage.classList.remove("dragging", "no-anim");
}
stage.addEventListener("pointerdown", pointerDown);
window.addEventListener("pointermove", pointerMove);
window.addEventListener("pointerup", pointerUp);
window.addEventListener("pointercancel", pointerUp);
/* ---------- Keyboard pan + nav ---------- */
stage.addEventListener("keydown", function (e) {
if (e.key === "ArrowLeft") {
e.preventDefault();
panX = clampPan(panX + 60);
applyPan();
dismissHint();
} else if (e.key === "ArrowRight") {
e.preventDefault();
panX = clampPan(panX - 60);
applyPan();
dismissHint();
} else if (e.key === "[" || e.key === "PageUp") {
goTo(current - 1, true);
} else if (e.key === "]" || e.key === "PageDown") {
goTo(current + 1, true);
}
});
/* ---------- Prev / next ---------- */
prevBtn.addEventListener("click", function () {
goTo(current - 1, true);
});
nextBtn.addEventListener("click", function () {
goTo(current + 1, true);
});
/* ---------- Auto tour ---------- */
function isPlaying() {
return stage.classList.contains("is-playing");
}
function startAuto() {
stage.classList.add("is-playing");
autoBtn.setAttribute("aria-pressed", "true");
autoBtn.setAttribute("aria-label", "Pause auto tour");
toast("Auto tour started");
runAutoStep();
}
function runAutoStep() {
// gently sweep the pan, then advance to next room
var dir = 1;
var t0 = null;
cancelAnimationFrame(autoPanRAF);
function sweep(ts) {
if (!isPlaying()) return;
if (t0 === null) t0 = ts;
// sweep across over ~3.4s using a sine ease
var p = (ts - t0) / 3400;
var eased = Math.sin(Math.min(p, 1) * Math.PI - Math.PI / 2);
panX = eased * maxPan * dir;
applyPan();
if (p < 1) {
autoPanRAF = requestAnimationFrame(sweep);
}
}
autoPanRAF = requestAnimationFrame(sweep);
autoTimer = setTimeout(function () {
if (!isPlaying()) return;
goTo(current + 1, false);
runAutoStep();
}, 3800);
}
function stopAuto() {
if (!isPlaying()) return;
stage.classList.remove("is-playing");
autoBtn.setAttribute("aria-pressed", "false");
autoBtn.setAttribute("aria-label", "Play auto tour");
clearTimeout(autoTimer);
cancelAnimationFrame(autoPanRAF);
}
autoBtn.addEventListener("click", function () {
if (isPlaying()) {
stopAuto();
toast("Auto tour paused");
} else {
dismissHint();
startAuto();
}
});
/* ---------- Fullscreen ---------- */
fsBtn.addEventListener("click", function () {
if (!document.fullscreenElement) {
if (stage.requestFullscreen) {
stage.requestFullscreen().catch(function () {
toast("Fullscreen unavailable here");
});
} else {
toast("Fullscreen unavailable here");
}
} else {
document.exitFullscreen && document.exitFullscreen();
}
});
document.addEventListener("fullscreenchange", function () {
var on = document.fullscreenElement === stage;
stage.classList.toggle("is-fs", on);
computeMaxPan();
});
/* ---------- Book showing ---------- */
bookBtn.addEventListener("click", function () {
toast("Showing request sent to Delphine — she'll confirm shortly.");
});
/* ---------- Resize ---------- */
window.addEventListener("resize", function () {
computeMaxPan();
panX = clampPan(panX);
applyPan();
});
/* ---------- Init ---------- */
computeMaxPan();
goTo(0, false);
// auto-dismiss the drag hint after a few seconds
setTimeout(dismissHint, 5200);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Maison Laurel — 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;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<!-- Listing header -->
<header class="lhead">
<div class="lhead__lead">
<span class="eyebrow">Virtual Tour · Live walkthrough</span>
<h1>Maison Laurel</h1>
<p class="addr">418 Juniper Hollow Lane, Westhaven, OR 97402</p>
</div>
<div class="lhead__meta">
<span class="badge badge--price">$1,485,000</span>
<span class="badge">4 Beds</span>
<span class="badge">3 Baths</span>
<span class="badge">3,240 sqft</span>
<span class="badge badge--status">Active</span>
</div>
</header>
<div class="grid">
<!-- Stage -->
<section class="stage-wrap" aria-label="360 degree tour viewer">
<div
class="stage"
id="stage"
tabindex="0"
role="application"
aria-label="Panoramic room view. Drag to look around. Use arrow keys to pan."
>
<div class="pano" id="pano" aria-hidden="true"></div>
<!-- hotspots live here -->
<div class="hotspots" id="hotspots"></div>
<!-- top overlay -->
<div class="stage__top">
<span class="room-tag" id="roomTag">Living Room</span>
<span class="live-dot" aria-hidden="true"></span>
</div>
<!-- drag hint -->
<div class="drag-hint" id="dragHint" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M8 12H4m0 0 3-3M4 12l3 3M16 12h4m0 0-3-3m3 3-3 3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Drag to look around
</div>
<!-- controls -->
<div class="controls" role="group" aria-label="Tour controls">
<button class="ctrl" id="prevBtn" type="button" aria-label="Previous room">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M15 6l-6 6 6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="ctrl ctrl--play" id="autoBtn" type="button" aria-pressed="false" aria-label="Play auto tour">
<svg class="ic-play" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="ic-pause" viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 5h3v14H7zM14 5h3v14h-3z"/></svg>
<span class="ctrl__label">Auto tour</span>
</button>
<button class="ctrl" id="nextBtn" type="button" aria-label="Next room">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="controls__sep" aria-hidden="true"></span>
<button class="ctrl" id="fsBtn" type="button" aria-label="Toggle fullscreen">
<svg class="ic-expand" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg class="ic-collapse" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<!-- thumbnail rail -->
<div class="rail" id="rail" role="tablist" aria-label="Jump to room">
<!-- thumbs injected -->
</div>
</section>
<!-- Side panel -->
<aside class="side">
<div class="card map-card">
<div class="card__head">
<h2>Floor plan</h2>
<span class="muted" id="mapLabel">Living Room</span>
</div>
<div class="floorplan" aria-hidden="true">
<svg viewBox="0 0 200 150" class="fp-svg" preserveAspectRatio="xMidYMid meet">
<rect class="fp-wall" x="4" y="4" width="192" height="142" rx="4"/>
<!-- rooms -->
<g id="fpRooms">
<rect class="fp-room" data-room="living" x="8" y="8" width="96" height="82"/>
<rect class="fp-room" data-room="kitchen" x="108" y="8" width="84" height="60"/>
<rect class="fp-room" data-room="primary" x="108" y="72" width="84" height="70"/>
<rect class="fp-room" data-room="bath" x="8" y="94" width="96" height="48"/>
</g>
<!-- labels -->
<text class="fp-label" x="56" y="52">Living</text>
<text class="fp-label" x="150" y="40">Kitchen</text>
<text class="fp-label" x="150" y="110">Primary</text>
<text class="fp-label" x="56" y="121">Bath</text>
<!-- you-are-here marker -->
<circle class="fp-here" id="fpHere" cx="56" cy="49" r="5"/>
</svg>
</div>
</div>
<div class="card detail-card">
<div class="card__head">
<h2 id="detailName">Living Room</h2>
<span class="pill" id="detailDim">21 × 18 ft</span>
</div>
<p class="detail-desc" id="detailDesc"></p>
<ul class="feat" id="detailFeat"></ul>
</div>
<div class="card agent-card">
<div class="agent">
<span class="agent__avatar" aria-hidden="true">DM</span>
<div>
<p class="agent__name">Delphine Moreau</p>
<p class="agent__role">Listing Agent · Laurel & Vane</p>
</div>
</div>
<button class="btn-primary" id="bookBtn" type="button">Book a private showing</button>
</div>
</aside>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Virtual Tour Viewer
A premium virtual-tour viewer for the fictional Maison Laurel listing. The stage presents each room as a richly layered CSS-gradient “photograph” with a soft vignette and warm light bloom, overlaid with pulsing brass hotspots. Drag anywhere on the stage to pan across the panorama, or focus it and use the arrow keys; a glassy control bar offers previous/next room, an auto-tour toggle, and a fullscreen button. A live room tag and a fading drag hint keep the interaction discoverable.
Below the stage, a thumbnail rail of all four rooms — Living Room, Kitchen, Primary Suite, and Spa Bath — lets visitors jump instantly, with the active room clearly marked. A floor-plan mini-map mirrors the same rooms: clicking a room on the plan navigates the tour, and a you-are-here marker animates to the current space. The right-hand panel updates with each room’s dimensions, a short editorial description, and key features, alongside an agent card for booking a private showing.
The auto-tour mode does more than skip slides: it eases a sine-curve pan sweep across the current room before advancing, then continues, while a small toast() helper surfaces status messages for every action. Hotspots can either teleport you to an adjacent room or reveal an inline detail, making the walkthrough feel guided rather than mechanical.
Illustrative UI only — sample listings and data are fictional; not a real real-estate service.