Map Mini
Original · freemini map preview widget
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-mini.json"use client";
import {
useCallback,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Layer = "streets" | "transit";
type Spot = {
id: string;
name: string;
kind: string;
address: string;
hours: string;
open: boolean;
walk: string;
lat: number;
lng: number;
x: number;
y: number;
};
const MAP_W = 880;
const MAP_H = 560;
const MIN_Z = 1;
const MAX_Z = 4;
const METERS_PER_UNIT = 2.2;
const SPOTS: Spot[] = [
{
id: "harbour",
name: "Harbour Lane Locker",
kind: "Parcel locker",
address: "12 Harbour Lane, Dock Quarter",
hours: "Open 24 hours",
open: true,
walk: "4 min walk",
lat: 53.4712,
lng: -2.2436,
x: 196,
y: 168,
},
{
id: "kiln",
name: "Kiln Street Pickup",
kind: "Counter service",
address: "48 Kiln Street, Old Foundry",
hours: "Closes 8:00 PM",
open: true,
walk: "7 min walk",
lat: 53.4738,
lng: -2.2381,
x: 402,
y: 122,
},
{
id: "quarry",
name: "Quarry Park Kiosk",
kind: "Weekend kiosk",
address: "Quarry Park, East Gate",
hours: "Opens Saturday 9:00 AM",
open: false,
walk: "11 min walk",
lat: 53.4691,
lng: -2.2294,
x: 654,
y: 236,
},
{
id: "bridge",
name: "Iron Bridge Depot",
kind: "Same-day depot",
address: "3 Iron Bridge Way, Riverside",
hours: "Closes 6:30 PM",
open: true,
walk: "9 min walk",
lat: 53.4664,
lng: -2.2418,
x: 318,
y: 388,
},
{
id: "vine",
name: "Vine Court Lockbox",
kind: "Parcel locker",
address: "77 Vine Court, Southbank",
hours: "Open until 11:00 PM",
open: true,
walk: "14 min walk",
lat: 53.4629,
lng: -2.2337,
x: 610,
y: 452,
},
];
const BLOCKS: Array<[number, number, number, number]> = [
[76, 96, 118, 74],
[232, 84, 108, 86],
[372, 62, 96, 96],
[500, 90, 128, 68],
[700, 108, 112, 84],
[86, 232, 104, 92],
[236, 244, 86, 68],
[452, 216, 116, 74],
[636, 292, 138, 80],
[128, 396, 122, 88],
[292, 444, 96, 66],
[440, 400, 108, 92],
[664, 428, 118, 74],
];
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
function clampFocus(x: number, y: number, z: number) {
const halfW = MAP_W / (2 * z);
const halfH = MAP_H / (2 * z);
return {
x: clamp(x, halfW, MAP_W - halfW),
y: clamp(y, halfH, MAP_H - halfH),
};
}
function scaleLabel(z: number) {
const raw = (72 * METERS_PER_UNIT) / z;
const steps = [25, 50, 75, 100, 150, 200, 300, 400];
let best = steps[0];
for (const s of steps) {
if (Math.abs(s - raw) < Math.abs(best - raw)) best = s;
}
return `${best} m`;
}
export default function MapMini() {
const reduce = useReducedMotion();
const rawId = useId();
const uid = `mapmini${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
const svgRef = useRef<SVGSVGElement | null>(null);
const dragRef = useRef<{ id: number; x: number; y: number } | null>(null);
const [selected, setSelected] = useState<string>("kiln");
const [focused, setFocused] = useState<string | null>(null);
const [layer, setLayer] = useState<Layer>("streets");
const [zoom, setZoom] = useState<number>(1.9);
const [center, setCenter] = useState<{ x: number; y: number }>(() =>
clampFocus(402, 122, 1.9),
);
const [dragging, setDragging] = useState(false);
const [copied, setCopied] = useState(false);
const spot = SPOTS.find((s) => s.id === selected) ?? SPOTS[0];
const goTo = useCallback((next: Spot) => {
setSelected(next.id);
setCopied(false);
setZoom((z) => {
const nz = z < 2 ? 2.2 : z;
setCenter(clampFocus(next.x, next.y, nz));
return nz;
});
}, []);
const nudgeZoom = useCallback((factor: number) => {
setZoom((z) => {
const nz = clamp(Number((z * factor).toFixed(3)), MIN_Z, MAX_Z);
setCenter((c) => clampFocus(c.x, c.y, nz));
return nz;
});
}, []);
const pan = useCallback(
(dx: number, dy: number) => {
setCenter((c) => clampFocus(c.x + dx, c.y + dy, zoom));
},
[zoom],
);
const unitsPerPixel = () => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return 1;
return MAP_W / rect.width;
};
const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
const target = e.target as Element | null;
// let a pin own its own press instead of stealing it for a pan
if (target?.closest?.(".mapmini-pin")) return;
dragRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY };
setDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {
const d = dragRef.current;
if (!d || d.id !== e.pointerId) return;
const upp = unitsPerPixel();
const dx = (e.clientX - d.x) * upp;
const dy = (e.clientY - d.y) * upp;
dragRef.current = { id: d.id, x: e.clientX, y: e.clientY };
pan(-dx / zoom, -dy / zoom);
};
const endDrag = (e: ReactPointerEvent<SVGSVGElement>) => {
if (dragRef.current?.id !== e.pointerId) return;
dragRef.current = null;
setDragging(false);
};
const onMapKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const step = 70 / zoom;
switch (e.key) {
case "ArrowUp":
pan(0, -step);
break;
case "ArrowDown":
pan(0, step);
break;
case "ArrowLeft":
pan(-step, 0);
break;
case "ArrowRight":
pan(step, 0);
break;
case "+":
case "=":
nudgeZoom(1.4);
break;
case "-":
case "_":
nudgeZoom(1 / 1.4);
break;
case "0":
setZoom(1);
setCenter(clampFocus(MAP_W / 2, MAP_H / 2, 1));
break;
default:
return;
}
e.preventDefault();
};
const onListKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const i = SPOTS.findIndex((s) => s.id === selected);
let next = i;
if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % SPOTS.length;
else if (e.key === "ArrowUp" || e.key === "ArrowLeft")
next = (i - 1 + SPOTS.length) % SPOTS.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = SPOTS.length - 1;
else return;
e.preventDefault();
goTo(SPOTS[next]);
const el = document.getElementById(`${uid}-opt-${SPOTS[next].id}`);
el?.focus();
};
const copyCoords = async () => {
const text = `${spot.lat.toFixed(4)}, ${spot.lng.toFixed(4)}`;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
const groupTransform = `translate(${MAP_W / 2}px, ${MAP_H / 2}px) scale(${zoom}) translate(${-center.x}px, ${-center.y}px)`;
return (
<section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 lg:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes mapmini-ping {
0% { transform: scale(1); opacity: .5; }
70% { transform: scale(2.8); opacity: 0; }
100% { transform: scale(2.8); opacity: 0; }
}
@keyframes mapmini-dash {
to { stroke-dashoffset: -36; }
}
@keyframes mapmini-breathe {
0%, 100% { opacity: .35; }
50% { opacity: .7; }
}
.mapmini-ping {
transform-box: fill-box;
transform-origin: center;
animation: mapmini-ping 2.4s cubic-bezier(0, 0, .2, 1) infinite;
}
.mapmini-dash { animation: mapmini-dash 1.6s linear infinite; }
.mapmini-breathe { animation: mapmini-breathe 3.2s ease-in-out infinite; }
.mapmini-pin:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 3px;
border-radius: 999px;
}
@media (prefers-reduced-motion: reduce) {
.mapmini-ping, .mapmini-dash, .mapmini-breathe { animation: none !important; }
.mapmini-view { transition: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-8 flex flex-col gap-3 sm:mb-10">
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="relative flex h-1.5 w-1.5">
<span className="mapmini-breathe absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
5 pickup points nearby
</span>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Choose where your parcel waits
</h2>
<p className="max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Drag the preview to explore, or pick a point from the list. Everything shown is
within a fifteen-minute walk of the Dock Quarter.
</p>
</header>
<div className="grid gap-4 lg:grid-cols-[1.55fr_1fr]">
{/* Map preview */}
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div
tabIndex={0}
role="group"
aria-label="Map preview. Use arrow keys to pan, plus and minus to zoom, zero to reset."
aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight + - 0"
onKeyDown={onMapKeyDown}
className="relative aspect-[11/7] w-full outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-inset dark:focus-visible:ring-indigo-400"
>
<svg
ref={svgRef}
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
className={`h-full w-full touch-none select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
role="presentation"
>
<defs>
<clipPath id={`${uid}-clip`}>
<rect x="0" y="0" width={MAP_W} height={MAP_H} />
</clipPath>
</defs>
<g clipPath={`url(#${uid}-clip)`}>
<rect
x="0"
y="0"
width={MAP_W}
height={MAP_H}
className="fill-slate-100 dark:fill-slate-950"
/>
<g
className="mapmini-view"
style={{
transform: groupTransform,
transition:
dragging || reduce
? "none"
: "transform 520ms cubic-bezier(.22,1,.36,1)",
}}
>
{/* water */}
<path
d="M-40 300 C 140 250, 220 366, 372 356 C 520 346, 596 452, 760 440 C 850 434, 900 470, 940 470 L 940 620 L -40 620 Z"
className="fill-sky-200/70 dark:fill-sky-500/15"
/>
<path
d="M-40 300 C 140 250, 220 366, 372 356 C 520 346, 596 452, 760 440 C 850 434, 900 470, 940 470"
className="fill-none stroke-sky-300 dark:stroke-sky-500/40"
strokeWidth="2"
/>
{/* parks */}
<path
d="M596 168 C 664 142, 760 152, 786 206 C 806 250, 762 292, 690 288 C 618 284, 570 226, 596 168 Z"
className="fill-emerald-200/80 dark:fill-emerald-500/15"
/>
<rect
x="60"
y="440"
width="150"
height="96"
rx="14"
className="fill-emerald-200/70 dark:fill-emerald-500/10"
/>
{/* city blocks */}
{BLOCKS.map(([x, y, w, h], i) => (
<rect
key={`${uid}-b${i}`}
x={x}
y={y}
width={w}
height={h}
rx="6"
className="fill-slate-200/90 dark:fill-slate-800/70"
/>
))}
{/* road casings + surfaces */}
<g
className="stroke-slate-300 dark:stroke-slate-800"
strokeLinecap="round"
fill="none"
>
<path d="M0 200 L880 200" strokeWidth="26" />
<path d="M0 348 L880 348" strokeWidth="22" />
<path d="M210 0 L210 560" strokeWidth="24" />
<path d="M480 0 L480 560" strokeWidth="20" />
<path d="M60 560 L560 0" strokeWidth="18" />
</g>
<g
className="stroke-white dark:stroke-slate-700"
strokeLinecap="round"
fill="none"
>
<path d="M0 200 L880 200" strokeWidth="18" />
<path d="M0 348 L880 348" strokeWidth="14" />
<path d="M210 0 L210 560" strokeWidth="16" />
<path d="M480 0 L480 560" strokeWidth="12" />
<path d="M60 560 L560 0" strokeWidth="11" />
</g>
<g
className="stroke-white/90 dark:stroke-slate-700/80"
strokeLinecap="round"
fill="none"
strokeWidth="6"
>
<path d="M0 92 L880 92" />
<path d="M0 470 L360 470" />
<path d="M340 0 L340 200" />
<path d="M660 200 L660 560" />
<path d="M780 0 L780 348" />
</g>
{/* transit overlay */}
{layer === "transit" ? (
<g>
<path
d="M-20 470 C 180 468, 240 300, 400 268 C 560 236, 640 320, 900 292"
className="fill-none stroke-violet-500/90 dark:stroke-violet-400/90"
strokeWidth="5"
strokeLinecap="round"
strokeDasharray="14 10"
/>
<path
d="M-20 470 C 180 468, 240 300, 400 268 C 560 236, 640 320, 900 292"
className="mapmini-dash fill-none stroke-violet-300/70 dark:stroke-violet-300/50"
strokeWidth="5"
strokeLinecap="round"
strokeDasharray="6 30"
/>
<path
d="M120 -20 C 200 160, 380 180, 470 300 C 540 394, 700 420, 880 402"
className="fill-none stroke-amber-500/80 dark:stroke-amber-400/80"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="10 12"
/>
{[
[252, 322],
[400, 268],
[470, 300],
[652, 300],
].map(([sx, sy], i) => (
<circle
key={`${uid}-st${i}`}
cx={sx}
cy={sy}
r={5 / zoom + 2}
className="fill-white stroke-violet-600 dark:fill-slate-950 dark:stroke-violet-400"
strokeWidth={2 / zoom + 0.6}
/>
))}
</g>
) : null}
{/* labels */}
<g className="fill-slate-500 dark:fill-slate-500" pointerEvents="none">
<text
x="700"
y="222"
fontSize={13 / zoom + 4}
className="fill-emerald-700 dark:fill-emerald-400/80"
fontWeight="600"
>
Quarry Park
</text>
<text x="66" y="392" fontSize={11 / zoom + 3} fontWeight="500">
Foundry Row
</text>
<text
x="128"
y="512"
fontSize={11 / zoom + 3}
className="fill-sky-700/80 dark:fill-sky-400/70"
fontWeight="500"
>
River Medlock
</text>
</g>
{/* you-are-here */}
<g>
<circle
cx="452"
cy="286"
r={26}
className="fill-indigo-500/15 stroke-indigo-500/40 dark:fill-indigo-400/10 dark:stroke-indigo-400/40"
strokeWidth={1.5 / zoom}
/>
<circle
cx="452"
cy="286"
r={6 / zoom + 2}
className="fill-indigo-600 stroke-white dark:fill-indigo-400 dark:stroke-slate-950"
strokeWidth={2 / zoom + 0.8}
/>
</g>
{/* pins */}
{SPOTS.map((s) => {
const active = s.id === selected;
return (
<g
key={s.id}
transform={`translate(${s.x} ${s.y}) scale(${1 / zoom})`}
>
{active ? (
<circle
r="13"
cy="-22"
className="mapmini-ping fill-indigo-500/60 dark:fill-indigo-400/60"
/>
) : null}
{focused === s.id ? (
<circle
r="21"
cy="-22"
className="fill-none stroke-indigo-500 dark:stroke-indigo-400"
strokeWidth="2.5"
/>
) : null}
<ellipse
cx="0"
cy="1"
rx="7"
ry="2.6"
className="fill-slate-900/20 dark:fill-slate-950/60"
/>
<g
role="button"
tabIndex={0}
aria-label={`${s.name}, ${s.kind}, ${s.walk}`}
className="mapmini-pin cursor-pointer"
onFocus={() => setFocused(s.id)}
onBlur={() => setFocused(null)}
onClick={() => goTo(s)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
goTo(s);
}
}}
>
<path
d="M0 0 C -9 -12, -14 -17, -14 -25 A 14 14 0 1 1 14 -25 C 14 -17, 9 -12, 0 0 Z"
className={
active
? "fill-indigo-600 stroke-white dark:fill-indigo-400 dark:stroke-slate-950"
: s.open
? "fill-slate-700 stroke-white dark:fill-slate-300 dark:stroke-slate-950"
: "fill-rose-500 stroke-white dark:fill-rose-400 dark:stroke-slate-950"
}
strokeWidth="2"
/>
<circle
cy="-25"
r="5"
className="fill-white dark:fill-slate-950"
/>
</g>
</g>
);
})}
</g>
</g>
</svg>
{/* zoom controls */}
<div className="pointer-events-none absolute right-3 top-3 flex flex-col gap-2">
<div className="pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/95">
<button
type="button"
onClick={() => nudgeZoom(1.4)}
disabled={zoom >= MAX_Z}
aria-label="Zoom in"
className="grid h-9 w-9 place-items-center text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:opacity-30 dark:text-slate-200 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true">
<path
d="M10 4v12M4 10h12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
<div className="h-px bg-slate-200 dark:bg-slate-700" />
<button
type="button"
onClick={() => nudgeZoom(1 / 1.4)}
disabled={zoom <= MIN_Z}
aria-label="Zoom out"
className="grid h-9 w-9 place-items-center text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:opacity-30 dark:text-slate-200 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true">
<path
d="M4 10h12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</button>
</div>
<button
type="button"
onClick={() => goTo(spot)}
aria-label={`Recenter on ${spot.name}`}
className="pointer-events-auto grid h-9 w-9 place-items-center rounded-xl border border-slate-200 bg-white/95 text-slate-700 shadow-sm backdrop-blur transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900/95 dark:text-slate-200 dark:hover:bg-slate-800"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true">
<circle
cx="10"
cy="10"
r="4"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M10 1.5v3M10 15.5v3M1.5 10h3M15.5 10h3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/* layer switch */}
<div
role="group"
aria-label="Map layer"
className="absolute left-3 top-3 flex gap-1 rounded-xl border border-slate-200 bg-white/95 p-1 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/95"
>
{(["streets", "transit"] as Layer[]).map((l) => (
<button
key={l}
type="button"
aria-pressed={layer === l}
onClick={() => setLayer(l)}
className={`rounded-lg px-2.5 py-1 text-xs font-medium capitalize transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
layer === l
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
>
{l}
</button>
))}
</div>
{/* scale bar */}
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2 rounded-lg bg-white/85 px-2 py-1 text-[11px] font-medium text-slate-600 backdrop-blur dark:bg-slate-900/85 dark:text-slate-400">
<span
className="block h-1.5 w-[72px] border-x-2 border-b-2 border-slate-500 dark:border-slate-500"
aria-hidden="true"
/>
{scaleLabel(zoom)}
</div>
<div className="pointer-events-none absolute bottom-3 right-3 rounded-lg bg-white/85 px-2 py-1 text-[11px] font-medium tabular-nums text-slate-500 backdrop-blur dark:bg-slate-900/85 dark:text-slate-500">
{zoom.toFixed(1)}×
</div>
</div>
{/* detail bar */}
<div className="border-t border-slate-200 p-4 dark:border-slate-800">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={spot.id}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 1 } : { opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
className="flex flex-wrap items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{spot.name}
</h3>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
spot.open
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400"
: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-400"
}`}
>
{spot.open ? "Open" : "Closed"}
</span>
</div>
<p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
{spot.address} · {spot.hours}
</p>
<p className="mt-0.5 text-xs tabular-nums text-slate-500 dark:text-slate-500">
{spot.lat.toFixed(4)}, {spot.lng.toFixed(4)} · {spot.walk}
</p>
</div>
<button
type="button"
onClick={copyCoords}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
{copied ? (
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" aria-hidden="true">
<path
d="M4 10.5l4 4 8-9"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" aria-hidden="true">
<rect
x="7"
y="7"
width="9"
height="9"
rx="2"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M13 5.5A1.5 1.5 0 0011.5 4h-6A1.5 1.5 0 004 5.5v6A1.5 1.5 0 005.5 13"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
)}
<span aria-live="polite">
{copied ? "Copied" : "Copy coordinates"}
</span>
</button>
</motion.div>
</AnimatePresence>
</div>
</div>
{/* Locations list */}
<div className="rounded-2xl border border-slate-200 bg-white p-2 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div
role="radiogroup"
aria-label="Pickup points"
onKeyDown={onListKeyDown}
className="flex flex-col"
>
{SPOTS.map((s) => {
const active = s.id === selected;
return (
<button
key={s.id}
id={`${uid}-opt-${s.id}`}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => goTo(s)}
className={`group flex items-start gap-3 rounded-xl px-3 py-3 text-left transition 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-slate-900 ${
active
? "bg-indigo-50 dark:bg-indigo-500/10"
: "hover:bg-slate-50 dark:hover:bg-slate-800/60"
}`}
>
<span
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg ${
active
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
}`}
aria-hidden="true"
>
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5">
<path
d="M10 17.5s5.5-5 5.5-9a5.5 5.5 0 10-11 0c0 4 5.5 9 5.5 9z"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="10" cy="8.5" r="1.9" fill="currentColor" />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span
className={`truncate text-sm font-medium ${
active
? "text-indigo-900 dark:text-indigo-200"
: "text-slate-800 dark:text-slate-200"
}`}
>
{s.name}
</span>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
s.open ? "bg-emerald-500" : "bg-rose-500"
}`}
aria-hidden="true"
/>
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-slate-400">
{s.kind} · {s.hours}
</span>
</span>
<span className="mt-0.5 shrink-0 text-xs font-medium tabular-nums text-slate-400 dark:text-slate-500">
{s.walk.replace(" walk", "")}
</span>
</button>
);
})}
</div>
<p className="border-t border-slate-100 px-3 py-3 text-[11px] leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-500">
Preview only — drag to pan, arrow keys to move, <kbd className="rounded border border-slate-200 px-1 font-sans dark:border-slate-700">+</kbd>{" "}
and <kbd className="rounded border border-slate-200 px-1 font-sans dark:border-slate-700">−</kbd> to zoom,{" "}
<kbd className="rounded border border-slate-200 px-1 font-sans dark:border-slate-700">0</kbd> to reset.
</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 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

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.

