Travel — Map + Content Split
A showcase-grade Airbnb-style split layout for the fictional coast of Cala Verdana — a scrollable column of point-of-interest cards on the left and a sticky, fly-to SVG map on the right. Hovering or selecting a card highlights its numbered pin and the map pans and zooms to centre it; selecting a pin scrolls the matching card into view and floats a preview popover. A category chip bar, price slider, search field and saved filter narrow both panes in sync, with a heart-driven trip planner and a mobile list-or-map toggle.
MCP
Code
:root {
--bg: #fbf7f1;
--surface: #ffffff;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-d: #176b6b;
--coral: #e8623f;
--coral-d: #cf4a29;
--sand: #e7d8c3;
--sand-soft: #f3ebde;
--gold: #d9a441;
--line: rgba(36, 31, 26, 0.12);
--line-2: rgba(36, 31, 26, 0.2);
--shadow-sm: 0 1px 2px rgba(36, 31, 26, 0.08);
--shadow-md: 0 10px 30px rgba(36, 31, 26, 0.14);
--r-sm: 10px;
--r-md: 16px;
--r-lg: 22px;
--topbar-h: 64px;
--filter-h: 60px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
font-family: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
display: flex;
flex-direction: column;
}
h1,
h2,
h3 {
font-family: "Fraunces", Georgia, "Times New Roman", serif;
font-weight: 600;
line-height: 1.15;
margin: 0;
}
button {
font: inherit;
cursor: pointer;
}
a {
color: inherit;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
left: -999px;
top: 8px;
z-index: 60;
background: var(--ink);
color: var(--bg);
padding: 8px 14px;
border-radius: var(--r-sm);
text-decoration: none;
}
.skip-link:focus {
left: 12px;
}
:focus-visible {
outline: 3px solid var(--teal);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Topbar ---------- */
.topbar {
height: var(--topbar-h);
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
padding: 0 clamp(14px, 3vw, 28px);
background: var(--surface);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 40;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.brand__mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(150deg, var(--teal), var(--teal-d));
color: var(--coral);
box-shadow: var(--shadow-sm);
}
.brand__text {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.brand__text strong {
font-family: "Fraunces", serif;
font-size: 1.05rem;
}
.brand__text span {
font-size: 0.72rem;
color: var(--muted);
letter-spacing: 0.02em;
}
.searchbar {
flex: 1 1 auto;
max-width: 460px;
display: flex;
align-items: center;
gap: 8px;
background: var(--sand-soft);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 14px;
height: 42px;
}
.searchbar:focus-within {
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(31, 138, 138, 0.18);
}
.searchbar__icon {
font-size: 1.15rem;
color: var(--muted);
}
.searchbar input {
border: 0;
background: transparent;
outline: none;
width: 100%;
font-size: 0.95rem;
color: var(--ink);
}
.view-toggle {
flex: 0 0 auto;
display: none;
align-items: center;
gap: 6px;
background: var(--ink);
color: var(--bg);
border: 0;
border-radius: 999px;
padding: 9px 16px;
font-weight: 600;
font-size: 0.9rem;
}
.view-toggle [data-when="map"] {
display: none;
}
/* ---------- Filter bar ---------- */
.filterbar {
flex: 0 0 auto;
height: var(--filter-h);
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 0 clamp(14px, 3vw, 28px);
background: var(--surface);
border-bottom: 1px solid var(--line);
position: sticky;
top: var(--topbar-h);
z-index: 35;
overflow-x: auto;
}
.chips {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.chip {
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink);
border-radius: 999px;
padding: 7px 14px;
font-size: 0.88rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover {
border-color: var(--teal);
}
.chip.is-active {
background: var(--teal);
border-color: var(--teal);
color: #fff;
}
.filterbar__right {
display: flex;
align-items: center;
gap: 18px;
flex: 0 0 auto;
}
.price {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.82rem;
color: var(--muted);
white-space: nowrap;
}
.price strong {
color: var(--coral-d);
font-family: "Work Sans", sans-serif;
}
.price input[type="range"] {
width: 110px;
accent-color: var(--coral);
}
.saved-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink);
border-radius: 999px;
padding: 7px 14px;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
}
.saved-toggle.is-active {
background: var(--coral);
border-color: var(--coral);
color: #fff;
}
.saved-toggle__count {
background: rgba(36, 31, 26, 0.12);
border-radius: 999px;
padding: 0 7px;
font-size: 0.78rem;
min-width: 20px;
text-align: center;
}
.saved-toggle.is-active .saved-toggle__count {
background: rgba(255, 255, 255, 0.28);
}
/* ---------- Split layout ---------- */
.split {
flex: 1 1 auto;
display: grid;
grid-template-columns: minmax(360px, 1fr) 1.2fr;
min-height: 0;
}
.pane {
min-height: 0;
}
.pane--list {
overflow-y: auto;
padding: 22px clamp(14px, 2.4vw, 26px) 60px;
scroll-behavior: smooth;
}
.pane--map {
position: sticky;
top: calc(var(--topbar-h) + var(--filter-h));
height: calc(100vh - var(--topbar-h) - var(--filter-h));
padding: 14px;
}
.list-head h1 {
font-size: clamp(1.5rem, 1rem + 1.6vw, 2rem);
}
.list-head h1 span {
color: var(--teal);
}
.list-head p {
margin: 4px 0 18px;
color: var(--muted);
font-size: 0.9rem;
}
/* ---------- Cards ---------- */
.cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 16px;
}
.card {
display: grid;
grid-template-columns: 132px 1fr;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
cursor: pointer;
scroll-margin-top: 16px;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.card.is-active {
border-color: var(--coral);
box-shadow: 0 0 0 2px var(--coral), var(--shadow-md);
}
.card__media {
position: relative;
min-height: 132px;
}
.card__num {
position: absolute;
top: 8px;
left: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--ink);
color: var(--bg);
font-size: 0.82rem;
font-weight: 700;
box-shadow: var(--shadow-sm);
}
.card__cat {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(36, 31, 26, 0.78);
color: #fff;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 999px;
}
.card__body {
padding: 12px 14px 13px;
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.card__top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.card__title {
font-size: 1.02rem;
font-family: "Fraunces", serif;
font-weight: 600;
}
.card__area {
font-size: 0.78rem;
color: var(--muted);
}
.card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 12px;
font-size: 0.82rem;
color: var(--muted);
margin-top: 2px;
}
.rating {
color: var(--gold);
font-weight: 600;
}
.rating span {
color: var(--muted);
font-weight: 400;
}
.price-tier strong {
color: var(--teal-d);
}
.price-tier em {
color: var(--line-2);
font-style: normal;
}
.card__price {
font-weight: 700;
font-size: 0.95rem;
color: var(--ink);
white-space: nowrap;
}
.card__price span {
font-weight: 400;
font-size: 0.74rem;
color: var(--muted);
}
.best-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--sand-soft);
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px 8px;
font-size: 0.72rem;
color: var(--ink);
margin-top: 4px;
width: max-content;
}
.heart {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 50%;
border: 0;
background: rgba(255, 255, 255, 0.92);
color: var(--muted);
font-size: 1rem;
line-height: 1;
box-shadow: var(--shadow-sm);
transition: transform 0.15s, color 0.15s;
}
.heart:hover {
transform: scale(1.12);
color: var(--coral);
}
.heart.is-saved {
color: var(--coral);
}
.empty {
margin-top: 30px;
text-align: center;
color: var(--muted);
font-size: 0.95rem;
}
/* ---------- Map ---------- */
.map {
position: relative;
width: 100%;
height: 100%;
border-radius: var(--r-lg);
overflow: hidden;
border: 1px solid var(--line);
box-shadow: var(--shadow-md);
background: #cfe9e8;
}
.map__viewport {
position: absolute;
inset: 0;
}
.map__canvas {
width: 100%;
height: 100%;
display: block;
transform-origin: 0 0;
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
/* Pins (SVG) */
.pin {
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.pin .pin__shape {
fill: var(--surface);
stroke: var(--ink);
stroke-width: 2;
transition: fill 0.2s, stroke 0.2s;
}
.pin .pin__num {
font-family: "Work Sans", sans-serif;
font-size: 16px;
font-weight: 700;
fill: var(--ink);
text-anchor: middle;
}
.pin:hover .pin__shape,
.pin.is-active .pin__shape {
fill: var(--coral);
stroke: var(--coral-d);
}
.pin:hover .pin__num,
.pin.is-active .pin__num {
fill: #fff;
}
.pin.is-active {
transform: scale(1.28);
}
.pin.is-dim {
opacity: 0.32;
}
.pin:focus-visible {
outline: none;
}
.pin:focus-visible .pin__shape {
stroke: var(--teal);
stroke-width: 4;
}
.map__controls {
position: absolute;
right: 12px;
bottom: 12px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 5;
}
.map__controls button {
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
font-size: 1.2rem;
font-weight: 600;
display: grid;
place-items: center;
box-shadow: var(--shadow-sm);
}
.map__controls button:hover {
background: var(--sand-soft);
}
.map__badge {
position: absolute;
left: 12px;
bottom: 12px;
background: rgba(36, 31, 26, 0.72);
color: #fff;
font-size: 0.72rem;
letter-spacing: 0.04em;
padding: 5px 10px;
border-radius: 999px;
z-index: 5;
}
/* Map popover card */
.map__pop {
position: absolute;
z-index: 8;
width: 220px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
overflow: hidden;
transform: translate(-50%, calc(-100% - 18px));
pointer-events: none;
animation: pop 0.16s ease;
}
@keyframes pop {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 8px));
}
}
.map__pop .pop__media {
height: 84px;
}
.map__pop .pop__body {
padding: 8px 11px 10px;
}
.map__pop .pop__title {
font-family: "Fraunces", serif;
font-weight: 600;
font-size: 0.95rem;
}
.map__pop .pop__meta {
font-size: 0.78rem;
color: var(--muted);
display: flex;
justify-content: space-between;
margin-top: 2px;
}
.map__pop .pop__meta strong {
color: var(--ink);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 24px);
background: var(--ink);
color: var(--bg);
padding: 11px 18px;
border-radius: 999px;
font-size: 0.88rem;
font-weight: 500;
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 80;
}
.toast.is-shown {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
.view-toggle {
display: inline-flex;
}
.pane--map {
position: static;
height: calc(100vh - var(--topbar-h) - var(--filter-h));
}
/* List view (default): show list, hide map */
.split[data-view="list"] .pane--map {
display: none;
}
/* Map view: show map, hide list */
.split[data-view="map"] .pane--list {
display: none;
}
.split[data-view="map"] .view-toggle [data-when="list"] {
display: none;
}
.split[data-view="map"] .view-toggle [data-when="map"] {
display: inline;
}
}
@media (max-width: 560px) {
.searchbar {
max-width: none;
}
.filterbar {
flex-wrap: nowrap;
}
}
@media (max-width: 420px) {
.brand__text {
display: none;
}
.card {
grid-template-columns: 104px 1fr;
}
.card__media {
min-height: 104px;
}
.filterbar__right .price {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
.map__canvas {
transition: none;
}
}(function () {
"use strict";
/* ----------------------------------------------------------------
* Data — fictional points of interest around "Cala Verdana".
* x/y are coordinates inside the 1000x700 SVG viewBox.
* ---------------------------------------------------------------- */
var GRADIENTS = {
stay: "linear-gradient(150deg,#f3b07a,#e8623f)",
eat: "linear-gradient(150deg,#f6cd6b,#d9a441)",
beach: "linear-gradient(150deg,#8fd6d8,#1f8a8a)",
trail: "linear-gradient(150deg,#bcd98c,#6f9a4a)",
sight: "linear-gradient(150deg,#caa6e0,#8a5fb0)",
};
var CAT_LABEL = {
stay: "Stay",
eat: "Eat",
beach: "Beach",
trail: "Trail",
sight: "Sight",
};
var PLACES = [
{ id: "p1", name: "Casa Marejada", cat: "stay", area: "Old Harbour", rating: 4.9, reviews: 214, price: 4, nightly: 186, best: "May–Sep", x: 372, y: 612 },
{ id: "p2", name: "The Salt Table", cat: "eat", area: "Fishermen's Row", rating: 4.7, reviews: 388, price: 3, nightly: 0, best: "Dinner", x: 430, y: 540 },
{ id: "p3", name: "Verdana Cove", cat: "beach", area: "South Shore", rating: 4.8, reviews: 902, price: 1, nightly: 0, best: "Morning", x: 352, y: 470 },
{ id: "p4", name: "Mirador del Pino", cat: "sight", area: "Pine Ridge", rating: 4.6, reviews: 156, price: 1, nightly: 0, best: "Golden hour", x: 700, y: 250 },
{ id: "p5", name: "Sendero Azul", cat: "trail", area: "Pine Ridge", rating: 4.5, reviews: 73, price: 1, nightly: 0, best: "Apr–Jun", x: 612, y: 330 },
{ id: "p6", name: "Villa Olivar", cat: "stay", area: "Hillside", rating: 4.8, reviews: 97, price: 4, nightly: 240, best: "Year-round", x: 560, y: 412 },
{ id: "p7", name: "Brasa & Brine", cat: "eat", area: "Old Harbour", rating: 4.4, reviews: 521, price: 2, nightly: 0, best: "Lunch", x: 398, y: 588 },
{ id: "p8", name: "Lighthouse Point", cat: "sight", area: "North Cape", rating: 4.7, reviews: 264, price: 2, nightly: 0, best: "Sunset", x: 858, y: 240 },
{ id: "p9", name: "Cala Secreta", cat: "beach", area: "North Cape", rating: 4.9, reviews: 145, price: 1, nightly: 0, best: "Low tide", x: 770, y: 318 },
{ id: "p10", name: "Aloja Pinar", cat: "stay", area: "Pine Ridge", rating: 4.3, reviews: 58, price: 2, nightly: 92, best: "Spring", x: 648, y: 296 },
{ id: "p11", name: "Cresta Trail", cat: "trail", area: "Hillside", rating: 4.6, reviews: 41, price: 1, nightly: 0, best: "Sunrise", x: 506, y: 360 },
{ id: "p12", name: "Café Onda", cat: "eat", area: "South Shore", rating: 4.5, reviews: 312, price: 1, nightly: 0, best: "Breakfast", x: 460, y: 432 },
];
/* ----------------------------------------------------------------
* State
* ---------------------------------------------------------------- */
var state = {
category: "all",
maxPrice: 4,
query: "",
savedOnly: false,
active: null,
saved: {},
zoom: 1,
panX: 0,
panY: 0,
};
var SVG_NS = "http://www.w3.org/2000/svg";
/* ----------------------------------------------------------------
* DOM refs
* ---------------------------------------------------------------- */
var cardsEl = document.getElementById("cards");
var pinsEl = document.getElementById("pins");
var mapCanvas = document.getElementById("mapCanvas");
var resultCount = document.getElementById("resultCount");
var emptyState = document.getElementById("emptyState");
var mapPop = document.getElementById("mapPop");
var split = document.querySelector(".split");
var listPane = document.getElementById("poi-list");
var savedCountEl = document.getElementById("savedCount");
var cardById = {};
var pinById = {};
/* ----------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------- */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-shown");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-shown");
}, 2000);
}
function priceTier(n) {
var on = "$".repeat(n);
var off = "$".repeat(4 - n);
return "<strong>" + on + "</strong><em>" + off + "</em>";
}
function stars(r) {
var full = Math.round(r);
return "★".repeat(full) + "☆".repeat(5 - full);
}
function matches(p) {
if (state.category !== "all" && p.cat !== state.category) return false;
if (p.price > state.maxPrice) return false;
if (state.savedOnly && !state.saved[p.id]) return false;
if (state.query) {
var q = state.query.toLowerCase();
if (
p.name.toLowerCase().indexOf(q) === -1 &&
p.area.toLowerCase().indexOf(q) === -1 &&
CAT_LABEL[p.cat].toLowerCase().indexOf(q) === -1
)
return false;
}
return true;
}
/* ----------------------------------------------------------------
* Render: list cards
* ---------------------------------------------------------------- */
function buildCards() {
cardsEl.innerHTML = "";
cardById = {};
PLACES.forEach(function (p, i) {
var li = document.createElement("li");
li.className = "card";
li.dataset.id = p.id;
li.setAttribute("tabindex", "0");
li.setAttribute("role", "button");
li.setAttribute(
"aria-label",
p.name + ", " + p.area + ", rated " + p.rating + " of 5"
);
var priceLine =
p.nightly > 0
? '<span class="card__price">$' + p.nightly + " <span>/ night</span></span>"
: '<span class="card__price">Free <span>entry</span></span>';
li.innerHTML =
'<div class="card__media" style="background:' + GRADIENTS[p.cat] + '">' +
'<span class="card__num">' + (i + 1) + "</span>" +
'<span class="card__cat">' + CAT_LABEL[p.cat] + "</span>" +
'<button class="heart" type="button" aria-label="Save ' + p.name + '" aria-pressed="false">♥</button>' +
"</div>" +
'<div class="card__body">' +
'<div class="card__top">' +
"<div>" +
'<div class="card__title">' + p.name + "</div>" +
'<div class="card__area">' + p.area + "</div>" +
"</div>" +
priceLine +
"</div>" +
'<div class="card__meta">' +
'<span class="rating">' + p.rating.toFixed(1) + " <span>★ (" + p.reviews + ")</span></span>" +
'<span class="price-tier">' + priceTier(p.price) + "</span>" +
"</div>" +
'<span class="best-badge">🕑 Best: ' + p.best + "</span>" +
"</div>";
cardsEl.appendChild(li);
cardById[p.id] = li;
var heart = li.querySelector(".heart");
function activate() {
setActive(p.id, true);
}
li.addEventListener("click", activate);
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activate();
}
});
li.addEventListener("mouseenter", function () {
hoverPin(p.id, true);
});
li.addEventListener("mouseleave", function () {
if (state.active !== p.id) hoverPin(p.id, false);
});
heart.addEventListener("click", function (e) {
e.stopPropagation();
toggleSave(p.id);
});
});
}
/* ----------------------------------------------------------------
* Render: SVG pins
* ---------------------------------------------------------------- */
function buildPins() {
pinsEl.innerHTML = "";
pinById = {};
PLACES.forEach(function (p, i) {
var g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", "pin");
g.setAttribute("transform", "translate(" + p.x + "," + p.y + ")");
g.setAttribute("tabindex", "0");
g.setAttribute("role", "button");
g.setAttribute("aria-label", p.name + ", pin " + (i + 1));
// teardrop shape, anchored at the bottom tip (0,0)
var path = document.createElementNS(SVG_NS, "path");
path.setAttribute("class", "pin__shape");
path.setAttribute(
"d",
"M0 0 C -14 -22 -18 -30 -18 -38 A 18 18 0 1 1 18 -38 C 18 -30 14 -22 0 0 Z"
);
var txt = document.createElementNS(SVG_NS, "text");
txt.setAttribute("class", "pin__num");
txt.setAttribute("x", "0");
txt.setAttribute("y", "-32");
txt.textContent = String(i + 1);
g.appendChild(path);
g.appendChild(txt);
pinsEl.appendChild(g);
pinById[p.id] = g;
g.addEventListener("click", function () {
setActive(p.id, false);
});
g.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActive(p.id, false);
}
});
g.addEventListener("mouseenter", function () {
hoverPin(p.id, true);
showPop(p);
});
g.addEventListener("mouseleave", function () {
if (state.active !== p.id) {
hoverPin(p.id, false);
hidePop();
}
});
g.addEventListener("focus", function () {
showPop(p);
});
g.addEventListener("blur", hidePop);
});
}
/* ----------------------------------------------------------------
* Map popover (anchored to pin in screen space)
* ---------------------------------------------------------------- */
function showPop(p) {
var pin = pinById[p.id];
if (!pin) return;
var pinRect = pin.getBoundingClientRect();
var mapRect = document.getElementById("map").getBoundingClientRect();
mapPop.innerHTML =
'<div class="pop__media" style="background:' + GRADIENTS[p.cat] + '"></div>' +
'<div class="pop__body">' +
'<div class="pop__title">' + p.name + "</div>" +
'<div class="pop__meta"><span>' + p.area + "</span><strong>" +
(p.nightly > 0 ? "$" + p.nightly : "Free") + "</strong></div>" +
"</div>";
mapPop.hidden = false;
var x = pinRect.left + pinRect.width / 2 - mapRect.left;
var y = pinRect.top - mapRect.top;
mapPop.style.left = x + "px";
mapPop.style.top = y + "px";
}
function hidePop() {
mapPop.hidden = true;
}
/* ----------------------------------------------------------------
* Hover sync (no fly, no scroll)
* ---------------------------------------------------------------- */
function hoverPin(id, on) {
var pin = pinById[id];
if (!pin) return;
// never toggle the highlight off the currently selected pin
if (state.active === id) return;
pin.classList.toggle("is-active", on);
}
/* ----------------------------------------------------------------
* Select / activate a place — syncs both panes + flies the map.
* scrollList=true when activation came from the map (scroll the card
* into view); when it came from a card we don't auto-scroll the list.
* ---------------------------------------------------------------- */
function setActive(id, fromCard) {
if (state.active && cardById[state.active])
cardById[state.active].classList.remove("is-active");
if (state.active && pinById[state.active])
pinById[state.active].classList.remove("is-active");
state.active = id;
var p = byId(id);
var card = cardById[id];
var pin = pinById[id];
if (card) card.classList.add("is-active");
if (pin) pin.classList.add("is-active");
flyTo(p);
showPop(p);
if (!fromCard && card) {
// came from the map -> bring the card into view in the list
card.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
function byId(id) {
for (var i = 0; i < PLACES.length; i++) if (PLACES[i].id === id) return PLACES[i];
return null;
}
/* ----------------------------------------------------------------
* "Fly" the map: pan + zoom so the place sits centred.
* Implemented with a CSS transform on the SVG canvas.
* ---------------------------------------------------------------- */
function flyTo(p) {
state.zoom = 1.9;
centreOn(p.x, p.y);
}
function centreOn(vx, vy) {
// SVG uses preserveAspectRatio="slice": derive the effective scale
var rect = mapCanvas.getBoundingClientRect();
var scale = Math.max(rect.width / 1000, rect.height / 700);
// place (vx,vy) in viewBox -> px within the *unscaled* canvas
var px = vx * scale;
var py = vy * scale;
var z = state.zoom;
state.panX = rect.width / 2 - px * z;
state.panY = rect.height / 2 - py * z;
applyTransform();
}
function applyTransform() {
mapCanvas.style.transform =
"translate(" + state.panX + "px," + state.panY + "px) scale(" + state.zoom + ")";
}
function resetView() {
state.zoom = 1;
state.panX = 0;
state.panY = 0;
applyTransform();
}
/* ----------------------------------------------------------------
* Filtering -> show/hide cards + pins
* ---------------------------------------------------------------- */
function applyFilters() {
var visible = 0;
PLACES.forEach(function (p) {
var ok = matches(p);
if (ok) visible++;
var card = cardById[p.id];
var pin = pinById[p.id];
if (card) card.style.display = ok ? "" : "none";
if (pin) pin.style.display = ok ? "" : "none";
});
emptyState.hidden = visible !== 0;
var word = visible === 1 ? "place" : "places";
resultCount.textContent = state.savedOnly
? visible + " saved " + word
: visible + " " + word + " on the map";
// if active place got filtered out, clear selection
if (state.active && !matches(byId(state.active))) {
var prev = state.active;
state.active = null;
if (cardById[prev]) cardById[prev].classList.remove("is-active");
if (pinById[prev]) pinById[prev].classList.remove("is-active");
hidePop();
resetView();
}
}
/* ----------------------------------------------------------------
* Saving
* ---------------------------------------------------------------- */
function toggleSave(id) {
state.saved[id] = !state.saved[id];
var card = cardById[id];
var heart = card.querySelector(".heart");
heart.classList.toggle("is-saved", state.saved[id]);
heart.setAttribute("aria-pressed", state.saved[id] ? "true" : "false");
var n = Object.keys(state.saved).filter(function (k) {
return state.saved[k];
}).length;
savedCountEl.textContent = n;
toast(state.saved[id] ? "Saved " + byId(id).name + " to your trip" : "Removed from your trip");
if (state.savedOnly) applyFilters();
}
/* ----------------------------------------------------------------
* Controls wiring
* ---------------------------------------------------------------- */
function wireFilters() {
// category chips
document.querySelectorAll(".chip").forEach(function (chip) {
chip.addEventListener("click", function () {
document.querySelectorAll(".chip").forEach(function (c) {
c.classList.remove("is-active");
c.setAttribute("aria-pressed", "false");
});
chip.classList.add("is-active");
chip.setAttribute("aria-pressed", "true");
state.category = chip.dataset.filter;
applyFilters();
});
});
// price slider
var price = document.getElementById("price");
var priceOut = document.getElementById("priceOut");
price.addEventListener("input", function () {
state.maxPrice = +price.value;
priceOut.textContent = "$".repeat(state.maxPrice);
applyFilters();
});
// search
var search = document.getElementById("search");
search.addEventListener("input", function () {
state.query = search.value.trim();
applyFilters();
});
// saved toggle
var savedToggle = document.getElementById("savedToggle");
savedToggle.addEventListener("click", function () {
state.savedOnly = !state.savedOnly;
savedToggle.classList.toggle("is-active", state.savedOnly);
savedToggle.setAttribute("aria-pressed", state.savedOnly ? "true" : "false");
applyFilters();
});
}
function wireMapControls() {
document.getElementById("zoomIn").addEventListener("click", function () {
state.zoom = Math.min(3, state.zoom + 0.4);
if (state.active) centreOn(byId(state.active).x, byId(state.active).y);
else applyTransform();
});
document.getElementById("zoomOut").addEventListener("click", function () {
state.zoom = Math.max(1, state.zoom - 0.4);
if (state.zoom === 1) resetView();
else if (state.active) centreOn(byId(state.active).x, byId(state.active).y);
else applyTransform();
});
document.getElementById("zoomReset").addEventListener("click", function () {
resetView();
hidePop();
toast("Map reset");
});
}
function wireViewToggle() {
var toggle = document.getElementById("viewToggle");
toggle.addEventListener("click", function () {
var next = split.dataset.view === "list" ? "map" : "list";
split.dataset.view = next;
toggle.setAttribute("aria-pressed", next === "map" ? "true" : "false");
if (next === "map") {
// recompute centring now the map has real dimensions
if (state.active) centreOn(byId(state.active).x, byId(state.active).y);
if (state.active) showPop(byId(state.active));
}
});
}
// keep popover anchored when the list scrolls or window resizes
function refreshPop() {
if (state.active && !mapPop.hidden) showPop(byId(state.active));
}
listPane.addEventListener("scroll", function () {
if (state.active) refreshPop();
});
window.addEventListener("resize", function () {
if (state.active) {
centreOn(byId(state.active).x, byId(state.active).y);
refreshPop();
}
});
/* ----------------------------------------------------------------
* Init
* ---------------------------------------------------------------- */
buildCards();
buildPins();
wireFilters();
wireMapControls();
wireViewToggle();
applyFilters();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cala Verdana — Map & Stays</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=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#poi-list">Skip to results</a>
<header class="topbar" role="banner">
<div class="brand">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M12 2C8.1 2 5 5 5 8.9 5 14 12 22 12 22s7-8 7-13.1C19 5 15.9 2 12 2Z" fill="currentColor"/>
<circle cx="12" cy="9" r="2.6" fill="#fbf7f1"/>
</svg>
</span>
<div class="brand__text">
<strong>Cala Verdana</strong>
<span>Coastal stays & trails</span>
</div>
</div>
<form class="searchbar" role="search" autocomplete="off">
<label class="visually-hidden" for="search">Search places</label>
<span class="searchbar__icon" aria-hidden="true">⌕</span>
<input id="search" type="search" placeholder="Search stays, coves, eats…" />
</form>
<button id="viewToggle" class="view-toggle" type="button" aria-pressed="false">
<span data-when="list">View map</span>
<span data-when="map">View list</span>
</button>
</header>
<nav class="filterbar" aria-label="Filter results">
<div class="chips" role="group" aria-label="Category">
<button class="chip is-active" type="button" data-filter="all" aria-pressed="true">All</button>
<button class="chip" type="button" data-filter="stay" aria-pressed="false">🏠 Stay</button>
<button class="chip" type="button" data-filter="eat" aria-pressed="false">🍽 Eat</button>
<button class="chip" type="button" data-filter="beach" aria-pressed="false">🏖 Beach</button>
<button class="chip" type="button" data-filter="trail" aria-pressed="false">🥾 Trail</button>
<button class="chip" type="button" data-filter="sight" aria-pressed="false">📷 Sights</button>
</div>
<div class="filterbar__right">
<label class="price" for="price">Up to <strong id="priceOut">$$$$</strong>
<input id="price" type="range" min="1" max="4" value="4" step="1" aria-label="Maximum price tier" />
</label>
<button id="savedToggle" class="saved-toggle" type="button" aria-pressed="false">
♥ Saved <span id="savedCount" class="saved-toggle__count">0</span>
</button>
</div>
</nav>
<main class="split" data-view="list">
<!-- LIST PANE -->
<section class="pane pane--list" aria-label="Places list" id="poi-list">
<div class="list-head">
<h1>Stays near <span>Cala Verdana</span></h1>
<p id="resultCount" aria-live="polite">12 places on the map</p>
</div>
<ul class="cards" id="cards"></ul>
<p class="empty" id="emptyState" hidden>No places match these filters. Try widening the price or clearing the category.</p>
</section>
<!-- MAP PANE -->
<section class="pane pane--map" aria-label="Map of places">
<div class="map" id="map">
<div class="map__viewport" id="mapViewport">
<svg class="map__canvas" id="mapCanvas" viewBox="0 0 1000 700" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Stylised coastal map of Cala Verdana">
<defs>
<linearGradient id="sea" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7fcfd0"/>
<stop offset="1" stop-color="#2f9fa6"/>
</linearGradient>
<linearGradient id="land" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f0e6d2"/>
<stop offset="1" stop-color="#dcc9a8"/>
</linearGradient>
<linearGradient id="hill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#cdd9a8"/>
<stop offset="1" stop-color="#9fb877"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.5"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- sea -->
<rect x="0" y="0" width="1000" height="700" fill="url(#sea)"/>
<g opacity="0.35" stroke="#bfeaea" stroke-width="2" fill="none">
<path d="M40 120 q30 -14 60 0 t60 0 t60 0"/>
<path d="M620 90 q30 -14 60 0 t60 0 t60 0"/>
<path d="M120 600 q30 -14 60 0 t60 0 t60 0"/>
</g>
<!-- land mass -->
<path d="M300 700 C 250 520 360 470 320 360 C 290 270 380 210 470 200
C 560 190 600 120 720 130 C 860 140 1000 90 1000 60
L1000 700 Z" fill="url(#land)"/>
<!-- hills -->
<path d="M520 250 C 600 200 690 220 760 200 C 830 180 920 210 1000 180 L1000 320
C 900 300 820 330 740 320 C 640 308 560 330 520 300 Z" fill="url(#hill)" opacity="0.9"/>
<!-- beach band -->
<path d="M300 700 C 250 520 360 470 320 360 C 305 318 330 300 360 300
C 360 420 330 540 360 700 Z" fill="#f6ead0" opacity="0.8"/>
<!-- coast road / route -->
<path id="route" d="M360 660 C 420 560 380 470 440 420 C 510 360 560 420 640 380 C 740 330 760 250 860 240"
fill="none" stroke="#e8623f" stroke-width="5" stroke-linecap="round" stroke-dasharray="2 14" opacity="0.85"/>
<!-- pin layer (filled by JS) -->
<g id="pins"></g>
</svg>
</div>
<div class="map__controls" role="group" aria-label="Map zoom">
<button id="zoomIn" type="button" aria-label="Zoom in">+</button>
<button id="zoomOut" type="button" aria-label="Zoom out">−</button>
<button id="zoomReset" type="button" aria-label="Reset map view" title="Reset view">⤾</button>
</div>
<div class="map__badge" aria-hidden="true">Cala Verdana · fictional</div>
<!-- hovered/active preview card -->
<div class="map__pop" id="mapPop" hidden></div>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Map + Content Split
A two-pane discovery layout for the invented coastline of Cala Verdana, built in the spirit of an Airbnb or booking map view. The left pane scrolls through point-of-interest cards — stays, eats, beaches, trails and sights — each pairing a gradient “photo”, a star rating, a price tier, a best-time badge and a save heart. The right pane is a sticky map rendered entirely from layered inline SVG and CSS gradients: a coastline, hills, a dashed coast road and one numbered teardrop pin per place.
The two panes stay in sync no matter which you reach for. Hovering a card lights up its pin; hovering a pin floats a small preview popover anchored in screen space. Selecting either one makes it the active place — the card gains a coral ring, the pin scales up, and the map “flies” by panning and zooming its SVG canvas via a CSS transform so the selection sits centred. Choosing a pin also scrolls the matching card into view. Pins are real keyboard-operable buttons, so focus and selection travel through the map as easily as through the list.
A filter bar narrows both panes at once: category chips, a maximum-price slider, a free-text search and a “Saved” toggle all run through one matcher that shows or hides cards and pins together and updates the live result count. Tapping a heart drops a place into your trip with a toast, and dedicated zoom controls plus a reset button round out the map. On narrow screens the split collapses to a single column with a topbar toggle that swaps between the full list and the full map, staying usable down to about 360px.
Illustrative travel UI only — fictional destinations, prices, and maps.