Map Card
Original · freelocation card with a hand-drawn SVG map
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/map-card.json"use client";
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const VB_W = 400;
const VB_H = 300;
type PoiKind = "studio" | "transit" | "spot";
type Poi = {
id: string;
x: number;
y: number;
kind: PoiKind;
label: string;
note: string;
};
type MapLabel = {
x: number;
y: number;
rotate: number;
text: string;
};
type Place = {
id: string;
tab: string;
name: string;
city: string;
country: string;
address: string[];
hours: string;
coords: string;
travel: string;
mapsUrl: string;
water: string;
parks: string[];
blocks: string[];
streets: string[];
rails: string[];
bridges: string[];
route: string;
labels: MapLabel[];
pois: Poi[];
};
const PLACES: Place[] = [
{
id: "lisbon",
tab: "Lisbon",
name: "Bica Studio",
city: "Lisbon",
country: "Portugal",
address: ["Rua da Bica de Duarte Belo 42", "1200-054 Lisboa, Portugal"],
hours: "Mon–Fri · 09:00 – 18:00 WET",
coords: "38.7100° N, 9.1450° W",
travel: "7 min uphill walk from Cais do Sodré",
mapsUrl: "https://www.google.com/maps/search/?api=1&query=38.7100,-9.1450",
water: "M 0 244 C 110 238 270 231 400 226 L 400 300 L 0 300 Z",
parks: ["M 34 38 L 118 30 L 126 88 L 42 96 Z"],
blocks: [
"M 98 70 L 174 64 L 178 120 L 102 126 Z",
"M 206 62 L 288 56 L 292 112 L 210 118 Z",
"M 110 140 L 180 134 L 184 188 L 114 194 Z",
"M 214 132 L 292 126 L 298 182 L 218 188 Z",
"M 320 54 L 392 48 L 398 174 L 326 180 Z",
],
streets: [
"M 0 58 L 400 44",
"M 0 128 L 400 116",
"M 0 200 L 400 188",
"M 88 0 L 104 240",
"M 190 0 L 204 236",
"M 302 0 L 314 230",
"M 12 236 L 190 58",
],
rails: ["M 0 224 L 400 212"],
bridges: [],
route: "M 128 224 L 152 198 L 196 192 L 200 132",
labels: [
{ x: 300, y: 272, rotate: -3, text: "RIO TEJO" },
{ x: 62, y: 68, rotate: -5, text: "JARDIM" },
],
pois: [
{
id: "studio",
x: 200,
y: 128,
kind: "studio",
label: "Bica Studio",
note: "Third floor, blue-tiled façade, buzzer marked 42-3E. The door is easy to miss — it sits between a tasca and a hardware shop.",
},
{
id: "lift",
x: 104,
y: 170,
kind: "spot",
label: "Elevador da Bica",
note: "The 1892 funicular still runs every 15 minutes. Cheaper than the hill on a hot afternoon, and it stops one street below us.",
},
{
id: "view",
x: 62,
y: 66,
kind: "spot",
label: "Miradouro de Santa Catarina",
note: "Where the team takes visiting clients at 18:00. Bring a coffee; the terrace has no service after five.",
},
{
id: "station",
x: 128,
y: 224,
kind: "transit",
label: "Cais do Sodré station",
note: "Green line metro, the Cascais train, and the Cacilhas ferry all land here. Exit toward Rua do Alecrim, then walk uphill.",
},
],
},
{
id: "copenhagen",
tab: "Copenhagen",
name: "Brygge Workshop",
city: "Copenhagen",
country: "Denmark",
address: ["Njalsgade 21C, second floor", "2300 København S, Denmark"],
hours: "Mon–Thu · 08:30 – 17:00 CET",
coords: "55.6620° N, 12.5860° E",
travel: "4 min walk from Islands Brygge Metro",
mapsUrl: "https://www.google.com/maps/search/?api=1&query=55.6620,12.5860",
water: "M 0 196 L 400 128 L 400 176 L 0 244 Z",
parks: ["M 252 24 L 340 18 L 348 76 L 260 84 Z"],
blocks: [
"M 40 60 L 118 54 L 122 112 L 44 118 Z",
"M 146 54 L 224 48 L 228 106 L 150 112 Z",
"M 40 140 L 116 134 L 120 176 L 44 184 Z",
"M 148 132 L 226 126 L 230 168 L 152 176 Z",
"M 292 190 L 372 176 L 380 226 L 300 240 Z",
],
streets: [
"M 0 42 L 400 30",
"M 0 124 L 400 108",
"M 60 0 L 74 182",
"M 160 0 L 172 168",
"M 264 0 L 274 150",
"M 356 0 L 364 136",
"M 0 262 L 400 208",
"M 240 300 L 262 176",
],
rails: ["M 0 16 L 400 6"],
bridges: ["M 170 166 L 194 256"],
route: "M 264 96 L 260 118 L 190 124 L 174 118",
labels: [
{ x: 92, y: 226, rotate: -9, text: "HAVNELØBET" },
{ x: 292, y: 52, rotate: -4, text: "PARK" },
],
pois: [
{
id: "studio",
x: 172,
y: 116,
kind: "studio",
label: "Brygge Workshop",
note: "Second floor of the old brewery block. Ring the intercom for 21C — the courtyard gate closes automatically at 18:00.",
},
{
id: "metro",
x: 264,
y: 96,
kind: "transit",
label: "Islands Brygge Metro",
note: "M1 line, four minutes from Kongens Nytorv. Take the north exit and follow the canal signs westward.",
},
{
id: "bridge",
x: 182,
y: 202,
kind: "spot",
label: "Bryggebroen",
note: "The cycle-and-foot bridge over the harbour. It is the fastest route in from Vesterbro and it is never gridlocked.",
},
{
id: "bath",
x: 96,
y: 232,
kind: "spot",
label: "Havnebadet",
note: "Open-water harbour bath, free entry, lifeguarded from June. Half the studio swims here before standup in summer.",
},
],
},
{
id: "kyoto",
tab: "Kyoto",
name: "Kamo Atelier",
city: "Kyoto",
country: "Japan",
address: ["Kiyamachi-dori 3F, Nakagyo-ku", "604-8004 Kyoto, Japan"],
hours: "Tue–Sat · 10:00 – 19:00 JST",
coords: "35.0090° N, 135.7710° E",
travel: "3 min walk from Sanjo Station",
mapsUrl: "https://www.google.com/maps/search/?api=1&query=35.0090,135.7710",
water: "M 262 0 L 292 0 L 274 300 L 244 300 Z",
parks: ["M 306 46 L 386 40 L 392 118 L 312 124 Z"],
blocks: [
"M 34 40 L 104 40 L 104 104 L 34 104 Z",
"M 132 40 L 206 40 L 206 104 L 132 104 Z",
"M 34 132 L 104 132 L 104 196 L 34 196 Z",
"M 132 132 L 206 132 L 206 196 L 132 196 Z",
"M 34 226 L 104 226 L 104 282 L 34 282 Z",
"M 132 226 L 206 226 L 206 282 L 132 282 Z",
"M 330 156 L 388 156 L 388 226 L 330 226 Z",
],
streets: [
"M 0 24 L 400 22",
"M 0 118 L 250 118",
"M 284 116 L 400 114",
"M 0 210 L 248 210",
"M 282 210 L 400 208",
"M 26 0 L 26 300",
"M 118 0 L 118 300",
"M 218 0 L 218 300",
"M 320 0 L 320 300",
"M 372 0 L 372 300",
],
rails: ["M 0 292 L 400 286"],
bridges: ["M 240 24 L 296 24"],
route: "M 320 40 L 288 30 L 250 34 L 224 62 L 218 118",
labels: [
{ x: 268, y: 258, rotate: 87, text: "KAMO-GAWA" },
{ x: 348, y: 84, rotate: -2, text: "SHRINE" },
],
pois: [
{
id: "studio",
x: 218,
y: 118,
kind: "studio",
label: "Kamo Atelier",
note: "Third floor above a coffee stand on Kiyamachi. The stair entrance is on the alley side, not the canal side.",
},
{
id: "station",
x: 320,
y: 40,
kind: "transit",
label: "Sanjo Station",
note: "Keihan line. Use exit 6, cross Sanjo Ohashi, then turn left before the canal. Roughly 300 metres in total.",
},
{
id: "market",
x: 118,
y: 118,
kind: "spot",
label: "Nishiki Market",
note: "Four hundred metres of covered food stalls. Our standing lunch order comes from the tamagoyaki counter near the west end.",
},
{
id: "river",
x: 268,
y: 190,
kind: "spot",
label: "Kamo riverbank",
note: "The stepping stones and the grass terrace. Most of our roadmap arguments have been settled sitting on this bank.",
},
],
},
];
const PIN_STYLE: Record<PoiKind, { body: string; ring: string; chip: string }> = {
studio: {
body: "fill-rose-500 stroke-rose-700 dark:fill-rose-400 dark:stroke-rose-200",
ring: "bg-rose-500/40 dark:bg-rose-400/40",
chip: "bg-rose-600 text-white dark:bg-rose-400 dark:text-rose-950",
},
transit: {
body: "fill-indigo-500 stroke-indigo-700 dark:fill-indigo-400 dark:stroke-indigo-200",
ring: "bg-indigo-500/40 dark:bg-indigo-400/40",
chip: "bg-indigo-600 text-white dark:bg-indigo-400 dark:text-indigo-950",
},
spot: {
body: "fill-violet-500 stroke-violet-700 dark:fill-violet-400 dark:stroke-violet-200",
ring: "bg-violet-500/40 dark:bg-violet-400/40",
chip: "bg-violet-600 text-white dark:bg-violet-400 dark:text-violet-950",
},
};
const KIND_LABEL: Record<PoiKind, string> = {
studio: "Studio",
transit: "Transit",
spot: "Nearby",
};
function MapArt({ place }: { place: Place }) {
return (
<g fill="none" strokeLinecap="round" strokeLinejoin="round">
<path
d={place.water}
className="fill-sky-500/25 stroke-sky-600/40 dark:fill-sky-400/15 dark:stroke-sky-300/35"
strokeWidth={1.4}
/>
{place.parks.map((d) => (
<path
key={d}
d={d}
className="fill-emerald-500/20 stroke-emerald-600/50 dark:fill-emerald-400/15 dark:stroke-emerald-400/40"
strokeWidth={1.4}
/>
))}
{place.blocks.map((d) => (
<path
key={d}
d={d}
className="fill-zinc-900/[0.07] stroke-zinc-600/45 dark:fill-zinc-100/[0.06] dark:stroke-zinc-400/35"
strokeWidth={1.2}
/>
))}
{place.streets.map((d) => (
<path
key={d}
d={d}
className="stroke-zinc-700/70 dark:stroke-zinc-300/55"
strokeWidth={1.7}
/>
))}
{place.bridges.map((d) => (
<path
key={d}
d={d}
className="stroke-zinc-800/85 dark:stroke-zinc-100/70"
strokeWidth={3.4}
/>
))}
{place.rails.map((d) => (
<path
key={d}
d={d}
className="stroke-zinc-500/70 dark:stroke-zinc-400/50"
strokeWidth={4}
strokeDasharray="1 6"
strokeLinecap="butt"
/>
))}
{place.labels.map((l) => (
<text
key={l.text}
x={l.x}
y={l.y}
textAnchor="middle"
transform={`rotate(${l.rotate} ${l.x} ${l.y})`}
className="fill-zinc-500/80 dark:fill-zinc-400/70"
style={{ fontSize: 7.5, letterSpacing: "0.22em", fontWeight: 600 }}
>
{l.text}
</text>
))}
</g>
);
}
export default function MapCard() {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const prefersReduced = useReducedMotion();
const reduce = prefersReduced ?? false;
const [placeIndex, setPlaceIndex] = useState(0);
const [poiId, setPoiId] = useState<string>(PLACES[0].pois[0].id);
const [showRoute, setShowRoute] = useState(true);
const [copied, setCopied] = useState(false);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const place = PLACES[placeIndex];
const poi = place.pois.find((p) => p.id === poiId) ?? place.pois[0];
function selectPlace(next: number) {
const i = (next + PLACES.length) % PLACES.length;
setPlaceIndex(i);
setPoiId(PLACES[i].pois[0].id);
setCopied(false);
tabRefs.current[i]?.focus();
}
function onTabKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
selectPlace(placeIndex + 1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
selectPlace(placeIndex - 1);
} else if (event.key === "Home") {
event.preventDefault();
selectPlace(0);
} else if (event.key === "End") {
event.preventDefault();
selectPlace(PLACES.length - 1);
}
}
async function copyAddress() {
const text = `${place.name}, ${place.address.join(", ")}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2200);
} catch {
setCopied(false);
}
}
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50 dark:focus-visible:ring-offset-zinc-900";
return (
<section className="relative w-full bg-zinc-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-zinc-950">
<style>{`
@keyframes mapcard-boil {
0%, 33.32% { opacity: 1; }
33.33%, 100% { opacity: 0; }
}
@keyframes mapcard-march {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -26; }
}
@keyframes mapcard-drop {
0% { transform: translateY(-12px) scale(0.8); opacity: 0; }
62% { transform: translateY(2px) scale(1.05); opacity: 1; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes mapcard-ping {
0% { transform: scale(0.55); opacity: 0.6; }
70%, 100% { transform: scale(2.4); opacity: 0; }
}
.mapcard-boil { animation: mapcard-boil 0.66s linear infinite; }
.mapcard-march { animation: mapcard-march 1.1s linear infinite; }
.mapcard-drop { animation: mapcard-drop 0.5s cubic-bezier(0.22, 1.2, 0.36, 1) backwards; }
.mapcard-ping { animation: mapcard-ping 2.4s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.mapcard-boil, .mapcard-march, .mapcard-drop, .mapcard-ping {
animation: none !important;
}
.mapcard-layer-alt { display: none !important; }
.mapcard-ping { opacity: 0 !important; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
Where we work
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
Three studios, drawn by hand
</h2>
<p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
We gave up on satellite tiles. Every location gets a map someone actually
sketched — the stairwell that is hard to find, the bridge that is faster than
the road, the bench where the argument gets settled.
</p>
</div>
<div
role="tablist"
aria-label="Studio locations"
className="mt-8 inline-flex gap-1 rounded-full border border-zinc-900/10 bg-white p-1 dark:border-zinc-100/10 dark:bg-zinc-900"
>
{PLACES.map((p, i) => {
const active = i === placeIndex;
return (
<button
key={p.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`${uid}-tab-${p.id}`}
aria-selected={active}
aria-controls={`${uid}-panel-${p.id}`}
tabIndex={active ? 0 : -1}
onClick={() => selectPlace(i)}
onKeyDown={onTabKeyDown}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
active
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-600 hover:bg-zinc-900/5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-100/10 dark:hover:text-zinc-100"
}`}
>
{p.tab}
</button>
);
})}
</div>
<div
role="tabpanel"
id={`${uid}-panel-${place.id}`}
aria-labelledby={`${uid}-tab-${place.id}`}
tabIndex={-1}
className="mt-5 overflow-hidden rounded-3xl border border-zinc-900/10 bg-amber-50 shadow-xl shadow-zinc-900/[0.06] focus-visible:outline-none dark:border-zinc-100/10 dark:bg-zinc-900 dark:shadow-black/40"
>
<div className="grid lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
{/* Map */}
<div className="p-4 sm:p-6">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-dashed border-zinc-900/25 bg-amber-100/70 dark:border-zinc-100/20 dark:bg-zinc-950">
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`Hand-drawn map of ${place.city} showing ${place.name} and nearby landmarks`}
>
<defs>
{[0, 1, 2].map((i) => (
<filter
key={i}
id={`${uid}-rough-${i}`}
x="-6%"
y="-6%"
width="112%"
height="112%"
filterUnits="objectBoundingBox"
>
<feTurbulence
type="fractalNoise"
baseFrequency="0.035"
numOctaves={2}
seed={3 + i * 13}
result={`n${i}`}
/>
<feDisplacementMap
in="SourceGraphic"
in2={`n${i}`}
scale={3.2}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
))}
<filter id={`${uid}-grain`} x="0" y="0" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.85"
numOctaves={3}
seed={9}
result="grain"
/>
<feColorMatrix in="grain" type="saturate" values="0" />
</filter>
</defs>
{[0, 1, 2].map((i) => (
<g
key={`${place.id}-layer-${i}`}
filter={`url(#${uid}-rough-${i})`}
className={
reduce
? i === 0
? undefined
: "hidden"
: `mapcard-boil${i > 0 ? " mapcard-layer-alt" : ""}`
}
style={reduce ? undefined : { animationDelay: `${i * 0.22}s` }}
>
<MapArt place={place} />
</g>
))}
<g filter={`url(#${uid}-rough-0)`} fill="none" strokeLinecap="round">
{showRoute ? (
<path
d={place.route}
className="stroke-rose-600 dark:stroke-rose-400"
strokeWidth={2.6}
strokeDasharray="7 6"
opacity={0.95}
/>
) : null}
{showRoute ? (
<path
d={place.route}
className="mapcard-march stroke-amber-50 dark:stroke-zinc-950"
strokeWidth={2.6}
strokeDasharray="7 6"
strokeDashoffset={0}
opacity={0.55}
/>
) : null}
{/* Compass */}
<g className="stroke-zinc-600/70 dark:stroke-zinc-400/60" strokeWidth={1.3}>
<circle cx={368} cy={266} r={15} />
<path d="M 368 256 L 372 268 L 368 265 L 364 268 Z" className="fill-zinc-700/80 stroke-none dark:fill-zinc-300/70" />
<path d="M 368 266 L 368 278" />
</g>
<text
x={368}
y={251}
textAnchor="middle"
className="fill-zinc-600/90 stroke-none dark:fill-zinc-400/80"
style={{ fontSize: 8, fontWeight: 700, letterSpacing: "0.1em" }}
>
N
</text>
{/* Scale bar */}
<g className="stroke-zinc-600/70 dark:stroke-zinc-400/60" strokeWidth={1.3}>
<path d="M 20 282 L 80 282" />
<path d="M 20 277 L 20 287" />
<path d="M 50 279 L 50 285" />
<path d="M 80 277 L 80 287" />
</g>
<text
x={20}
y={273}
className="fill-zinc-600/90 stroke-none dark:fill-zinc-400/80"
style={{ fontSize: 7.5, letterSpacing: "0.14em", fontWeight: 600 }}
>
200 m
</text>
</g>
<rect
x="0"
y="0"
width={VB_W}
height={VB_H}
filter={`url(#${uid}-grain)`}
className="opacity-[0.13] mix-blend-multiply dark:opacity-[0.09] dark:mix-blend-screen"
style={{ pointerEvents: "none" }}
/>
</svg>
{place.pois.map((p, i) => {
const selected = p.id === poi.id;
const style = PIN_STYLE[p.kind];
return (
<button
key={`${place.id}-${p.id}`}
type="button"
aria-pressed={selected}
onClick={() => setPoiId(p.id)}
className={`group absolute flex h-9 w-9 -translate-x-1/2 -translate-y-full items-end justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-100 dark:focus-visible:ring-offset-zinc-950 ${
selected ? "z-20" : "z-10"
}`}
style={{
left: `${(p.x / VB_W) * 100}%`,
top: `${(p.y / VB_H) * 100}%`,
}}
>
<span
className={`pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-semibold shadow-sm transition-opacity duration-150 ${style.chip} ${
selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
}`}
>
{p.label}
</span>
{selected ? (
<span
aria-hidden="true"
className={`mapcard-ping pointer-events-none absolute bottom-1 left-1/2 h-6 w-6 -translate-x-1/2 rounded-full ${style.ring}`}
/>
) : null}
<svg
viewBox="0 0 24 32"
className={`mapcard-drop relative h-7 w-[21px] transition-transform duration-150 group-hover:-translate-y-0.5 ${
selected ? "-translate-y-0.5" : ""
}`}
style={{ animationDelay: `${i * 80}ms` }}
aria-hidden="true"
>
<path
d="M12 30.5C12 30.5 21.5 19.4 21.5 11.6C21.5 6 17.2 1.5 12 1.5C6.8 1.5 2.5 6 2.5 11.6C2.5 19.4 12 30.5 12 30.5Z"
className={style.body}
strokeWidth={1.6}
strokeLinejoin="round"
/>
<circle
cx="12"
cy="11.4"
r="3.5"
className="fill-amber-50 dark:fill-zinc-900"
/>
</svg>
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<label className="group inline-flex cursor-pointer select-none items-center gap-2.5">
<input
type="checkbox"
className="peer sr-only"
checked={showRoute}
onChange={(e) => setShowRoute(e.target.checked)}
/>
<span
className={`relative h-5 w-9 rounded-full transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-amber-50 dark:peer-focus-visible:ring-offset-zinc-900 ${
showRoute
? "bg-rose-600 dark:bg-rose-500"
: "bg-zinc-900/20 dark:bg-zinc-100/20"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform dark:bg-zinc-950 ${
showRoute ? "translate-x-4" : "translate-x-0"
}`}
/>
</span>
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
Walking route from transit
</span>
</label>
<p className="text-xs text-zinc-500 dark:text-zinc-500">
Tap a pin for directions detail
</p>
</div>
</div>
{/* Details */}
<div className="border-t border-zinc-900/10 bg-white/60 p-6 sm:p-8 lg:border-l lg:border-t-0 dark:border-zinc-100/10 dark:bg-zinc-950/40">
<h3 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{place.name}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{place.city}, {place.country}
</p>
<address className="mt-5 space-y-0.5 text-sm not-italic leading-relaxed text-zinc-700 dark:text-zinc-300">
{place.address.map((line) => (
<p key={line}>{line}</p>
))}
</address>
<dl className="mt-6 space-y-3 border-t border-zinc-900/10 pt-5 text-sm dark:border-zinc-100/10">
<div className="flex gap-4">
<dt className="w-24 shrink-0 text-zinc-500 dark:text-zinc-500">Hours</dt>
<dd className="text-zinc-800 dark:text-zinc-200">{place.hours}</dd>
</div>
<div className="flex gap-4">
<dt className="w-24 shrink-0 text-zinc-500 dark:text-zinc-500">Getting in</dt>
<dd className="text-zinc-800 dark:text-zinc-200">{place.travel}</dd>
</div>
<div className="flex gap-4">
<dt className="w-24 shrink-0 text-zinc-500 dark:text-zinc-500">Coordinates</dt>
<dd className="font-mono text-xs text-zinc-800 dark:text-zinc-200">
{place.coords}
</dd>
</div>
</dl>
<div className="mt-6 min-h-[9.5rem] rounded-2xl border border-dashed border-zinc-900/20 bg-amber-100/50 p-4 dark:border-zinc-100/15 dark:bg-zinc-900/60">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`${place.id}-${poi.id}`}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 1 } : { opacity: 0, y: -6 }}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className={`h-2 w-2 rounded-full ${
poi.kind === "studio"
? "bg-rose-500"
: poi.kind === "transit"
? "bg-indigo-500"
: "bg-violet-500"
}`}
/>
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-zinc-500 dark:text-zinc-500">
{KIND_LABEL[poi.kind]}
</span>
</div>
<p className="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{poi.label}
</p>
<p className="mt-1.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
{poi.note}
</p>
</motion.div>
</AnimatePresence>
</div>
<p role="status" aria-live="polite" className="sr-only">
{copied ? `Address for ${place.name} copied to clipboard` : ""}
</p>
<div className="mt-6 flex flex-wrap gap-2">
<button
type="button"
onClick={copyAddress}
className={`inline-flex items-center gap-2 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 ${focusRing}`}
>
{copied ? (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 10.5 8 14.5 16 5.5" />
</svg>
) : (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<rect x="7" y="7" width="10" height="11" rx="2" />
<path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
</svg>
)}
{copied ? "Copied" : "Copy address"}
</button>
<a
href={place.mapsUrl}
target="_blank"
rel="noopener"
className={`inline-flex items-center gap-2 rounded-full border border-zinc-900/15 px-4 py-2 text-sm font-medium text-zinc-800 transition-colors hover:bg-zinc-900/5 dark:border-zinc-100/15 dark:text-zinc-200 dark:hover:bg-zinc-100/10 ${focusRing}`}
>
Open directions
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 13 13 7" />
<path d="M8 7h5v5" />
</svg>
<span className="sr-only">(opens in a new tab)</span>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Map Location Picker
Originallocation picker with pin

Map Store Locator
Originalstore locator list plus map

Map Contact
Originalcontact section with map and details
Map Delivery Tracking
Originaldelivery tracking with route

Map Pins List
Originalmap with a list of pinned places

Map Region Select
Originalclickable region selector map

Map Mini
Originalmini map preview widget

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

