Map Pins List
Original · freemap with a list of pinned places
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-pins-list.json"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactElement,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Category = "coffee" | "food" | "shop" | "outdoors" | "music" | "nightlife";
type Place = {
id: string;
n: number;
name: string;
category: Category;
blurb: string;
hours: string;
walk: number;
rating: number;
coords: string;
x: number;
y: number;
};
const PLACES: Place[] = [
{
id: "ferrous",
n: 1,
name: "Ferrous Roasters",
category: "coffee",
blurb:
"Six-bar espresso, a rotating Ethiopian filter, and the only counter on the row that opens before the market does.",
hours: "06:00 – 16:00 · Daily",
walk: 4,
rating: 4.8,
coords: "51.5241, -0.0834",
x: 31.3,
y: 35,
},
{
id: "long-table",
n: 2,
name: "The Long Table",
category: "food",
blurb:
"Twelve seats, one menu, no substitutions. The lamb shoulder goes at eight and does not come back.",
hours: "17:30 – 23:00 · Wed – Sun",
walk: 7,
rating: 4.6,
coords: "51.5218, -0.0791",
x: 56.9,
y: 40.8,
},
{
id: "marginalia",
n: 3,
name: "Marginalia Books",
category: "shop",
blurb:
"Second-hand stacks two floors deep, with a poetry room that turns into a reading nook every Thursday.",
hours: "10:00 – 19:00 · Tue – Sun",
walk: 9,
rating: 4.9,
coords: "51.5192, -0.0902",
x: 18.8,
y: 60,
},
{
id: "hollis",
n: 4,
name: "Hollis Green",
category: "outdoors",
blurb:
"Eleven acres of plane trees, a lido that opens in May, and a bandstand the brass band still uses on Sundays.",
hours: "Open 24 hours",
walk: 12,
rating: 4.7,
coords: "51.5266, -0.0718",
x: 86.3,
y: 16.7,
},
{
id: "basement",
n: 5,
name: "Basement 12",
category: "music",
blurb:
"A 180-capacity room under the old print works. Doors at seven, nothing on the floor but the band and the bar.",
hours: "19:00 – 02:00 · Thu – Sat",
walk: 6,
rating: 4.5,
coords: "51.5183, -0.0762",
x: 65,
y: 80,
},
{
id: "nightjar",
n: 6,
name: "Nightjar",
category: "nightlife",
blurb:
"Low ceilings, high-proof stirred drinks, and a bartender who will happily talk you out of what you ordered.",
hours: "18:00 – 01:00 · Tue – Sat",
walk: 8,
rating: 4.4,
coords: "51.5171, -0.0845",
x: 41.3,
y: 86.7,
},
{
id: "salt",
n: 7,
name: "Salt & Ferment",
category: "food",
blurb:
"A bakery that mills its own rye. Arrive before eleven or make peace with the seeded loaf.",
hours: "07:30 – 15:00 · Wed – Sun",
walk: 10,
rating: 4.8,
coords: "51.5197, -0.0729",
x: 80,
y: 63.3,
},
{
id: "union",
n: 8,
name: "Union Print Works",
category: "shop",
blurb:
"Letterpress studio and paper shop. They will set your name in 24pt metal type and hand it over still smelling of ink.",
hours: "11:00 – 18:00 · Thu – Sat",
walk: 5,
rating: 4.6,
coords: "51.5259, -0.0871",
x: 26.9,
y: 17.5,
},
];
type IconProps = { className?: string };
function CoffeeIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M4 8h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M16 9.5h1.75A2.25 2.25 0 0 1 20 11.75v0A2.25 2.25 0 0 1 17.75 14H16"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M8 2.5v2M12 2.5v2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function FoodIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M6 3v7.5a2.5 2.5 0 0 0 5 0V3M8.5 11v10"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17 3c-1.7 1.3-2.5 3.2-2.5 5.5S15.3 12 17 12.5V21"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ShopIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M5 8h14l-1 12H6L5 8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M9 10V7a3 3 0 0 1 6 0v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function TreeIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M12 3 5.5 13h4L5 20h14l-4.5-7h4L12 3Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M12 20v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function MusicIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M9 18V5.5l10-2V16"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="6.5" cy="18" r="2.5" stroke="currentColor" strokeWidth="1.6" />
<circle cx="16.5" cy="16" r="2.5" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
function GlassIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M4 4h16l-8 8-8-8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M12 12v8M8 20h8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function StarIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="m12 3.6 2.42 4.9 5.41.79-3.92 3.81.93 5.39L12 15.94l-4.84 2.55.93-5.39-3.92-3.81 5.41-.79L12 3.6Z" />
</svg>
);
}
function ClockIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 7.5V12l3 2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function WalkIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="13" cy="4.2" r="2" stroke="currentColor" strokeWidth="1.6" />
<path
d="M13.5 8.2 10 10.4l-1.3 4M13.5 8.2l3 2.3.8 3.3M13.5 8.2l-.6 5.3 2.4 2.7.9 4.3M12.9 13.5 9.6 16l-1.5 4.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CopyIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<rect
x="9"
y="9"
width="11"
height="11"
rx="2.5"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M15 6.5A2.5 2.5 0 0 0 12.5 4h-6A2.5 2.5 0 0 0 4 6.5v6A2.5 2.5 0 0 0 6.5 15"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="m5 12.5 4.5 4.5L19 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
type CategoryMeta = {
label: string;
dot: string;
chipIcon: string;
pinFill: string;
pulse: string;
Icon: (props: IconProps) => ReactElement;
};
const CATEGORIES: Record<Category, CategoryMeta> = {
coffee: {
label: "Coffee",
dot: "bg-amber-500 dark:bg-amber-400",
chipIcon: "text-amber-600 dark:text-amber-400",
pinFill: "fill-amber-500 dark:fill-amber-400",
pulse: "bg-amber-500/40 dark:bg-amber-400/40",
Icon: CoffeeIcon,
},
food: {
label: "Food",
dot: "bg-rose-500 dark:bg-rose-400",
chipIcon: "text-rose-600 dark:text-rose-400",
pinFill: "fill-rose-500 dark:fill-rose-400",
pulse: "bg-rose-500/40 dark:bg-rose-400/40",
Icon: FoodIcon,
},
shop: {
label: "Shops",
dot: "bg-sky-500 dark:bg-sky-400",
chipIcon: "text-sky-600 dark:text-sky-400",
pinFill: "fill-sky-500 dark:fill-sky-400",
pulse: "bg-sky-500/40 dark:bg-sky-400/40",
Icon: ShopIcon,
},
outdoors: {
label: "Outdoors",
dot: "bg-emerald-500 dark:bg-emerald-400",
chipIcon: "text-emerald-600 dark:text-emerald-400",
pinFill: "fill-emerald-500 dark:fill-emerald-400",
pulse: "bg-emerald-500/40 dark:bg-emerald-400/40",
Icon: TreeIcon,
},
music: {
label: "Music",
dot: "bg-violet-500 dark:bg-violet-400",
chipIcon: "text-violet-600 dark:text-violet-400",
pinFill: "fill-violet-500 dark:fill-violet-400",
pulse: "bg-violet-500/40 dark:bg-violet-400/40",
Icon: MusicIcon,
},
nightlife: {
label: "Nightlife",
dot: "bg-indigo-500 dark:bg-indigo-400",
chipIcon: "text-indigo-600 dark:text-indigo-400",
pinFill: "fill-indigo-500 dark:fill-indigo-400",
pulse: "bg-indigo-500/40 dark:bg-indigo-400/40",
Icon: GlassIcon,
},
};
const FILTERS: Array<{ key: Category | "all"; label: string }> = [
{ key: "all", label: "All stops" },
{ key: "coffee", label: "Coffee" },
{ key: "food", label: "Food" },
{ key: "shop", label: "Shops" },
{ key: "outdoors", label: "Outdoors" },
{ key: "music", label: "Music" },
{ key: "nightlife", label: "Nightlife" },
];
const BLOCKS: Array<{ x: number; y: number; w: number; h: number }> = [
{ x: 34, y: 34, w: 132, h: 102 },
{ x: 194, y: 34, w: 172, h: 102 },
{ x: 394, y: 34, w: 88, h: 102 },
{ x: 496, y: 34, w: 90, h: 102 },
{ x: 34, y: 164, w: 132, h: 122 },
{ x: 194, y: 164, w: 172, h: 122 },
{ x: 394, y: 164, w: 192, h: 122 },
{ x: 614, y: 164, w: 152, h: 122 },
{ x: 34, y: 314, w: 132, h: 122 },
{ x: 194, y: 314, w: 172, h: 56 },
{ x: 194, y: 382, w: 172, h: 54 },
{ x: 394, y: 314, w: 192, h: 122 },
{ x: 614, y: 314, w: 152, h: 122 },
{ x: 194, y: 464, w: 172, h: 102 },
{ x: 394, y: 464, w: 192, h: 102 },
{ x: 614, y: 464, w: 152, h: 102 },
];
export default function MapPinsList() {
const reduce = useReducedMotion();
const rawId = useId();
const uid = rawId.replace(/[^a-zA-Z0-9]/g, "");
const [filter, setFilter] = useState<Category | "all">("all");
const [selectedId, setSelectedId] = useState<string>("ferrous");
const [copied, setCopied] = useState(false);
const optionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
};
}, []);
const visible = useMemo(
() => (filter === "all" ? PLACES : PLACES.filter((p) => p.category === filter)),
[filter],
);
const selected: Place = PLACES.find((p) => p.id === selectedId) ?? PLACES[0];
const selectedMeta = CATEGORIES[selected.category];
const applyFilter = (key: Category | "all") => {
setFilter(key);
const next = key === "all" ? PLACES : PLACES.filter((p) => p.category === key);
if (!next.some((p) => p.id === selectedId) && next.length > 0) {
setSelectedId(next[0].id);
}
};
const selectFromPin = (id: string) => {
setSelectedId(id);
const el = optionRefs.current[id];
if (el) {
el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
}
};
const onListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (visible.length === 0) return;
const idx = visible.findIndex((p) => p.id === selectedId);
const last = visible.length - 1;
let target = -1;
if (e.key === "ArrowDown" || e.key === "ArrowRight") target = idx >= last ? 0 : idx + 1;
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") target = idx <= 0 ? last : idx - 1;
else if (e.key === "Home") target = 0;
else if (e.key === "End") target = last;
if (target < 0) return;
e.preventDefault();
const next = visible[target];
setSelectedId(next.id);
optionRefs.current[next.id]?.focus();
};
const copyCoords = async () => {
if (typeof navigator === "undefined" || !navigator.clipboard) return;
try {
await navigator.clipboard.writeText(`${selected.name} — ${selected.coords}`);
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
return (
<section className="relative w-full bg-white px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes mplPinPulse-mpl {
0% { transform: scale(0.65); opacity: 0.5; }
70% { transform: scale(2.1); opacity: 0; }
100% { transform: scale(2.1); opacity: 0; }
}
@keyframes mplRouteFlow-mpl {
to { stroke-dashoffset: -36; }
}
.mpl-pulse {
animation: mplPinPulse-mpl 2.4s cubic-bezier(0.2, 0.6, 0.3, 1) infinite;
}
.mpl-route {
stroke-dasharray: 10 8;
animation: mplRouteFlow-mpl 1.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.mpl-pulse, .mpl-route { animation: none !important; }
.mpl-pulse { opacity: 0.3; transform: scale(1.35); }
}
`}</style>
<div className="mx-auto w-full 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">
Neighbourhood guide
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Eight stops worth the walk in Mill Row
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Every pin is a place someone on the team has actually been to twice. Pick one on the
map or work down the list — the two stay in sync, and the whole thing runs on the
keyboard.
</p>
</header>
<div
role="group"
aria-label="Filter places by category"
className="mt-8 flex flex-wrap gap-2"
>
{FILTERS.map((f) => {
const active = filter === f.key;
const meta = f.key === "all" ? null : CATEGORIES[f.key];
return (
<button
key={f.key}
type="button"
aria-pressed={active}
onClick={() => applyFilter(f.key)}
className={[
"inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
"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-950",
active
? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-800",
].join(" ")}
>
{meta ? (
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${meta.dot}`} />
) : null}
{f.label}
</button>
);
})}
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-5">
{/* Map */}
<div className="lg:col-span-3">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="relative aspect-[4/3] w-full">
<svg
viewBox="0 0 800 600"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<defs>
<pattern
id={`${uid}-dots`}
width="22"
height="22"
patternUnits="userSpaceOnUse"
>
<circle
cx="2"
cy="2"
r="1"
className="fill-slate-300/70 dark:fill-slate-700/70"
/>
</pattern>
</defs>
<rect
width="800"
height="600"
className="fill-slate-100 dark:fill-slate-900"
/>
<rect width="800" height="600" fill={`url(#${uid}-dots)`} />
{/* water */}
<path
d="M0 600 L0 396 C 112 448 184 520 252 600 Z"
className="fill-sky-200/90 dark:fill-sky-950"
/>
<path
d="M0 396 C 112 448 184 520 252 600"
fill="none"
strokeWidth="2"
className="stroke-sky-300 dark:stroke-sky-900"
/>
{/* park */}
<rect
x="608"
y="28"
width="164"
height="112"
rx="12"
className="fill-emerald-100 dark:fill-emerald-950"
/>
<circle cx="640" cy="112" r="9" className="fill-emerald-300/70 dark:fill-emerald-900" />
<circle cx="666" cy="122" r="6" className="fill-emerald-300/70 dark:fill-emerald-900" />
<circle cx="744" cy="66" r="8" className="fill-emerald-300/70 dark:fill-emerald-900" />
{/* blocks */}
{BLOCKS.map((b) => (
<rect
key={`${b.x}-${b.y}-${b.h}`}
x={b.x}
y={b.y}
width={b.w}
height={b.h}
rx="6"
className="fill-white stroke-slate-200 dark:fill-slate-800/70 dark:stroke-slate-700/60"
strokeWidth="1"
/>
))}
{/* road casing */}
<g
fill="none"
strokeWidth="18"
strokeLinecap="round"
className="stroke-slate-200 dark:stroke-slate-700/70"
>
<path d="M0 150 H800" />
<path d="M0 300 H800" />
<path d="M0 450 H800" />
<path d="M180 0 V600" />
<path d="M380 0 V600" />
<path d="M600 0 V600" />
</g>
{/* road surface */}
<g
fill="none"
strokeWidth="13"
strokeLinecap="round"
className="stroke-white dark:stroke-slate-800"
>
<path d="M0 150 H800" />
<path d="M0 300 H800" />
<path d="M0 450 H800" />
<path d="M180 0 V600" />
<path d="M380 0 V600" />
<path d="M600 0 V600" />
</g>
{/* walking route */}
<path
d="M24 566 C 176 548 208 380 380 300 C 520 236 640 252 792 188"
fill="none"
strokeWidth="3"
strokeLinecap="round"
className="mpl-route stroke-violet-500/70 dark:stroke-violet-400/60"
/>
{/* labels */}
<g
fontSize="11"
fontWeight="700"
letterSpacing="1.8"
className="fill-slate-400 dark:fill-slate-600"
>
<text x="42" y="296">MILL ROW</text>
<text x="490" y="228" textAnchor="middle">OLD TOWN</text>
<text x="280" y="492" textAnchor="middle">PRINT QUARTER</text>
</g>
<text
x="92"
y="512"
fontSize="11"
fontWeight="700"
letterSpacing="1.8"
transform="rotate(-52 92 512)"
className="fill-sky-500/80 dark:fill-sky-700"
>
THE CUT
</text>
<text
x="690"
y="52"
fontSize="11"
fontWeight="700"
letterSpacing="1.8"
textAnchor="middle"
className="fill-emerald-600/80 dark:fill-emerald-600"
>
HOLLIS GREEN
</text>
</svg>
{/* pins */}
<AnimatePresence initial={false}>
{visible.map((p) => {
const meta = CATEGORIES[p.category];
const isSel = p.id === selected.id;
return (
<motion.div
key={p.id}
className="absolute"
style={{ left: `${p.x}%`, top: `${p.y}%`, zIndex: isSel ? 30 : 10 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.18 }}
>
<div className="relative -translate-x-1/2 -translate-y-full">
{isSel ? (
<div className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 pb-1.5">
<motion.span
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
className="block whitespace-nowrap rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white shadow-lg dark:bg-slate-100 dark:text-slate-900"
>
{p.name}
</motion.span>
</div>
) : null}
<motion.button
type="button"
onClick={() => selectFromPin(p.id)}
aria-label={`${p.name}, ${meta.label}. ${
isSel ? "Selected stop." : "Select this stop."
}`}
aria-current={isSel ? "true" : undefined}
initial={reduce ? false : { scale: 0.6, y: -10 }}
animate={{ scale: isSel ? 1.14 : 1, y: 0 }}
whileHover={reduce ? undefined : { scale: isSel ? 1.2 : 1.1, y: -2 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 430, damping: 24 }
}
className="relative block cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-slate-100 dark:focus-visible:ring-offset-slate-900"
>
{isSel ? (
<span
className={`mpl-pulse pointer-events-none absolute left-1/2 top-[13px] -ml-3.5 -mt-3.5 h-7 w-7 rounded-full ${meta.pulse}`}
/>
) : null}
<svg
viewBox="0 0 28 38"
className="relative block h-9 w-7 drop-shadow-sm"
aria-hidden="true"
>
<path
d="M14 37.2C14 37.2 26.4 22.6 26.4 14A12.4 12.4 0 1 0 1.6 14C1.6 22.6 14 37.2 14 37.2Z"
strokeWidth="1.6"
className={
isSel
? `${meta.pinFill} stroke-white dark:stroke-slate-900`
: "fill-white stroke-slate-300 dark:fill-slate-800 dark:stroke-slate-600"
}
/>
<text
x="14"
y="18.4"
textAnchor="middle"
fontSize="12"
fontWeight="700"
className={
isSel
? "fill-white dark:fill-slate-950"
: "fill-slate-500 dark:fill-slate-300"
}
>
{p.n}
</text>
</svg>
</motion.button>
</div>
</motion.div>
);
})}
</AnimatePresence>
{visible.length === 0 ? (
<div className="absolute inset-x-0 bottom-4 flex justify-center">
<p className="rounded-full bg-slate-900/90 px-3 py-1.5 text-xs font-medium text-white dark:bg-slate-100/90 dark:text-slate-900">
No pins in this category.
</p>
</div>
) : null}
{/* scale bar */}
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-md bg-white/85 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-slate-500 backdrop-blur-sm dark:bg-slate-900/85 dark:text-slate-400">
<span className="block h-[3px] w-10 rounded-full bg-slate-400 dark:bg-slate-600" />
200 m
</div>
</div>
{/* detail strip */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white dark:bg-slate-100 dark:text-slate-900">
{selected.n}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{selected.name}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${selectedMeta.dot}`} />
{selectedMeta.label}
<span aria-hidden="true">·</span>
<span className="truncate">{selected.hours}</span>
</p>
</div>
</div>
<div className="flex items-center gap-3">
<code className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{selected.coords}
</code>
<button
type="button"
onClick={copyCoords}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-50 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-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
{copied ? (
<CheckIcon className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
) : (
<CopyIcon className="h-3.5 w-3.5" />
)}
{copied ? "Copied" : "Copy"}
</button>
<span role="status" aria-live="polite" className="sr-only">
{copied ? `Coordinates for ${selected.name} copied to clipboard` : ""}
</span>
</div>
</div>
</div>
</div>
{/* List */}
<div className="lg:col-span-2">
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-baseline justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<h3 className="text-sm font-semibold">Pinned places</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
{visible.length} of {PLACES.length}
</p>
</div>
<div
role="listbox"
aria-label="Pinned places in Mill Row"
onKeyDown={onListKeyDown}
className="max-h-[26rem] overflow-y-auto p-2 lg:max-h-[32rem]"
>
{visible.map((p) => {
const meta = CATEGORIES[p.category];
const isSel = p.id === selected.id;
const Icon = meta.Icon;
return (
<div
key={p.id}
ref={(el) => {
optionRefs.current[p.id] = el;
}}
role="option"
aria-selected={isSel}
tabIndex={isSel ? 0 : -1}
onClick={() => setSelectedId(p.id)}
className={[
"mb-1.5 cursor-pointer rounded-xl border p-3 transition-colors last:mb-0",
"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",
isSel
? "border-slate-900 bg-slate-50 dark:border-slate-100 dark:bg-slate-800"
: "border-transparent hover:bg-slate-50 dark:hover:bg-slate-800/60",
].join(" ")}
>
<div className="flex gap-3">
<span
className={[
"mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
isSel
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
].join(" ")}
aria-hidden="true"
>
{p.n}
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold">{p.name}</p>
<span
className={`inline-flex shrink-0 items-center gap-1 text-[11px] font-medium ${meta.chipIcon}`}
>
<Icon className="h-3.5 w-3.5" />
{meta.label}
</span>
</div>
<p className="mt-1 text-xs leading-relaxed text-slate-600 dark:text-slate-400">
{p.blurb}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1">
<WalkIcon className="h-3.5 w-3.5" />
{p.walk} min walk
</span>
<span className="inline-flex items-center gap-1">
<ClockIcon className="h-3.5 w-3.5" />
{p.hours}
</span>
<span className="inline-flex items-center gap-1">
<StarIcon className="h-3.5 w-3.5 text-amber-500 dark:text-amber-400" />
{p.rating.toFixed(1)}
</span>
</div>
</div>
</div>
</div>
);
})}
{visible.length === 0 ? (
<p className="px-2 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
Nothing pinned under this category yet.
</p>
) : null}
</div>
<p className="border-t border-slate-200 px-4 py-3 text-[11px] leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-400">
Arrow keys move through the list, Home and End jump to the ends, and every pin on
the map is a real button you can tab to.
</p>
</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 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 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.

