Travel — Point-of-interest Card
A reusable point-of-interest card for travel guides, built with pure CSS and inline SVG scenes instead of photos. Each card shows a category chip, title, partial-fill star rating with review count, a one-line description, price or entry tier, distance and a view-on-map link. A heart toggle saves places to a trip and persists in localStorage, while a filter bar and a small stylised pin map tie four variants together. Warm editorial palette, hover lift and keyboard-friendly controls throughout.
MCP
Code
:root {
--bg: #fbf7f1;
--surface: #ffffff;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-deep: #146a6a;
--coral: #e8623f;
--coral-deep: #c84b2c;
--sand: #e7d8c3;
--gold: #d99a2b;
--line: rgba(36, 31, 26, 0.12);
--line-strong: rgba(36, 31, 26, 0.2);
--shadow: 0 1px 2px rgba(36, 31, 26, 0.06), 0 10px 28px -14px rgba(36, 31, 26, 0.28);
--shadow-lift: 0 6px 14px rgba(36, 31, 26, 0.1), 0 22px 50px -18px rgba(36, 31, 26, 0.4);
--radius: 18px;
--radius-sm: 11px;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--serif: "Fraunces", Georgia, "Times New Roman", serif;
--sans: "Work Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 16px;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(120% 80% at 100% -10%, rgba(31, 138, 138, 0.08), transparent 60%),
radial-gradient(90% 60% at -10% 0%, rgba(232, 98, 63, 0.07), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
img, svg { display: block; max-width: 100%; }
.skip-link {
position: absolute;
left: 12px;
top: -56px;
z-index: 50;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
text-decoration: none;
transition: top 0.18s var(--ease);
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 3px solid var(--teal);
outline-offset: 2px;
border-radius: 8px;
}
/* ---------- header ---------- */
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 1060px;
margin: 0 auto;
padding: 22px 24px 6px;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 11px;
color: #fff;
background: linear-gradient(150deg, var(--teal), var(--teal-deep));
box-shadow: var(--shadow);
}
.brand-text {
font-family: var(--serif);
font-weight: 600;
font-size: 1.22rem;
letter-spacing: -0.01em;
}
.brand-text em { font-style: italic; color: var(--coral); font-weight: 600; }
.head-meta { text-align: right; }
.kicker {
margin: 0;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
}
.saved-readout { margin: 1px 0 0; font-size: 0.92rem; color: var(--ink); }
.saved-num {
font-family: var(--serif);
font-weight: 600;
color: var(--coral);
font-variant-numeric: tabular-nums;
}
/* ---------- layout ---------- */
.wrap { max-width: 1060px; margin: 0 auto; padding: 14px 24px 80px; }
.intro { max-width: 640px; margin: 16px 0 26px; }
.eyebrow {
margin: 0 0 8px;
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--teal-deep);
}
.display {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(1.85rem, 5.4vw, 2.9rem);
line-height: 1.08;
letter-spacing: -0.02em;
margin: 0 0 12px;
}
.lede { margin: 0 0 20px; color: var(--muted); font-size: 1.04rem; max-width: 54ch; }
/* ---------- filter toolbar ---------- */
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; }
.chip-filter {
font: inherit;
font-size: 0.9rem;
font-weight: 500;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
transition: transform 0.16s var(--ease), border-color 0.16s var(--ease),
background 0.16s var(--ease), color 0.16s var(--ease), box-shadow 0.16s var(--ease);
}
.chip-filter:hover { border-color: var(--line-strong); transform: translateY(-1px); }
.chip-filter:active { transform: translateY(0); }
.chip-filter.is-active {
background: var(--ink);
color: #fff;
border-color: var(--ink);
box-shadow: var(--shadow);
}
.chip-filter.chip-saved.is-active { background: var(--coral); border-color: var(--coral); }
.chip-heart { color: var(--coral); }
.chip-filter.is-active .chip-heart { color: #fff; }
/* ---------- grid ---------- */
.grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 22px;
grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
}
.poi { display: flex; }
.poi[hidden] { display: none; }
/* ---------- card ---------- */
.card {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform 0.22s var(--ease), box-shadow 0.22s var(--ease),
border-color 0.22s var(--ease);
}
.card:hover,
.card:focus-within {
transform: translateY(-4px);
box-shadow: var(--shadow-lift);
border-color: var(--line-strong);
}
/* ---------- photo (CSS/SVG scenes) ---------- */
.photo {
position: relative;
aspect-ratio: 16 / 11;
overflow: hidden;
}
.photo .scene { position: absolute; inset: 0; width: 100%; height: 100%; }
.photo::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 52%, rgba(36, 31, 26, 0.28));
pointer-events: none;
}
.photo--hotel { background: linear-gradient(165deg, #ffd9a8, #f6a86b 55%, #e07d52); }
.photo--hotel .s-bldg { fill: #fbe7cf; }
.photo--hotel .s-bldg2 { fill: #f3d4b3; }
.photo--hotel .s-win { fill: #3c6f7a; opacity: 0.85; }
.photo--hotel .s-sun { fill: #fff3d6; opacity: 0.9; }
.photo--food { background: linear-gradient(170deg, #6a2f2a, #98443a 60%, #c2603f); }
.photo--food .s-wall { fill: transparent; }
.photo--food .s-cord { stroke: rgba(255, 230, 200, 0.55); stroke-width: 2; }
.photo--food .s-lamp { fill: #ffd27a; filter: drop-shadow(0 0 14px rgba(255, 200, 110, 0.7)); }
.photo--food .s-table { fill: rgba(40, 18, 14, 0.5); }
.photo--landmark { background: linear-gradient(180deg, #2b4d6b, #3f6b86 45%, #d98c5a); }
.photo--landmark .s-hill { fill: #20384c; }
.photo--landmark .s-arch { fill: #e9c7a6; }
.photo--landmark .s-archIn { fill: #2b4d6b; }
.photo--landmark .s-moon { fill: #fff6e0; opacity: 0.92; }
.photo--beach { background: linear-gradient(180deg, #bfe6ec, #8fd2da); }
.photo--beach .s-sky { fill: transparent; }
.photo--beach .s-sea { fill: #2aa3b0; }
.photo--beach .s-foam { fill: rgba(255, 255, 255, 0.55); }
.photo--beach .s-cliff { fill: #d9b07f; }
.photo--beach .s-sun { fill: #fff4cf; opacity: 0.9; }
/* ---------- heart ---------- */
.heart {
position: absolute;
top: 10px;
right: 10px;
width: 40px;
height: 40px;
display: grid;
place-items: center;
border: none;
border-radius: 50%;
cursor: pointer;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(36, 31, 26, 0.22);
transition: transform 0.16s var(--ease), background 0.16s var(--ease);
}
.heart svg {
width: 21px;
height: 21px;
fill: none;
stroke: var(--coral-deep);
stroke-width: 2;
transition: fill 0.16s var(--ease), stroke 0.16s var(--ease), transform 0.16s var(--ease);
}
.heart:hover { transform: scale(1.08); }
.heart:active { transform: scale(0.92); }
.heart[aria-pressed="true"] { background: #fff; }
.heart[aria-pressed="true"] svg { fill: var(--coral); stroke: var(--coral); }
.heart.pulse svg { animation: pop 0.42s var(--ease); }
@keyframes pop {
0% { transform: scale(1); }
40% { transform: scale(1.4); }
70% { transform: scale(0.86); }
100% { transform: scale(1); }
}
/* ---------- badge ---------- */
.badge {
position: absolute;
left: 10px;
bottom: 10px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
padding: 4px 10px;
border-radius: 999px;
color: #fff;
background: rgba(36, 31, 26, 0.6);
backdrop-filter: blur(3px);
}
.badge--best { background: rgba(31, 138, 138, 0.92); }
.badge--free { background: rgba(217, 154, 43, 0.95); }
/* ---------- body ---------- */
.body { padding: 15px 16px 17px; display: flex; flex-direction: column; gap: 7px; flex: 1; }
.row-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0; }
.cat {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 9px;
border-radius: 7px;
}
.cat--hotel { color: var(--coral-deep); background: rgba(232, 98, 63, 0.12); }
.cat--food { color: #8a3b32; background: rgba(194, 96, 63, 0.14); }
.cat--landmark { color: var(--teal-deep); background: rgba(31, 138, 138, 0.13); }
.cat--beach { color: #0f6f7a; background: rgba(42, 163, 176, 0.16); }
.dist { font-size: 0.78rem; color: var(--muted); white-space: nowrap; }
.name {
font-family: var(--serif);
font-weight: 600;
font-size: 1.28rem;
line-height: 1.15;
letter-spacing: -0.01em;
margin: 1px 0 0;
}
.rating { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 0.86rem; }
.rating strong { font-variant-numeric: tabular-nums; }
.reviews { color: var(--muted); }
.stars {
--pct: 0%;
display: inline-block;
width: 86px;
height: 15px;
background: linear-gradient(90deg, var(--gold) var(--pct), #e3d8c8 var(--pct));
-webkit-mask: repeat-x 0 0 / 17.2px 15px;
mask: repeat-x 0 0 / 17.2px 15px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='17' height='15' viewBox='0 0 17 15'%3E%3Cpath d='M8.5 1l2.1 4.3 4.7.7-3.4 3.3.8 4.7-4.2-2.2-4.2 2.2.8-4.7L1.2 6l4.7-.7z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='17' height='15' viewBox='0 0 17 15'%3E%3Cpath d='M8.5 1l2.1 4.3 4.7.7-3.4 3.3.8 4.7-4.2-2.2-4.2 2.2.8-4.7L1.2 6l4.7-.7z'/%3E%3C/svg%3E");
}
.desc { margin: 1px 0 4px; color: var(--muted); font-size: 0.92rem; flex: 1; }
.row-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 0;
padding-top: 9px;
border-top: 1px solid var(--line);
}
.price {
font-weight: 600;
font-size: 0.95rem;
color: var(--ink);
letter-spacing: 0.02em;
}
.price[data-tier="0"] { color: var(--teal-deep); }
.price-dim { color: var(--line-strong); }
.map-link {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.86rem;
font-weight: 600;
color: var(--teal-deep);
text-decoration: none;
padding: 4px 4px;
border-radius: 8px;
transition: color 0.16s var(--ease), transform 0.16s var(--ease);
}
.map-link svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.map-link:hover { color: var(--coral); transform: translateX(1px); }
/* ---------- empty state ---------- */
.empty {
margin: 18px 0 0;
padding: 20px;
text-align: center;
color: var(--muted);
background: var(--surface);
border: 1px dashed var(--line-strong);
border-radius: var(--radius);
}
/* ---------- mini map ---------- */
.mini-map { margin: 44px 0 0; }
.map-title {
font-family: var(--serif);
font-weight: 600;
font-size: 1.4rem;
margin: 0 0 12px;
}
.map-canvas {
position: relative;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--line);
box-shadow: var(--shadow);
aspect-ratio: 600 / 260;
background: #cfe7e3;
}
.map-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.m-water { fill: #bfe0dc; }
.m-land { fill: #e9dcc5; }
.m-route {
fill: none;
stroke: var(--coral);
stroke-width: 3;
stroke-dasharray: 7 7;
stroke-linecap: round;
opacity: 0.85;
}
.pin {
position: absolute;
transform: translate(-50%, -100%);
width: 30px;
height: 36px;
display: grid;
place-items: center;
padding-bottom: 6px;
border: none;
background: none;
color: #fff;
font: 600 0.8rem var(--sans);
cursor: pointer;
filter: drop-shadow(0 3px 5px rgba(36, 31, 26, 0.4));
transition: transform 0.18s var(--ease);
z-index: 2;
}
.pin::before {
content: "";
position: absolute;
inset: 0;
background: var(--teal);
border: 2px solid #fff;
border-radius: 50% 50% 50% 50%;
clip-path: polygon(50% 100%, 8% 42%, 8% 8%, 92% 8%, 92% 42%);
transition: background 0.18s var(--ease);
z-index: -1;
}
.pin > span { position: relative; line-height: 1; transform: translateY(-3px); }
.pin:hover::before { background: var(--coral); }
.pin.is-active { transform: translate(-50%, -100%) scale(1.22); z-index: 4; }
.pin.is-active::before { background: var(--coral); }
.map-note { margin: 10px 0 0; color: var(--muted); font-size: 0.88rem; }
.map-note strong { color: var(--ink); }
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 140%);
z-index: 60;
max-width: min(92vw, 420px);
padding: 12px 18px;
border-radius: 12px;
background: var(--ink);
color: #fff;
font-size: 0.92rem;
font-weight: 500;
box-shadow: var(--shadow-lift);
opacity: 0;
transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
pointer-events: none;
}
.toast.show { transform: translate(-50%, 0); opacity: 1; }
.toast.toast--save { background: var(--coral-deep); }
/* ---------- responsive ---------- */
@media (max-width: 560px) {
.page-head { padding: 18px 18px 4px; }
.wrap { padding: 12px 18px 64px; }
.grid { grid-template-columns: 1fr; gap: 18px; }
.display { font-size: clamp(1.7rem, 8vw, 2.2rem); }
}
@media (max-width: 380px) {
.head-meta { display: none; }
.toolbar { gap: 6px; }
.chip-filter { padding: 7px 13px; font-size: 0.84rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}/* Travel — Point-of-interest cards
Vanilla JS: heart toggles (persisted), star fills, category + saved filter,
"view on map" focus, and a small toast helper. No external libs. */
(function () {
"use strict";
var STORE_KEY = "wayfarer:saved:v1";
var grid = document.getElementById("poiGrid");
var pois = Array.prototype.slice.call(document.querySelectorAll(".poi"));
var hearts = Array.prototype.slice.call(document.querySelectorAll(".heart"));
var filters = Array.prototype.slice.call(document.querySelectorAll(".chip-filter"));
var savedCountEl = document.getElementById("savedCount");
var emptyState = document.getElementById("emptyState");
var mapNote = document.getElementById("mapNote");
var pins = Array.prototype.slice.call(document.querySelectorAll(".pin"));
var activeFilter = "all";
/* ---------- toast helper ---------- */
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg, kind) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.toggle("toast--save", kind === "save");
toastEl.classList.add("show");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
/* ---------- persistence ---------- */
function loadSaved() {
try {
var raw = window.localStorage.getItem(STORE_KEY);
var arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (e) {
return [];
}
}
function persist(list) {
try {
window.localStorage.setItem(STORE_KEY, JSON.stringify(list));
} catch (e) {
/* storage may be blocked (private mode) — keep working in-session */
}
}
var saved = loadSaved();
function isSaved(id) {
return saved.indexOf(id) !== -1;
}
/* ---------- star ratings ---------- */
document.querySelectorAll(".stars").forEach(function (el) {
var rating = parseFloat(el.getAttribute("data-rating")) || 0;
var pct = Math.max(0, Math.min(100, (rating / 5) * 100));
el.style.setProperty("--pct", pct.toFixed(1) + "%");
});
/* ---------- counts + empty state ---------- */
function refreshCount() {
if (savedCountEl) savedCountEl.textContent = String(saved.length);
}
function updateEmptyState() {
if (!emptyState) return;
if (activeFilter !== "saved") {
emptyState.hidden = true;
return;
}
emptyState.hidden = saved.length > 0;
}
/* ---------- filtering ---------- */
function applyFilter() {
pois.forEach(function (poi) {
var cat = poi.getAttribute("data-category");
var id = poi.getAttribute("data-id");
var show;
if (activeFilter === "all") show = true;
else if (activeFilter === "saved") show = isSaved(id);
else show = cat === activeFilter;
poi.hidden = !show;
});
updateEmptyState();
}
filters.forEach(function (btn) {
btn.addEventListener("click", function () {
activeFilter = btn.getAttribute("data-filter");
filters.forEach(function (b) {
var on = b === btn;
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
applyFilter();
});
});
/* ---------- heart toggle ---------- */
function syncHeart(btn) {
var poi = btn.closest(".poi");
if (!poi) return;
var id = poi.getAttribute("data-id");
btn.setAttribute("aria-pressed", isSaved(id) ? "true" : "false");
}
hearts.forEach(function (btn) {
syncHeart(btn);
btn.addEventListener("click", function () {
var poi = btn.closest(".poi");
if (!poi) return;
var id = poi.getAttribute("data-id");
var nameEl = poi.querySelector(".name");
var name = nameEl ? nameEl.textContent.trim() : "place";
if (isSaved(id)) {
saved = saved.filter(function (x) { return x !== id; });
btn.setAttribute("aria-pressed", "false");
toast("Removed " + name + " from your trip");
} else {
saved.push(id);
btn.setAttribute("aria-pressed", "true");
btn.classList.add("pulse");
window.setTimeout(function () { btn.classList.remove("pulse"); }, 440);
toast("Saved " + name + " to your trip ♥", "save");
}
persist(saved);
refreshCount();
if (activeFilter === "saved") applyFilter();
else updateEmptyState();
});
});
/* ---------- view on map ---------- */
function focusPlace(place) {
var matched = null;
pins.forEach(function (pin) {
var on = pin.getAttribute("data-place") === place;
pin.classList.toggle("is-active", on);
if (on) matched = pin;
});
if (mapNote && matched) {
mapNote.innerHTML = "Showing <strong>" + place + "</strong> on the map.";
}
return matched;
}
document.querySelectorAll(".map-link").forEach(function (link) {
link.addEventListener("click", function () {
// anchor still scrolls to #map; we just light up the pin
var place = link.getAttribute("data-place");
focusPlace(place);
toast("Focused " + place + " on the map");
});
});
pins.forEach(function (pin) {
pin.addEventListener("click", function () {
var place = pin.getAttribute("data-place");
focusPlace(place);
toast(place);
});
});
/* ---------- init ---------- */
refreshCount();
applyFilter();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Travel — Point-of-interest Cards</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&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="page-head" role="banner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
</span>
<span class="brand-text">Wayfarer<em>Guides</em></span>
</div>
<div class="head-meta">
<p class="kicker">Saved places</p>
<p class="saved-readout"><span id="savedCount" class="saved-num">0</span> in your trip</p>
</div>
</header>
<main id="main" class="wrap" role="main">
<section class="intro" aria-labelledby="introTitle">
<p class="eyebrow">Lisboa & the Algarve Coast</p>
<h1 id="introTitle" class="display">Pin the places worth the detour.</h1>
<p class="lede">A handful of hand-picked stops — sleep, eat, wander, swim. Tap the heart to drop any place into your trip; we’ll keep it for next time.</p>
<div class="toolbar" role="group" aria-label="Filter places by category">
<button class="chip-filter is-active" type="button" data-filter="all" aria-pressed="true">All</button>
<button class="chip-filter" type="button" data-filter="hotel" aria-pressed="false">Stay</button>
<button class="chip-filter" type="button" data-filter="restaurant" aria-pressed="false">Eat</button>
<button class="chip-filter" type="button" data-filter="landmark" aria-pressed="false">See</button>
<button class="chip-filter" type="button" data-filter="beach" aria-pressed="false">Swim</button>
<button class="chip-filter chip-saved" type="button" data-filter="saved" aria-pressed="false">
<span class="chip-heart" aria-hidden="true">♥</span> Saved
</button>
</div>
</section>
<ul class="grid" id="poiGrid" aria-label="Points of interest">
<!-- HOTEL -->
<li class="poi" data-category="hotel" data-id="tivira-house">
<article class="card" tabindex="-1" aria-labelledby="t-tivira">
<div class="photo photo--hotel" role="img" aria-label="Pastel tiled townhouse hotel at golden hour">
<svg class="scene" viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect class="s-bldg" x="40" y="50" width="100" height="130" rx="6" />
<rect class="s-bldg2" x="150" y="70" width="120" height="110" rx="6" />
<g class="s-win">
<rect x="56" y="70" width="18" height="24" rx="3" /><rect x="86" y="70" width="18" height="24" rx="3" /><rect x="116" y="70" width="18" height="24" rx="3" />
<rect x="56" y="106" width="18" height="24" rx="3" /><rect x="86" y="106" width="18" height="24" rx="3" /><rect x="116" y="106" width="18" height="24" rx="3" />
<rect x="168" y="90" width="20" height="26" rx="3" /><rect x="204" y="90" width="20" height="26" rx="3" /><rect x="240" y="90" width="20" height="26" rx="3" />
</g>
<circle class="s-sun" cx="262" cy="42" r="26" />
</svg>
<button class="heart" type="button" aria-pressed="false" aria-label="Save Casa Tivira to your trip">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/></svg>
</button>
<span class="badge badge--best">Best Sep–Oct</span>
</div>
<div class="body">
<p class="row-top"><span class="cat cat--hotel">Boutique stay</span><span class="dist">1.2 km from centre</span></p>
<h2 id="t-tivira" class="name">Casa Tivira</h2>
<p class="rating" aria-label="Rated 4.8 out of 5 from 326 reviews">
<span class="stars" data-rating="4.8" aria-hidden="true"></span>
<strong>4.8</strong> <span class="reviews">(326)</span>
</p>
<p class="desc">A nine-room townhouse with a tiled courtyard, rooftop figs and the gentlest breakfast in the old town.</p>
<p class="row-bottom">
<span class="price" data-tier="3" aria-label="Price tier: expensive">€€€<span class="price-dim">€</span></span>
<a class="map-link" href="#map" data-place="Casa Tivira">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/><circle cx="12" cy="10" r="2.4"/></svg>
View on map
</a>
</p>
</div>
</article>
</li>
<!-- RESTAURANT -->
<li class="poi" data-category="restaurant" data-id="taberna-sal">
<article class="card" tabindex="-1" aria-labelledby="t-sal">
<div class="photo photo--food" role="img" aria-label="Warm taverna interior with hanging lamps">
<svg class="scene" viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect class="s-wall" x="0" y="0" width="320" height="200" />
<line class="s-cord" x1="80" y1="0" x2="80" y2="40" /><circle class="s-lamp" cx="80" cy="48" r="16" />
<line class="s-cord" x1="160" y1="0" x2="160" y2="56" /><circle class="s-lamp" cx="160" cy="64" r="20" />
<line class="s-cord" x1="240" y1="0" x2="240" y2="40" /><circle class="s-lamp" cx="240" cy="48" r="16" />
<rect class="s-table" x="40" y="150" width="240" height="50" rx="8" />
</svg>
<button class="heart" type="button" aria-pressed="false" aria-label="Save Taberna do Sal to your trip">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/></svg>
</button>
<span class="badge badge--best">Dinner only</span>
</div>
<div class="body">
<p class="row-top"><span class="cat cat--food">Seafood taverna</span><span class="dist">450 m away</span></p>
<h2 id="t-sal" class="name">Taberna do Sal</h2>
<p class="rating" aria-label="Rated 4.6 out of 5 from 1208 reviews">
<span class="stars" data-rating="4.6" aria-hidden="true"></span>
<strong>4.6</strong> <span class="reviews">(1.2k)</span>
</p>
<p class="desc">Grilled sardines, clams in coriander and a chalkboard that changes with the morning’s catch.</p>
<p class="row-bottom">
<span class="price" data-tier="2" aria-label="Price tier: moderate">€€<span class="price-dim">€€</span></span>
<a class="map-link" href="#map" data-place="Taberna do Sal">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/><circle cx="12" cy="10" r="2.4"/></svg>
View on map
</a>
</p>
</div>
</article>
</li>
<!-- LANDMARK -->
<li class="poi" data-category="landmark" data-id="miradouro-arco">
<article class="card" tabindex="-1" aria-labelledby="t-arco">
<div class="photo photo--landmark" role="img" aria-label="Hilltop viewpoint over a tiled city">
<svg class="scene" viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<path class="s-hill" d="M0 200 L0 120 Q90 70 170 110 T320 95 L320 200 Z" />
<rect class="s-arch" x="120" y="60" width="80" height="80" rx="40" />
<rect class="s-archIn" x="138" y="86" width="44" height="54" rx="22" />
<circle class="s-moon" cx="60" cy="48" r="18" />
</svg>
<button class="heart" type="button" aria-pressed="false" aria-label="Save Miradouro do Arco to your trip">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/></svg>
</button>
<span class="badge badge--free">Free entry</span>
</div>
<div class="body">
<p class="row-top"><span class="cat cat--landmark">Viewpoint</span><span class="dist">2.0 km uphill</span></p>
<h2 id="t-arco" class="name">Miradouro do Arco</h2>
<p class="rating" aria-label="Rated 4.9 out of 5 from 5400 reviews">
<span class="stars" data-rating="4.9" aria-hidden="true"></span>
<strong>4.9</strong> <span class="reviews">(5.4k)</span>
</p>
<p class="desc">The whole tiled city tumbles to the river below — come for sunset and a glass of vinho verde.</p>
<p class="row-bottom">
<span class="price" data-tier="0" aria-label="Free to visit">Free</span>
<a class="map-link" href="#map" data-place="Miradouro do Arco">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/><circle cx="12" cy="10" r="2.4"/></svg>
View on map
</a>
</p>
</div>
</article>
</li>
<!-- BEACH -->
<li class="poi" data-category="beach" data-id="praia-concha">
<article class="card" tabindex="-1" aria-labelledby="t-praia">
<div class="photo photo--beach" role="img" aria-label="Sheltered cove with turquoise water and a cliff">
<svg class="scene" viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect class="s-sky" x="0" y="0" width="320" height="120" />
<path class="s-sea" d="M0 110 L320 110 L320 200 L0 200 Z" />
<path class="s-foam" d="M0 130 Q40 122 80 130 T160 130 T240 130 T320 130 L320 138 L0 138 Z" />
<path class="s-cliff" d="M230 200 L230 70 Q270 50 300 80 L320 90 L320 200 Z" />
<circle class="s-sun" cx="70" cy="44" r="22" />
</svg>
<button class="heart" type="button" aria-pressed="false" aria-label="Save Praia da Concha to your trip">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/></svg>
</button>
<span class="badge badge--best">Best Jun–Aug</span>
</div>
<div class="body">
<p class="row-top"><span class="cat cat--beach">Hidden cove</span><span class="dist">14 km drive</span></p>
<h2 id="t-praia" class="name">Praia da Concha</h2>
<p class="rating" aria-label="Rated 4.7 out of 5 from 842 reviews">
<span class="stars" data-rating="4.7" aria-hidden="true"></span>
<strong>4.7</strong> <span class="reviews">(842)</span>
</p>
<p class="desc">A shell-shaped cove reached by a cliff stair — calm, clear water and one tiny café for grilled fish.</p>
<p class="row-bottom">
<span class="price" data-tier="0" aria-label="Free to visit">Free</span>
<a class="map-link" href="#map" data-place="Praia da Concha">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-5.7-7-11a7 7 0 0 1 14 0c0 5.3-7 11-7 11z"/><circle cx="12" cy="10" r="2.4"/></svg>
View on map
</a>
</p>
</div>
</article>
</li>
</ul>
<p class="empty" id="emptyState" hidden role="status">No saved places yet — tap a heart to start your trip.</p>
<!-- lightweight map target so "view on map" feels real -->
<section class="mini-map" id="map" aria-labelledby="mapTitle">
<h2 id="mapTitle" class="map-title">On the map</h2>
<div class="map-canvas" role="img" aria-label="Stylised map of the saved places">
<svg class="map-svg" viewBox="0 0 600 260" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<path class="m-water" d="M0 0H600V260H0Z" />
<path class="m-land" d="M40 220 Q120 120 230 150 T420 110 Q520 90 560 160 L560 240 L40 240 Z" />
<path class="m-route" d="M120 190 L220 150 L330 170 L470 130" />
</svg>
<button class="pin" type="button" style="left:20%;top:73%" data-place="Casa Tivira" aria-label="Casa Tivira on map"><span>1</span></button>
<button class="pin" type="button" style="left:37%;top:58%" data-place="Taberna do Sal" aria-label="Taberna do Sal on map"><span>2</span></button>
<button class="pin" type="button" style="left:55%;top:65%" data-place="Miradouro do Arco" aria-label="Miradouro do Arco on map"><span>3</span></button>
<button class="pin" type="button" style="left:78%;top:50%" data-place="Praia da Concha" aria-label="Praia da Concha on map"><span>4</span></button>
</div>
<p class="map-note" id="mapNote">Tap a pin or a “view on map” link to focus a place.</p>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Point-of-interest Card
The building block for any travel guide: a responsive grid of point-of-interest cards for a fictional Lisboa & Algarve trip. Four variants — a boutique hotel, a seafood taverna, a hilltop viewpoint and a hidden cove — each lead with a layered CSS/SVG “landscape” instead of a photo, then stack a category chip, title, star rating with review count, a one-line description, a price or entry tier, the distance and a “view on map” link. Best-time and free-entry badges add quick context without clutter.
Every interaction actually works in vanilla JS. The heart in each photo toggles the place into your
trip and persists in localStorage, with a live count in the header and an animated pulse on
save. A filter bar narrows the grid by category or down to just your saved places, falling back to a
friendly empty state. Star ratings are drawn with a single masked gradient so half-stars fill
precisely, and a small stylised pin map lets the “view on map” links and pins highlight the matching
spot. Cards lift on hover and focus, controls are keyboard-usable with visible focus rings, contrast
meets WCAG AA and the layout collapses to a single column down to ~360px.
Illustrative travel UI only — fictional destinations, prices, and maps.