Map Contact
Original · freecontact section with map and details
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-contact.json"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type ModeId = "metro" | "car" | "bike" | "walk";
type Rect = { x: number; y: number; w: number; h: number };
type Point = readonly [number, number];
type Mode = {
id: ModeId;
label: string;
eta: string;
detail: string;
poi: { x: number; y: number; name: string };
points: readonly Point[];
steps: readonly string[];
};
const VB_W = 840;
const VB_H = 560;
const STUDIO: Point = [470, 168];
const COLS_R: readonly Point[] = [
[0, 120],
[120, 160],
[280, 160],
[440, 160],
[600, 140],
[740, 100],
];
const ROWS_R: readonly Point[] = [
[0, 90],
[90, 110],
[200, 120],
[320, 120],
];
const PARK: Rect = { x: 128, y: 208, w: 144, h: 104 };
const BLOCKS: Rect[] = [];
for (const [x, w] of COLS_R) {
for (const [y, h] of ROWS_R) {
if (x === 120 && y === 200) continue;
BLOCKS.push({ x: x + 8, y: y + 8, w: w - 16, h: h - 16 });
}
}
function noise(seed: number): number {
const s = Math.sin(seed * 12.9898) * 43758.5453;
return s - Math.floor(s);
}
const BUILDINGS: Rect[] = [];
BLOCKS.forEach((b, i) => {
for (let k = 0; k < 3; k++) {
const a = noise(i * 7 + k * 13 + 1);
const c = noise(i * 11 + k * 5 + 2);
const w = b.w * (0.16 + a * 0.2);
const h = b.h * (0.16 + c * 0.22);
BUILDINGS.push({
x: b.x + 5 + c * (b.w - w - 10),
y: b.y + 5 + a * (b.h - h - 10),
w,
h,
});
}
});
const TREES: Point[] = [];
for (let i = 0; i < 14; i++) {
TREES.push([
PARK.x + 14 + noise(i * 3 + 5) * (PARK.w - 28),
PARK.y + 14 + noise(i * 9 + 7) * (PARK.h - 28),
]);
}
const MODE_ORDER: readonly ModeId[] = ["metro", "car", "bike", "walk"];
const MODE_MAP: Record<ModeId, Mode> = {
metro: {
id: "metro",
label: "Metro",
eta: "6 min",
detail: "Green line · 300 m walk",
poi: { x: 120, y: 440, name: "Cais do Sodré" },
points: [
[120, 440],
[120, 200],
[470, 200],
[470, 168],
],
steps: [
"Ride the green line to Cais do Sodré — it is the last stop, so you cannot overshoot it.",
"Leave by the Rua do Alecrim exit and cross at the lights, not the tram tracks.",
"Climb Alecrim for two blocks, then turn right into Rua da Boavista. No. 87 is 40 m along on your left.",
],
},
car: {
id: "car",
label: "Car",
eta: "9 min",
detail: "Paid garage · €1.60 per hour",
poi: { x: 740, y: 320, name: "Garagem da Ribeira" },
points: [
[740, 320],
[740, 200],
[470, 200],
[470, 168],
],
steps: [
"Boavista is one-way heading west, so approach from Rua Nova do Carvalho rather than the avenue.",
"There is no street parking. Use Garagem da Ribeira — the ramp is beside the fruit shop, level −2 is usually free.",
"Walk two blocks west along Rua de São Paulo, then right at the tile shop.",
],
},
bike: {
id: "bike",
label: "Bike",
eta: "4 min",
detail: "12 stands · downhill on the way back",
poi: { x: 280, y: 90, name: "Dock 214 · Chiado" },
points: [
[280, 90],
[280, 200],
[470, 200],
[470, 168],
],
steps: [
"Dock at stand 214 on Largo do Chiado — twelve stands, rarely full before 10:00.",
"Roll down Rua do Alecrim. The cobbles are polished, so cross the tram tracks at an angle.",
"If you would rather bring the bike up, we keep two hooks in the entrance hall. Ask at the buzzer for the code.",
],
},
walk: {
id: "walk",
label: "On foot",
eta: "5 min",
detail: "Flat · 400 m · cobbled",
poi: { x: 600, y: 440, name: "Praça de São Paulo" },
points: [
[600, 440],
[600, 320],
[440, 320],
[440, 200],
[470, 200],
[470, 168],
],
steps: [
"Start at Praça de São Paulo, by the church steps where the flower stall sets up.",
"Head west on Rua de São Paulo — flat and shaded the whole way, but cobbled, so leave the heels at home.",
"Turn right at Rua do Ferragial, then left onto Boavista. Blue door, buzzer 3, second floor.",
],
},
};
const ZOOM_LEVELS: readonly number[] = [1, 1.3, 1.7, 2.2];
const ADDRESS_LINES: readonly string[] = ["Northbeam Studio", "Rua da Boavista 87, 2.º", "1200-068 Lisboa, Portugal"];
const HOURS: readonly (readonly [string, string])[] = [
["Mon – Thu", "09:00 – 18:00"],
["Friday", "09:00 – 15:00"],
["Sat – Sun", "By arrangement"],
];
const ADDRESS_ONE_LINE = "Northbeam Studio, Rua da Boavista 87, 2.º, 1200-068 Lisboa, Portugal";
const OSM_URL = "https://www.openstreetmap.org/?mlat=38.7093&mlon=-9.1447#map=18/38.7093/-9.1447";
function clamp(v: number, lo: number, hi: number): number {
return Math.min(Math.max(v, lo), hi);
}
function toPath(points: readonly Point[]): string {
return points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ");
}
function focusOf(points: readonly Point[]): { x: number; y: number } {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const [x, y] of points) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
}
function ModeIcon({ id, className }: { id: ModeId; className?: string }) {
const common = {
className,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
if (id === "metro") {
return (
<svg {...common}>
<path d="M6 5.5A2.5 2.5 0 0 1 8.5 3h7A2.5 2.5 0 0 1 18 5.5v9a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 6 14.5z" />
<path d="M6 10h12" />
<path d="M9.5 13.6h.01M14.5 13.6h.01" />
<path d="M8.5 17 6.5 21M15.5 17l2 4" />
</svg>
);
}
if (id === "car") {
return (
<svg {...common}>
<path d="M3.5 16.5v-3.6c0-.3.07-.6.2-.87l2.05-4.1A2 2 0 0 1 7.54 6.8h8.92a2 2 0 0 1 1.79 1.1l2.05 4.13c.13.27.2.57.2.87v3.6a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z" />
<path d="M5.6 12.4h12.8" />
<path d="M6.6 14.6h1.4M16 14.6h1.4" />
<path d="M5.5 17.5V19M18.5 17.5V19" />
</svg>
);
}
if (id === "bike") {
return (
<svg {...common}>
<circle cx="5.6" cy="16.4" r="3.6" />
<circle cx="18.4" cy="16.4" r="3.6" />
<path d="M5.6 16.4 9.8 8.6h3.8l3.4 7.8" />
<path d="M9.2 6.6h3.2M12.6 12.9h-4" />
</svg>
);
}
return (
<svg {...common}>
<circle cx="13.4" cy="4.4" r="1.7" />
<path d="M11.4 21.2l1.3-5.9-2.8-2.4V9.2l3.6-1.6 2.3 3.1 2.6 1" />
<path d="M9.7 12.1 8 15.5l-2.6 1.8" />
<path d="M12.7 15.3l2.9 2.6.8 3.3" />
</svg>
);
}
export default function MapContact() {
const reduce = useReducedMotion();
const [activeId, setActiveId] = useState<ModeId>("metro");
const [zoomIndex, setZoomIndex] = useState<number>(0);
const [copied, setCopied] = useState<boolean>(false);
const copyTimer = useRef<number | null>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const active = MODE_MAP[activeId];
const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;
const view = useMemo(() => {
const f = focusOf(active.points);
const tx = clamp(VB_W / 2 - f.x * zoom, VB_W * (1 - zoom), 0);
const ty = clamp(VB_H / 2 - f.y * zoom, VB_H * (1 - zoom), 0);
return { tx, ty };
}, [active, zoom]);
const routeD = useMemo(() => toPath(active.points), [active]);
const transition = reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 110, damping: 20, mass: 0.9 };
useEffect(() => {
return () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
};
}, []);
const copyAddress = useCallback(async () => {
try {
await navigator.clipboard.writeText(ADDRESS_ONE_LINE);
setCopied(true);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 2200);
} catch {
setCopied(false);
}
}, []);
const onOptionKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
const current = MODE_ORDER.indexOf(activeId);
let next = -1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (current + 1) % MODE_ORDER.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (current - 1 + MODE_ORDER.length) % MODE_ORDER.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = MODE_ORDER.length - 1;
if (next === -1) return;
event.preventDefault();
const id = MODE_ORDER[next] ?? "metro";
setActiveId(id);
optionRefs.current[next]?.focus();
},
[activeId],
);
const ring =
"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-950";
return (
<section className="relative w-full bg-white px-4 py-20 text-zinc-900 sm:px-6 sm:py-28 lg:px-8 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes mcnt-pulse {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.55; }
70% { transform: translate(-50%, -50%) scale(3.6); opacity: 0; }
100% { transform: translate(-50%, -50%) scale(3.6); opacity: 0; }
}
@keyframes mcnt-flow {
to { stroke-dashoffset: -48; }
}
.mcnt-pulse { animation: mcnt-pulse 2.6s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
.mcnt-flow { stroke-dasharray: 10 14; animation: mcnt-flow 1.4s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.mcnt-pulse { animation: none; opacity: 0.4; }
.mcnt-flow { animation: none; }
}
`}</style>
<div className="mx-auto max-w-6xl">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Find us
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Rua da Boavista 87 — the blue door beside the tile shop
</h2>
<p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Northbeam is six people on a second floor in Cais do Sodré, above a tile shop that has been trading since
1948. Buzzer 3. Pick how you are travelling and we will tell you the part the map app leaves out.
</p>
</header>
<div className="mt-12 grid gap-8 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:items-start">
<div>
<div
role="radiogroup"
aria-label="How are you getting here?"
className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-2"
>
{MODE_ORDER.map((id, index) => {
const mode = MODE_MAP[id];
const selected = id === activeId;
return (
<button
key={id}
ref={(el) => {
optionRefs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => setActiveId(id)}
onKeyDown={onOptionKeyDown}
className={`${ring} flex flex-col items-start gap-2 rounded-xl border p-3 text-left transition-colors ${
selected
? "border-indigo-500 bg-indigo-50 text-indigo-950 dark:border-indigo-400/70 dark:bg-indigo-500/10 dark:text-indigo-100"
: "border-zinc-200 bg-white text-zinc-700 hover:border-zinc-300 hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-900"
}`}
>
<ModeIcon
id={id}
className={`h-5 w-5 ${selected ? "text-indigo-600 dark:text-indigo-300" : "text-zinc-400 dark:text-zinc-500"}`}
/>
<span className="text-sm font-medium">{mode.label}</span>
<span
className={`text-xs tabular-nums ${selected ? "text-indigo-700/80 dark:text-indigo-300/80" : "text-zinc-500 dark:text-zinc-500"}`}
>
{mode.eta} to the door
</span>
</button>
);
})}
</div>
<div
aria-live="polite"
className="mt-6 rounded-2xl border border-zinc-200 bg-zinc-50/70 p-5 dark:border-zinc-800 dark:bg-zinc-900/50"
>
<div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-white">
<ModeIcon id={active.id} className="h-3.5 w-3.5" />
</span>
<p className="text-sm font-semibold">From {active.poi.name}</p>
</div>
<p className="mt-1.5 text-xs text-zinc-500 dark:text-zinc-500">{active.detail}</p>
<ol className="mt-4 space-y-3">
{active.steps.map((step, i) => (
<li key={step} className="flex gap-3">
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-zinc-300 text-[11px] font-semibold tabular-nums text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{i + 1}
</span>
<span className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{step}</span>
</li>
))}
</ol>
</div>
</div>
<div className="relative aspect-[3/2] w-full overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-900">
<motion.div
className="absolute inset-0"
style={{ transformOrigin: "0px 0px" }}
animate={{ x: `${(view.tx / VB_W) * 100}%`, y: `${(view.ty / VB_H) * 100}%`, scale: zoom }}
transition={transition}
>
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="absolute inset-0 h-full w-full" aria-hidden="true">
<rect width={VB_W} height={VB_H} className="fill-zinc-200 dark:fill-zinc-800" />
{BLOCKS.map((b) => (
<rect
key={`b-${b.x}-${b.y}`}
x={b.x}
y={b.y}
width={b.w}
height={b.h}
rx="4"
className="fill-zinc-50 dark:fill-zinc-900"
/>
))}
{BUILDINGS.map((b, i) => (
<rect
key={`h-${i}`}
x={b.x}
y={b.y}
width={b.w}
height={b.h}
rx="2"
className="fill-zinc-200/70 dark:fill-zinc-800/80"
/>
))}
<rect
x={PARK.x}
y={PARK.y}
width={PARK.w}
height={PARK.h}
rx="8"
className="fill-emerald-100 dark:fill-emerald-950"
/>
{TREES.map(([tx, ty], i) => (
<circle key={`t-${i}`} cx={tx} cy={ty} r="4.5" className="fill-emerald-300/80 dark:fill-emerald-800" />
))}
<text
x={PARK.x + PARK.w / 2}
y={PARK.y + PARK.h / 2 + 4}
textAnchor="middle"
className="fill-emerald-700/80 text-[10px] font-medium dark:fill-emerald-500/70"
>
Jardim do Príncipe
</text>
<path
d="M0,468 C 140,452 260,486 400,470 C 540,454 680,484 840,466 L840,560 L0,560 Z"
className="fill-sky-100 dark:fill-sky-950"
/>
<path
d="M0,468 C 140,452 260,486 400,470 C 540,454 680,484 840,466"
fill="none"
strokeWidth="2"
className="stroke-sky-200 dark:stroke-sky-900"
/>
<rect x="560" y="468" width="26" height="46" rx="3" className="fill-zinc-300 dark:fill-zinc-700" />
<rect x="660" y="472" width="18" height="34" rx="3" className="fill-zinc-300 dark:fill-zinc-700" />
<text x="770" y="530" textAnchor="end" className="fill-sky-500/80 text-[11px] font-medium tracking-wide dark:fill-sky-700">
Rio Tejo
</text>
<text x="300" y="86" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
R. Garrett
</text>
<text x="500" y="196" className="fill-zinc-500 text-[9px] font-semibold dark:fill-zinc-500">
R. da Boavista
</text>
<text x="300" y="316" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
R. de São Paulo
</text>
<text x="300" y="460" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
Av. 24 de Julho
</text>
<text x="118" y="60" transform="rotate(-90 118 60)" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
R. do Alecrim
</text>
<text x="438" y="410" transform="rotate(-90 438 410)" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
R. do Ferragial
</text>
<motion.path
key={`casing-${active.id}`}
d={routeD}
fill="none"
strokeWidth="9"
strokeLinecap="round"
strokeLinejoin="round"
className="stroke-indigo-500/30 dark:stroke-indigo-400/30"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={reduce ? { duration: 0 } : { duration: 0.75, ease: "easeInOut" }}
/>
<path
d={routeD}
fill="none"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="mcnt-flow stroke-indigo-600 dark:stroke-indigo-400"
/>
</svg>
<div
className="absolute"
style={{ left: `${(STUDIO[0] / VB_W) * 100}%`, top: `${(STUDIO[1] / VB_H) * 100}%` }}
>
<motion.div
className="absolute left-0 top-0"
style={{ transformOrigin: "0px 0px" }}
animate={{ scale: 1 / zoom }}
transition={transition}
>
<span
className="mcnt-pulse absolute left-0 top-0 block h-3.5 w-3.5 rounded-full bg-indigo-500/50"
style={{ transform: "translate(-50%, -50%)" }}
aria-hidden="true"
/>
<span
className="absolute left-0 top-0 block h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-indigo-600 dark:border-zinc-900"
aria-hidden="true"
/>
<div className="relative -translate-x-1/2 -translate-y-full pb-2.5">
<span
className="absolute bottom-0 left-1/2 h-2.5 w-px -translate-x-1/2 bg-indigo-500"
aria-hidden="true"
/>
<p className="whitespace-nowrap rounded-full bg-indigo-600 px-2.5 py-1 text-[11px] font-semibold text-white shadow-sm">
Northbeam Studio · No. 87
</p>
</div>
</motion.div>
</div>
{MODE_ORDER.map((id) => {
const mode = MODE_MAP[id];
const selected = id === activeId;
return (
<div
key={id}
className="absolute"
style={{ left: `${(mode.poi.x / VB_W) * 100}%`, top: `${(mode.poi.y / VB_H) * 100}%` }}
>
<motion.div
className="absolute left-0 top-0"
style={{ transformOrigin: "0px 0px" }}
animate={{ scale: 1 / zoom }}
transition={transition}
>
<span
className={`absolute left-0 top-0 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white dark:border-zinc-900 ${
selected ? "bg-indigo-600" : "bg-zinc-500 dark:bg-zinc-400"
}`}
aria-hidden="true"
/>
<div className="relative -translate-x-1/2 -translate-y-full pb-2">
<span
className={`absolute bottom-0 left-1/2 h-2 w-px -translate-x-1/2 ${
selected ? "bg-indigo-500" : "bg-zinc-400 dark:bg-zinc-500"
}`}
aria-hidden="true"
/>
<button
type="button"
aria-pressed={selected}
onClick={() => setActiveId(id)}
className={`${ring} flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-sm transition-colors ${
selected
? "border-indigo-600 bg-indigo-600 text-white"
: "border-zinc-200 bg-white/95 text-zinc-700 hover:border-zinc-300 hover:bg-white dark:border-zinc-700 dark:bg-zinc-900/95 dark:text-zinc-200 dark:hover:bg-zinc-900"
}`}
>
<ModeIcon id={id} className="h-3.5 w-3.5" />
<span>{mode.poi.name}</span>
<span className="sr-only">— show the {mode.label.toLowerCase()} route to the studio</span>
</button>
</div>
</motion.div>
</div>
);
})}
</motion.div>
<p className="pointer-events-none absolute left-3 top-3 rounded-full border border-zinc-200 bg-white/90 px-2.5 py-1 text-[11px] font-medium text-zinc-600 backdrop-blur-sm dark:border-zinc-700 dark:bg-zinc-900/90 dark:text-zinc-300">
Cais do Sodré · Lisboa
</p>
<div className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full border border-zinc-200 bg-white/90 p-1 backdrop-blur-sm dark:border-zinc-700 dark:bg-zinc-900/90">
<button
type="button"
aria-label="Zoom out"
disabled={zoomIndex === 0}
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
className={`${ring} inline-flex h-7 w-7 items-center justify-center rounded-full text-zinc-600 transition-colors hover:bg-zinc-100 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 12h12" />
</svg>
</button>
<span className="w-9 text-center text-[11px] font-semibold tabular-nums text-zinc-600 dark:text-zinc-300">
{zoom.toFixed(1)}×
</span>
<button
type="button"
aria-label="Zoom in"
disabled={zoomIndex === ZOOM_LEVELS.length - 1}
onClick={() => setZoomIndex((i) => Math.min(ZOOM_LEVELS.length - 1, i + 1))}
className={`${ring} inline-flex h-7 w-7 items-center justify-center rounded-full text-zinc-600 transition-colors hover:bg-zinc-100 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 6v12M6 12h12" />
</svg>
</button>
</div>
</div>
</div>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
<circle cx="12" cy="10" r="2.6" />
</svg>
<h3 className="text-xs font-semibold uppercase tracking-[0.12em]">Studio</h3>
</div>
<address className="mt-3 space-y-0.5 text-sm not-italic leading-relaxed text-zinc-600 dark:text-zinc-400">
{ADDRESS_LINES.map((line) => (
<p key={line} className={line === "Northbeam Studio" ? "font-medium text-zinc-900 dark:text-zinc-100" : undefined}>
{line}
</p>
))}
</address>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={copyAddress}
className={`${ring} inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800`}
>
{copied ? (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7" />
</svg>
) : (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2.5" />
<path d="M15 5.5A1.5 1.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A1.5 1.5 0 0 0 5.5 15" />
</svg>
)}
{copied ? "Copied" : "Copy address"}
</button>
<a
href={OSM_URL}
target="_blank"
rel="noopener"
className={`${ring} inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10`}
>
Open in maps
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 5h11v11M19 5 6 18" />
</svg>
</a>
</div>
<p role="status" aria-live="polite" className="sr-only">
{copied ? "Studio address copied to clipboard" : ""}
</p>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 6 6l1.5-2 4 1.5v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.7 2 2 0 0 1 6.5 3.5z" />
</svg>
<h3 className="text-xs font-semibold uppercase tracking-[0.12em]">Reach a human</h3>
</div>
<ul className="mt-3 space-y-2.5 text-sm">
<li>
<a
href="tel:+351213460912"
className={`${ring} -mx-1 inline-block rounded px-1 font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition-colors hover:decoration-indigo-500 dark:text-zinc-100 dark:decoration-zinc-600`}
>
+351 21 346 0912
</a>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-500">Rings the studio desk, not a call centre.</p>
</li>
<li>
<a
href="mailto:hello@northbeam.studio"
className={`${ring} -mx-1 inline-block rounded px-1 font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition-colors hover:decoration-indigo-500 dark:text-zinc-100 dark:decoration-zinc-600`}
>
hello@northbeam.studio
</a>
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-500">Answered within one working day, by a person who read it.</p>
</li>
</ul>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white p-5 sm:col-span-2 lg:col-span-1 dark:border-zinc-800 dark:bg-zinc-900/60">
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
<h3 className="text-xs font-semibold uppercase tracking-[0.12em]">Open</h3>
</div>
<dl className="mt-3 space-y-1.5 text-sm">
{HOURS.map(([day, hours]) => (
<div key={day} className="flex items-baseline justify-between gap-4 border-b border-dashed border-zinc-200 pb-1.5 last:border-0 dark:border-zinc-800">
<dt className="text-zinc-600 dark:text-zinc-400">{day}</dt>
<dd className="font-medium tabular-nums text-zinc-900 dark:text-zinc-100">{hours}</dd>
</div>
))}
</dl>
<p className="mt-3 text-xs text-zinc-500 dark:text-zinc-500">
Lisbon time (WET / WEST). Drop-ins welcome, though the good coffee runs out around 16:00.
</p>
</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 Card
Originallocation card with a hand-drawn SVG map

Map Location Picker
Originallocation picker with pin

Map Store Locator
Originalstore locator list plus map
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.

