Map Store Locator
Original · freestore locator list plus 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-store-locator.json"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactElement,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type FeatureKey = "roastery" | "parking" | "stepFree" | "outdoor";
type DayHours = [number, number] | null;
type Store = {
id: string;
name: string;
area: string;
address: string;
postcode: string;
phone: string;
miles: number;
x: number;
y: number;
features: FeatureKey[];
hours: [DayHours, DayHours, DayHours, DayHours, DayHours, DayHours, DayHours];
note: string;
};
type SortKey = "nearest" | "name" | "open";
const MAP_W = 100;
const MAP_H = 80;
const ZOOM = 1.6;
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const WEEKDAY: DayHours = [420, 1080];
const SAT: DayHours = [480, 1080];
const SUN: DayHours = [540, 960];
const STORES: Store[] = [
{
id: "ancoats",
name: "Northline Ancoats Roastery",
area: "Ancoats",
address: "Unit 4, Cutting Room Square, Blossom Street",
postcode: "M4 6BF",
phone: "0161 232 4180",
miles: 0.6,
x: 62,
y: 29,
features: ["roastery", "stepFree", "outdoor"],
hours: [SUN, [420, 1140], [420, 1140], [420, 1140], [420, 1140], [420, 1140], SAT],
note: "The production floor is glazed end to end — you can watch the 25kg Loring run from the counter. Free cupping table every Saturday at 11am, first come first served.",
},
{
id: "northern-quarter",
name: "Northline Edge Street",
area: "Northern Quarter",
address: "18 Edge Street",
postcode: "M4 1HN",
phone: "0161 232 4181",
miles: 0.4,
x: 47,
y: 31,
features: ["stepFree", "outdoor"],
hours: [SUN, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, [420, 1140], SAT],
note: "Our smallest bar and the busiest between 8am and 9.30am. Two seats at the window, six on the pavement, and a filter batch brewed every 20 minutes.",
},
{
id: "deansgate",
name: "Northline St John's",
area: "Deansgate",
address: "St John's, 2 Water Street",
postcode: "M3 4JQ",
phone: "0161 232 4182",
miles: 0.9,
x: 38,
y: 43,
features: ["stepFree", "parking", "outdoor"],
hours: [SUN, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, [420, 1140], SAT],
note: "Ground floor of the old goods yard. Long benches, plenty of sockets, and a 90-minute laptop limit after midday on weekends.",
},
{
id: "salford-quays",
name: "Northline Dock Office",
area: "Salford Quays",
address: "The Dock Office, Trafford Road",
postcode: "M50 3XB",
phone: "0161 232 4183",
miles: 2.8,
x: 16,
y: 35,
features: ["parking", "stepFree"],
hours: [null, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, [480, 960]],
note: "Closed Sundays. Ninety free parking spaces behind the building, validated at the till for any order over £4.",
},
{
id: "chorlton",
name: "Northline Beech Road",
area: "Chorlton",
address: "72 Beech Road",
postcode: "M21 9EG",
phone: "0161 232 4184",
miles: 3.6,
x: 25,
y: 61,
features: ["outdoor", "stepFree"],
hours: [[480, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1080], [480, 1080]],
note: "Nine tables in the back garden, heated until the end of October. Dogs welcome inside and out; water bowls by the door.",
},
{
id: "didsbury",
name: "Northline Old Bank",
area: "Didsbury",
address: "The Old Bank, 726 Wilmslow Road",
postcode: "M20 2DW",
phone: "0161 232 4185",
miles: 5.2,
x: 40,
y: 71,
features: ["parking", "outdoor"],
hours: [[540, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1020], [480, 1080]],
note: "A 1904 banking hall with the vault door still in place. Step-free entry is via the side lane on Warburton Street — ring the bell and we will open it.",
},
{
id: "stockport",
name: "Northline Produce Hall",
area: "Stockport",
address: "Produce Hall, Market Place",
postcode: "SK1 1EU",
phone: "0161 232 4186",
miles: 6.1,
x: 82,
y: 65,
features: ["roastery", "stepFree", "parking"],
hours: [[600, 960], null, [540, 1020], [540, 1020], [540, 1020], [540, 1080], [480, 1080]],
note: "Closed Mondays. Our sample roaster lives here — Wednesday evenings are open to anyone who wants to learn to roast on 200g batches.",
},
];
const FEATURE_LABEL: Record<FeatureKey, string> = {
roastery: "Roastery tours",
parking: "Free parking",
stepFree: "Step-free access",
outdoor: "Outdoor seating",
};
const BLOCKS: { x: number; y: number; w: number; h: number }[] = [
{ x: 5, y: 4, w: 10, h: 8 },
{ x: 20, y: 5, w: 12, h: 7 },
{ x: 37, y: 3, w: 10, h: 9 },
{ x: 52, y: 5, w: 9, h: 7 },
{ x: 66, y: 3, w: 11, h: 8 },
{ x: 82, y: 6, w: 12, h: 7 },
{ x: 20, y: 22, w: 9, h: 7 },
{ x: 35, y: 20, w: 8, h: 8 },
{ x: 50, y: 22, w: 10, h: 6 },
{ x: 66, y: 21, w: 9, h: 8 },
{ x: 80, y: 34, w: 13, h: 9 },
{ x: 63, y: 38, w: 10, h: 7 },
{ x: 48, y: 36, w: 8, h: 6 },
{ x: 8, y: 46, w: 9, h: 7 },
{ x: 34, y: 54, w: 10, h: 8 },
{ x: 50, y: 62, w: 9, h: 7 },
{ x: 64, y: 56, w: 11, h: 8 },
{ x: 14, y: 68, w: 12, h: 8 },
{ x: 78, y: 70, w: 14, h: 7 },
];
const ROADS: string[] = [
"M -2 17 L 102 13",
"M -2 33 L 102 29",
"M -2 51 L 102 49",
"M -2 67 L 102 70",
"M 16 -2 L 21 82",
"M 32 -2 L 35 82",
"M 45 -2 L 43 82",
"M 59 -2 L 56 82",
"M 73 -2 L 77 82",
"M 87 -2 L 90 82",
];
const RING =
"M 50 7 C 77 7, 93 25, 91 43 C 89 61, 69 73, 47 71 C 25 69, 9 55, 11 37 C 13 19, 27 7, 50 7 Z";
const RIVER =
"M -6 12 C 14 22, 22 30, 32 38 C 42 46, 44 50, 56 55 C 70 61, 82 63, 106 62";
const CANAL = "M 96 4 C 80 16, 74 26, 76 40 C 78 52, 72 60, 60 66";
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function fmtTime(mins: number): string {
const h = Math.floor(mins / 60);
const m = mins % 60;
const suffix = h >= 12 ? "pm" : "am";
const h12 = h % 12 === 0 ? 12 : h % 12;
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, "0")}${suffix}`;
}
function isOpenAt(store: Store, nowMin: number): boolean {
const day = Math.floor(nowMin / 1440) % 7;
const minute = nowMin % 1440;
const slot = store.hours[day];
if (!slot) return false;
return minute >= slot[0] && minute < slot[1];
}
function directionsHref(store: Store): string {
const q = encodeURIComponent(`${store.name}, ${store.address}, ${store.postcode}, UK`);
return `https://www.google.com/maps/search/?api=1&query=${q}`;
}
function IconSearch({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="1.7" />
<path d="m16 16 4.5 4.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function IconClose({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="m7 7 10 10M17 7 7 17" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function IconClock({ className }: { className?: string }) {
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" />
</svg>
);
}
function IconPhone({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M6.2 4h3l1.4 3.5-2 1.4a11 11 0 0 0 5.5 5.5l1.4-2 3.5 1.4v3a1.6 1.6 0 0 1-1.8 1.6C10.8 18 6 13.2 4.6 5.8A1.6 1.6 0 0 1 6.2 4Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function IconArrow({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M7 17 17 7M9 7h8v8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconFlame({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M12 3s4.2 4.4 4.2 8.4a4.2 4.2 0 0 1-8.4 0c0-1.6.8-2.9 1.7-4 .3 1 .9 1.7 1.6 2 .3-2.4-.2-4.3.9-6.4Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path d="M12 20.8a4.9 4.9 0 0 0 4.9-4.9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function IconParking({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<rect x="4" y="4" width="16" height="16" rx="4" stroke="currentColor" strokeWidth="1.6" />
<path d="M10 17V8h3a2.6 2.6 0 0 1 0 5.2h-3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconAccess({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="13" cy="4.6" r="1.9" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.6 8.6v4.2h4l2.6 5.4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.9 15.7a4.8 4.8 0 1 1-5.1-5.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function IconSun({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 3.4v2M12 18.6v2M3.4 12h2M18.6 12h2M6 6l1.4 1.4M16.6 16.6 18 18M18 6l-1.4 1.4M7.4 16.6 6 18"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
const FEATURE_ICON: Record<FeatureKey, (p: { className?: string }) => ReactElement> = {
roastery: IconFlame,
parking: IconParking,
stepFree: IconAccess,
outdoor: IconSun,
};
function FeatureIcon({ id, className }: { id: FeatureKey; className?: string }) {
const Cmp = FEATURE_ICON[id];
return <Cmp className={className} />;
}
export default function MapStoreLocator() {
const reduce = useReducedMotion();
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("nearest");
const [filters, setFilters] = useState<FeatureKey[]>([]);
const [openOnly, setOpenOnly] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>("ancoats");
const [activeId, setActiveId] = useState<string>("ancoats");
const [nowMin, setNowMin] = useState<number | null>(null);
const optionRefs = useRef<Record<string, HTMLLIElement | null>>({});
useEffect(() => {
const d = new Date();
setNowMin(d.getDay() * 1440 + d.getHours() * 60 + d.getMinutes());
}, []);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = STORES.filter((s) => {
if (q) {
const hay = `${s.name} ${s.area} ${s.address} ${s.postcode}`.toLowerCase();
if (!hay.includes(q)) return false;
}
if (openOnly && nowMin !== null && !isOpenAt(s, nowMin)) return false;
for (const f of filters) {
if (!s.features.includes(f)) return false;
}
return true;
});
const sorted = [...filtered];
if (sort === "name") {
sorted.sort((a, b) => a.name.localeCompare(b.name));
} else if (sort === "open" && nowMin !== null) {
sorted.sort((a, b) => {
const oa = isOpenAt(a, nowMin) ? 0 : 1;
const ob = isOpenAt(b, nowMin) ? 0 : 1;
return oa - ob || a.miles - b.miles;
});
} else {
sorted.sort((a, b) => a.miles - b.miles);
}
return sorted;
}, [query, sort, filters, openOnly, nowMin]);
useEffect(() => {
if (results.length === 0) return;
if (!results.some((s) => s.id === activeId)) {
setActiveId(results[0].id);
}
}, [results, activeId]);
useEffect(() => {
if (selectedId && !results.some((s) => s.id === selectedId)) {
setSelectedId(results.length > 0 ? results[0].id : null);
}
}, [results, selectedId]);
useEffect(() => {
const el = optionRefs.current[activeId];
if (el) el.scrollIntoView({ block: "nearest" });
}, [activeId]);
const selected = useMemo(
() => STORES.find((s) => s.id === selectedId) ?? null,
[selectedId],
);
const zoom = selected ? ZOOM : 1;
const fx = selected ? selected.x / MAP_W : 0.5;
const fy = selected ? selected.y / MAP_H : 0.5;
const tx = clamp((0.5 - zoom * fx) * 100, -(zoom - 1) * 100, 0);
const ty = clamp((0.5 - zoom * fy) * 100, -(zoom - 1) * 100, 0);
const mapTransition = reduce
? { duration: 0 }
: ({ type: "spring", stiffness: 130, damping: 24, mass: 0.9 } as const);
const toggleFilter = useCallback((key: FeatureKey) => {
setFilters((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}, []);
const pick = useCallback((id: string) => {
setActiveId(id);
setSelectedId(id);
}, []);
const onListKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
if (results.length === 0) return;
const i = results.findIndex((s) => s.id === activeId);
const current = i < 0 ? 0 : i;
let next = -1;
if (e.key === "ArrowDown") next = Math.min(current + 1, results.length - 1);
else if (e.key === "ArrowUp") next = Math.max(current - 1, 0);
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = results.length - 1;
else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
pick(results[current].id);
return;
} else {
return;
}
e.preventDefault();
pick(results[next].id);
};
const clearAll = () => {
setQuery("");
setFilters([]);
setOpenOnly(false);
setSort("nearest");
};
const hasFilters = query.trim() !== "" || filters.length > 0 || openOnly;
const todayIndex = nowMin === null ? -1 : Math.floor(nowMin / 1440) % 7;
return (
<section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes mslx-pulse {
0% { transform: scale(0.5); opacity: 0.5; }
70% { transform: scale(2.4); opacity: 0; }
100% { transform: scale(2.4); opacity: 0; }
}
@keyframes mslx-beacon {
0%, 100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.4); }
}
.mslx-pulse-ring { animation: mslx-pulse 2.4s cubic-bezier(0.3, 0, 0.2, 1) infinite; }
.mslx-beacon {
transform-box: fill-box;
transform-origin: center;
animation: mslx-beacon 2.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.mslx-pulse-ring, .mslx-beacon { animation: none !important; }
}
`}</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">
Northline Coffee Roasters
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
Seven bars across Greater Manchester
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Search by area or postcode, filter for what you need, and pick a pin to see the
address, today’s hours and how to get there. Distances are measured from
Manchester Piccadilly.
</p>
</header>
<div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* ---------------- Controls + list ---------------- */}
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-5 dark:border-zinc-800 dark:bg-zinc-900">
<label htmlFor="mslx-search" className="sr-only">
Search Northline locations by name, area or postcode
</label>
<div className="relative">
<IconSearch className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-zinc-500" />
<input
id="mslx-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ancoats, M21, Beech Road…"
autoComplete="off"
className="w-full rounded-xl border border-slate-300 bg-slate-50 py-2.5 pl-10 pr-9 text-sm text-slate-900 placeholder:text-slate-400 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-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:ring-offset-zinc-900"
/>
{query !== "" ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
<IconClose className="h-3.5 w-3.5" />
<span className="sr-only">Clear search</span>
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
aria-pressed={openOnly}
onClick={() => setOpenOnly((v) => !v)}
disabled={nowMin === null}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs 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 disabled:opacity-50 dark:focus-visible:ring-offset-zinc-900 ${
openOnly
? "border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500 dark:text-zinc-950"
: "border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
}`}
>
<IconClock className="h-3.5 w-3.5" />
Open now
</button>
{(Object.keys(FEATURE_LABEL) as FeatureKey[]).map((key) => {
const on = filters.includes(key);
return (
<button
key={key}
type="button"
aria-pressed={on}
onClick={() => toggleFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs 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 ${
on
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500 dark:text-zinc-950"
: "border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
}`}
>
<FeatureIcon id={key} className="h-3.5 w-3.5" />
{FEATURE_LABEL[key]}
</button>
);
})}
</div>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-slate-200 pt-3 dark:border-zinc-800">
<p className="text-xs text-slate-600 dark:text-zinc-400">
<span className="font-semibold text-slate-900 dark:text-zinc-100">
{results.length}
</span>{" "}
of {STORES.length} locations
</p>
<div className="flex items-center gap-2">
<label
htmlFor="mslx-sort"
className="text-xs font-medium text-slate-600 dark:text-zinc-400"
>
Sort
</label>
<select
id="mslx-sort"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded-lg border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-800 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-zinc-700 dark:bg-zinc-950 dark:text-zinc-200 dark:focus-visible:ring-offset-zinc-900"
>
<option value="nearest">Nearest</option>
<option value="name">Name A–Z</option>
<option value="open">Open first</option>
</select>
</div>
</div>
<p aria-live="polite" className="sr-only">
{results.length === 0
? "No locations match your filters."
: `${results.length} location${results.length === 1 ? "" : "s"} listed.`}
</p>
{results.length === 0 ? (
<div className="mt-4 rounded-xl border border-dashed border-slate-300 px-4 py-10 text-center dark:border-zinc-700">
<p className="text-sm font-medium text-slate-800 dark:text-zinc-200">
Nothing matches that yet
</p>
<p className="mx-auto mt-1 max-w-xs text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
Try a wider search — every bar sits within six miles of Piccadilly.
</p>
<button
type="button"
onClick={clearAll}
className="mt-4 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 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-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
Reset filters
</button>
</div>
) : (
<ul
role="listbox"
aria-label="Northline locations"
tabIndex={0}
aria-activedescendant={
results.some((s) => s.id === activeId) ? `mslx-opt-${activeId}` : undefined
}
onKeyDown={onListKeyDown}
className="mt-3 max-h-[24rem] space-y-1.5 overflow-y-auto rounded-xl p-1 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"
>
{results.map((s) => {
const isSelected = s.id === selectedId;
const isActive = s.id === activeId;
const open = nowMin !== null && isOpenAt(s, nowMin);
const today = todayIndex >= 0 ? s.hours[todayIndex] : null;
return (
<li
key={s.id}
id={`mslx-opt-${s.id}`}
role="option"
aria-selected={isSelected}
ref={(el) => {
optionRefs.current[s.id] = el;
}}
onClick={() => pick(s.id)}
className={`cursor-pointer rounded-xl border px-3 py-2.5 transition-colors ${
isSelected
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400/70 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/60"
} ${isActive ? "ring-2 ring-indigo-400/70 ring-offset-1 ring-offset-white dark:ring-indigo-400/60 dark:ring-offset-zinc-900" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-zinc-50">
{s.area}
</p>
<p className="mt-0.5 truncate text-xs text-slate-600 dark:text-zinc-400">
{s.address}
</p>
</div>
<span className="shrink-0 rounded-md bg-slate-100 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-slate-700 dark:bg-zinc-800 dark:text-zinc-300">
{s.miles.toFixed(1)} mi
</span>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px]">
{nowMin === null ? (
<span className="text-slate-500 dark:text-zinc-500">
{s.postcode}
</span>
) : (
<>
<span
className={`inline-flex items-center gap-1 font-semibold ${
open
? "text-emerald-700 dark:text-emerald-400"
: "text-rose-700 dark:text-rose-400"
}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${
open ? "bg-emerald-500" : "bg-rose-500"
}`}
/>
{open ? "Open" : "Closed"}
</span>
<span className="text-slate-500 dark:text-zinc-500">
{today
? `${fmtTime(today[0])} – ${fmtTime(today[1])} today`
: "Closed today"}
</span>
</>
)}
</div>
</li>
);
})}
</ul>
)}
{hasFilters ? (
<button
type="button"
onClick={clearAll}
className="mt-3 text-xs font-semibold text-indigo-700 underline underline-offset-4 hover:text-indigo-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-900"
>
Clear search and filters
</button>
) : null}
</div>
{/* ---------------- Map + detail ---------------- */}
<div className="space-y-4">
<div
role="group"
aria-label="Map of Northline locations. Each pin is a button."
className="relative aspect-[5/4] w-full overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 shadow-sm sm:aspect-[16/10] dark:border-zinc-800 dark:bg-zinc-900"
>
<motion.div
className="absolute inset-0"
style={{ transformOrigin: "0% 0%" }}
animate={{ x: `${tx}%`, y: `${ty}%`, scale: zoom }}
transition={mapTransition}
>
<svg
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
className="h-full w-full"
>
<rect
x="0"
y="0"
width={MAP_W}
height={MAP_H}
className="fill-slate-100 dark:fill-zinc-900"
/>
{BLOCKS.map((b) => (
<rect
key={`${b.x}-${b.y}`}
x={b.x}
y={b.y}
width={b.w}
height={b.h}
rx="1.2"
className="fill-slate-200/80 dark:fill-zinc-800/80"
/>
))}
<ellipse
cx="26"
cy="59"
rx="10"
ry="6"
className="fill-emerald-200/70 dark:fill-emerald-900/40"
/>
<ellipse
cx="76"
cy="20"
rx="11"
ry="7"
className="fill-emerald-200/70 dark:fill-emerald-900/40"
/>
<path
d={RIVER}
fill="none"
strokeWidth="5.5"
strokeLinecap="round"
className="stroke-sky-200 dark:stroke-sky-950"
/>
<path
d={CANAL}
fill="none"
strokeWidth="1.6"
strokeLinecap="round"
className="stroke-sky-300/80 dark:stroke-sky-900"
/>
{ROADS.map((d) => (
<path
key={d}
d={d}
fill="none"
strokeWidth="1.4"
strokeLinecap="round"
className="stroke-white dark:stroke-zinc-700"
/>
))}
<path
d={RING}
fill="none"
strokeWidth="2.2"
strokeLinecap="round"
className="stroke-amber-300/70 dark:stroke-amber-800/70"
/>
<circle cx="53" cy="37" r="4.5" className="mslx-beacon fill-indigo-500/30" />
<circle cx="53" cy="37" r="1.5" className="fill-indigo-600 dark:fill-indigo-400" />
</svg>
{STORES.map((s) => {
const isSelected = s.id === selectedId;
const visible = results.some((r) => r.id === s.id);
return (
<div
key={s.id}
className="absolute -translate-x-1/2 -translate-y-full"
style={{
left: `${(s.x / MAP_W) * 100}%`,
top: `${(s.y / MAP_H) * 100}%`,
}}
>
<motion.button
type="button"
onClick={() => pick(s.id)}
aria-pressed={isSelected}
aria-label={`${s.area} — ${s.name}, ${s.postcode}${
visible ? "" : " (filtered out)"
}`}
animate={{ scale: 1 / zoom, opacity: visible ? 1 : 0.3 }}
transition={mapTransition}
className="relative block origin-bottom rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
{isSelected ? (
<span
aria-hidden="true"
className="mslx-pulse-ring absolute bottom-0 left-1/2 h-7 w-7 -translate-x-1/2 translate-y-1/2 rounded-full bg-indigo-500/45"
/>
) : null}
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={`relative h-8 w-8 drop-shadow-sm transition-transform ${
isSelected ? "scale-110" : "hover:scale-105"
}`}
>
<path
d="M12 22.5S19 15.6 19 10.4A7 7 0 1 0 5 10.4C5 15.6 12 22.5 12 22.5Z"
className={
isSelected
? "fill-indigo-600 stroke-indigo-800 dark:fill-indigo-400 dark:stroke-indigo-200"
: "fill-white stroke-slate-400 dark:fill-zinc-800 dark:stroke-zinc-500"
}
strokeWidth="1.1"
/>
<circle
cx="12"
cy="10.2"
r="2.7"
className={
isSelected
? "fill-white dark:fill-zinc-950"
: "fill-indigo-500 dark:fill-indigo-400"
}
/>
</svg>
{isSelected ? (
<span className="absolute left-1/2 top-full mt-0.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow dark:bg-zinc-100 dark:text-zinc-900">
{s.area}
</span>
) : null}
</motion.button>
</div>
);
})}
</motion.div>
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between p-3">
<span className="pointer-events-auto rounded-lg bg-white/90 px-2.5 py-1 text-[11px] font-medium text-slate-700 shadow-sm backdrop-blur dark:bg-zinc-950/85 dark:text-zinc-300">
Illustrative map — not to scale
</span>
{selected ? (
<button
type="button"
onClick={() => setSelectedId(null)}
className="pointer-events-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white/95 px-2.5 py-1 text-[11px] font-semibold text-slate-800 shadow-sm backdrop-blur hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:border-zinc-700 dark:bg-zinc-950/90 dark:text-zinc-200 dark:hover:bg-zinc-950 dark:focus-visible:ring-offset-zinc-900"
>
<IconClose className="h-3 w-3" />
Show all pins
</button>
) : null}
</div>
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-3 rounded-lg bg-white/90 px-2.5 py-1.5 text-[11px] text-slate-600 shadow-sm backdrop-blur dark:bg-zinc-950/85 dark:text-zinc-400">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-2 w-2 rounded-full bg-indigo-600 dark:bg-indigo-400" />
Piccadilly
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-2 w-3.5 rounded-sm bg-amber-300 dark:bg-amber-800" />
Ring road
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-2 w-3.5 rounded-sm bg-sky-200 dark:bg-sky-950" />
Irwell
</span>
</div>
</div>
{/* ---------------- Detail ---------------- */}
{selected ? (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
{selected.name}
</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-zinc-400">
{selected.address}, {selected.postcode} ·{" "}
<span className="tabular-nums">{selected.miles.toFixed(1)} mi</span> from
Piccadilly
</p>
</div>
<a
href={directionsHref(selected)}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1.5 rounded-xl bg-indigo-600 px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:text-zinc-950 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
Get directions
<IconArrow className="h-4 w-4" />
</a>
</div>
<p className="mt-4 text-sm leading-relaxed text-slate-700 dark:text-zinc-300">
{selected.note}
</p>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
<div>
<h4 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-500">
<IconClock className="h-3.5 w-3.5" />
Opening hours
</h4>
<dl className="mt-2 space-y-1">
{DAY_LABELS.map((day, i) => {
const slot = selected.hours[i];
const isToday = i === todayIndex;
return (
<div
key={day}
className={`flex items-center justify-between rounded-md px-2 py-0.5 text-xs ${
isToday
? "bg-indigo-50 font-semibold text-slate-900 dark:bg-indigo-500/10 dark:text-zinc-50"
: "text-slate-600 dark:text-zinc-400"
}`}
>
<dt>
{day}
{isToday ? <span className="sr-only"> (today)</span> : null}
</dt>
<dd className="tabular-nums">
{slot ? `${fmtTime(slot[0])} – ${fmtTime(slot[1])}` : "Closed"}
</dd>
</div>
);
})}
</dl>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-500">
At this bar
</h4>
<ul className="mt-2 space-y-1.5">
{selected.features.map((f) => (
<li
key={f}
className="flex items-center gap-2 text-xs text-slate-700 dark:text-zinc-300"
>
<FeatureIcon
id={f}
className="h-4 w-4 text-indigo-600 dark:text-indigo-400"
/>
{FEATURE_LABEL[f]}
</li>
))}
</ul>
<a
href={`tel:${selected.phone.replace(/\s/g, "")}`}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg text-xs font-semibold text-slate-800 underline underline-offset-4 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-200 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
<IconPhone className="h-3.5 w-3.5" />
{selected.phone}
</a>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-slate-300 bg-white/60 p-6 text-center dark:border-zinc-700 dark:bg-zinc-900/50">
<p className="text-sm font-semibold text-slate-800 dark:text-zinc-200">
Showing all {STORES.length} locations
</p>
<p className="mx-auto mt-1 max-w-sm text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
Choose a pin or a row from the list to zoom in and see hours, phone and
directions. Arrow keys work in the list.
</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 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.

