UI Components Medium
Store Locator
Store list with map placeholder, search/filter by city, distance sorting, and clickable store cards. No libraries.
Open in Lab
MCP
vanilla-js css react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f9fafb;
height: 100vh;
display: flex;
}
.sl-layout {
display: flex;
width: 100%;
height: 100%;
}
.sl-sidebar {
width: 300px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sl-search-wrap {
padding: 16px;
border-bottom: 1px solid #f3f4f6;
}
.sl-search {
width: 100%;
padding: 9px 12px;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
font-size: 13px;
outline: none;
color: #111;
transition: border-color 0.15s;
}
.sl-search:focus {
border-color: #6366f1;
}
.sl-filters {
display: flex;
gap: 6px;
padding: 10px 16px;
border-bottom: 1px solid #f3f4f6;
}
.sf-btn {
background: #f3f4f6;
border: none;
color: #6b7280;
font-size: 12px;
font-weight: 600;
padding: 5px 12px;
border-radius: 20px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sf-btn.active {
background: #6366f1;
color: #fff;
}
.sl-list {
flex: 1;
overflow-y: auto;
}
.sl-item {
padding: 14px 16px;
border-bottom: 1px solid #f9fafb;
cursor: pointer;
transition: background 0.12s;
}
.sl-item:hover {
background: #f9fafb;
}
.sl-item.active {
background: #f0f4ff;
border-left: 3px solid #6366f1;
}
.sl-item.hidden {
display: none;
}
.sl-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 3px;
}
.sl-item-name {
font-size: 13px;
font-weight: 700;
color: #111827;
}
.sl-item-dist {
font-size: 11px;
font-weight: 700;
color: #6366f1;
flex-shrink: 0;
}
.sl-item-addr {
font-size: 12px;
color: #9ca3af;
margin-bottom: 5px;
}
.sl-item-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.sl-tag {
font-size: 10px;
font-weight: 600;
background: #f3f4f6;
color: #6b7280;
padding: 2px 7px;
border-radius: 4px;
}
.sl-tag--open {
background: #dcfce7;
color: #166534;
}
.sl-tag--flagship {
background: #fef9c3;
color: #92400e;
}
/* Map */
.sl-map {
flex: 1;
position: relative;
background: #e0e7ff;
overflow: hidden;
}
.map-bg {
position: absolute;
inset: 0;
background-color: #dbeafe;
background-image: radial-gradient(ellipse 120px 80px at 25% 35%, #93c5fd 0%, transparent 70%),
radial-gradient(ellipse 90px 60px at 70% 65%, #93c5fd 0%, transparent 70%),
radial-gradient(ellipse 60px 100px at 85% 20%, #bfdbfe 0%, transparent 60%),
radial-gradient(ellipse 200px 150px at 50% 50%, #d1fae5 0%, transparent 60%),
radial-gradient(ellipse 140px 100px at 20% 70%, #d1fae5 0%, transparent 60%),
radial-gradient(ellipse 160px 110px at 80% 40%, #dcfce7 0%, transparent 60%),
linear-gradient(rgba(0, 0, 0, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.04) 1px, transparent 1px),
linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 100% 100%, 100% 100%, 80px 80px, 80px
80px, 20px 20px, 20px 20px;
}
.map-pin-el {
position: absolute;
transform: translate(-50%, -100%);
cursor: pointer;
transition: transform 0.2s;
}
.map-pin-el:hover {
transform: translate(-50%, -100%) scale(1.2);
}
.map-pin-el.active svg path {
fill: #f59e0b;
}
.selected-popup {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #fff;
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
min-width: 200px;
text-align: center;
animation: pop-in 0.2s ease;
}
@keyframes pop-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.popup-name {
font-size: 14px;
font-weight: 700;
color: #111827;
margin-bottom: 2px;
}
.popup-addr {
font-size: 12px;
color: #9ca3af;
margin-bottom: 8px;
}
.popup-link {
font-size: 12px;
color: #6366f1;
text-decoration: none;
font-weight: 600;
}const STORES = [
{
id: 1,
name: "Acme — Manhattan",
addr: "350 5th Ave, New York, NY",
dist: "0.4 km",
open: true,
flagship: true,
x: 55,
y: 42,
},
{
id: 2,
name: "Acme — SoHo",
addr: "120 Spring St, New York, NY",
dist: "1.8 km",
open: true,
flagship: false,
x: 48,
y: 60,
},
{
id: 3,
name: "Acme — Brooklyn",
addr: "1 Court Square, LIC, NY",
dist: "3.2 km",
open: false,
flagship: false,
x: 72,
y: 65,
},
{
id: 4,
name: "Acme — Hoboken",
addr: "88 River St, Hoboken, NJ",
dist: "5.1 km",
open: true,
flagship: false,
x: 28,
y: 50,
},
{
id: 5,
name: "Acme — Upper West",
addr: "2109 Broadway, New York, NY",
dist: "6.3 km",
open: true,
flagship: false,
x: 50,
y: 28,
},
];
const list = document.getElementById("slList");
const pinsEl = document.getElementById("mapPins");
const popup = document.getElementById("selectedPopup");
let activeFilter = "all";
let searchQ = "";
let activeId = null;
function renderList() {
list.innerHTML = "";
STORES.forEach((s) => {
const visible = filterMatch(s);
const div = document.createElement("div");
div.className = "sl-item" + (activeId === s.id ? " active" : "") + (visible ? "" : " hidden");
const tags = [
s.open ? '<span class="sl-tag sl-tag--open">Open now</span>' : "",
s.flagship ? '<span class="sl-tag sl-tag--flagship">Flagship</span>' : "",
]
.filter(Boolean)
.join("");
div.innerHTML = `
<div class="sl-item-header"><span class="sl-item-name">${s.name}</span><span class="sl-item-dist">${s.dist}</span></div>
<p class="sl-item-addr">${s.addr}</p>
<div class="sl-item-tags">${tags}</div>
`;
div.addEventListener("click", () => selectStore(s.id));
list.appendChild(div);
});
}
function renderPins() {
pinsEl.innerHTML = "";
STORES.forEach((s) => {
const pin = document.createElement("div");
pin.className = "map-pin-el" + (activeId === s.id ? " active" : "");
pin.style.left = s.x + "%";
pin.style.top = s.y + "%";
pin.innerHTML = `<svg width="20" height="28" viewBox="0 0 24 32" fill="none"><path d="M12 0C5.373 0 0 5.373 0 12c0 8 12 20 12 20s12-12 12-20c0-6.627-5.373-12-12-12z" fill="#6366f1"/><circle cx="12" cy="12" r="5" fill="#fff"/></svg>`;
pin.addEventListener("click", () => selectStore(s.id));
pinsEl.appendChild(pin);
});
}
function filterMatch(s) {
if (searchQ && !s.name.toLowerCase().includes(searchQ) && !s.addr.toLowerCase().includes(searchQ))
return false;
if (activeFilter === "open" && !s.open) return false;
if (activeFilter === "flagship" && !s.flagship) return false;
return true;
}
function selectStore(id) {
activeId = id;
const s = STORES.find((x) => x.id === id);
document.getElementById("popupName").textContent = s.name;
document.getElementById("popupAddr").textContent = s.addr;
popup.hidden = false;
renderList();
renderPins();
}
document.getElementById("slSearch").addEventListener("input", (e) => {
searchQ = e.target.value.toLowerCase();
renderList();
});
document.querySelectorAll(".sf-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".sf-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
activeFilter = btn.dataset.filter;
renderList();
});
});
renderList();
renderPins();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Store Locator</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="sl-layout">
<div class="sl-sidebar">
<div class="sl-search-wrap">
<input class="sl-search" id="slSearch" type="search" placeholder="Search city or store…" />
</div>
<div class="sl-filters">
<button class="sf-btn active" data-filter="all">All</button>
<button class="sf-btn" data-filter="open">Open now</button>
<button class="sf-btn" data-filter="flagship">Flagship</button>
</div>
<div class="sl-list" id="slList"></div>
</div>
<div class="sl-map" id="slMap">
<div class="map-bg"></div>
<div class="map-pins" id="mapPins"></div>
<div class="selected-popup" id="selectedPopup" hidden>
<p class="popup-name" id="popupName"></p>
<p class="popup-addr" id="popupAddr"></p>
<a href="#" class="popup-link">Get directions →</a>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useEffect, useRef } from "react";
interface Store {
id: number;
name: string;
address: string;
city: string;
state: string;
lat: number;
lng: number;
distance: string;
distanceNum: number;
open: boolean;
hours: string;
phone: string;
rating: number;
}
const STORES: Store[] = [
{
id: 1,
name: "Flagship — Union Square",
address: "170 O'Farrell St",
city: "San Francisco",
state: "CA",
lat: 37.7869,
lng: -122.4072,
distance: "0.4 mi",
distanceNum: 0.4,
open: true,
hours: "Mon–Sat 9–9, Sun 11–7",
phone: "(415) 555-0101",
rating: 4.8,
},
{
id: 2,
name: "SoMa Outlet",
address: "899 Howard St",
city: "San Francisco",
state: "CA",
lat: 37.7793,
lng: -122.4024,
distance: "1.2 mi",
distanceNum: 1.2,
open: true,
hours: "Mon–Fri 10–7, Sat–Sun 10–6",
phone: "(415) 555-0102",
rating: 4.3,
},
{
id: 3,
name: "Mission District",
address: "2401 Mission St",
city: "San Francisco",
state: "CA",
lat: 37.7568,
lng: -122.4189,
distance: "2.8 mi",
distanceNum: 2.8,
open: false,
hours: "Tue–Sun 10–7",
phone: "(415) 555-0103",
rating: 4.6,
},
{
id: 4,
name: "Berkeley Marina",
address: "225 University Ave",
city: "Berkeley",
state: "CA",
lat: 37.8702,
lng: -122.2679,
distance: "5.5 mi",
distanceNum: 5.5,
open: true,
hours: "Daily 10–8",
phone: "(510) 555-0104",
rating: 4.7,
},
{
id: 5,
name: "Oakland City Center",
address: "20th & Broadway",
city: "Oakland",
state: "CA",
lat: 37.8083,
lng: -122.2712,
distance: "8.1 mi",
distanceNum: 8.1,
open: true,
hours: "Mon–Sat 10–8, Sun 11–6",
phone: "(510) 555-0105",
rating: 4.4,
},
{
id: 6,
name: "Palo Alto",
address: "340 University Ave",
city: "Palo Alto",
state: "CA",
lat: 37.4459,
lng: -122.1613,
distance: "29 mi",
distanceNum: 29,
open: false,
hours: "Mon–Sat 10–7",
phone: "(650) 555-0106",
rating: 4.5,
},
];
// Pseudo-map using SVG with real-ish coordinate mapping
function MapView({
stores,
selectedId,
onSelect,
}: {
stores: Store[];
selectedId: number | null;
onSelect: (id: number) => void;
}) {
// Map bounds (roughly Bay Area)
const minLat = 37.4,
maxLat = 37.93;
const minLng = -122.52,
maxLng = -122.1;
const W = 540,
H = 380;
const project = (lat: number, lng: number) => ({
x: ((lng - minLng) / (maxLng - minLng)) * W,
y: H - ((lat - minLat) / (maxLat - minLat)) * H,
});
return (
<div className="relative w-full h-full bg-[#0d1117] rounded-xl overflow-hidden border border-[#30363d]">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-full" preserveAspectRatio="xMidYMid meet">
{/* Grid */}
<defs>
<pattern id="mapgrid" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#21262d" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#mapgrid)" />
{/* Water bodies (Bay) */}
<path
d="M 350 50 L 400 80 L 420 160 L 400 240 L 360 280 L 380 340 L 450 380 L 540 380 L 540 0 L 380 0 Z"
fill="#1c2128"
stroke="#30363d"
strokeWidth="1"
opacity="0.7"
/>
<text x="460" y="190" fill="#484f58" fontSize="10" fontFamily="monospace">
San Francisco Bay
</text>
{/* Roads */}
{[
"M 50 190 L 540 190",
"M 200 0 L 200 380",
"M 100 100 L 400 200",
"M 50 300 L 350 250",
].map((d, i) => (
<path
key={i}
d={d}
fill="none"
stroke="#21262d"
strokeWidth={i === 0 || i === 1 ? 3 : 1.5}
/>
))}
{/* Store pins */}
{stores.map((s) => {
const { x, y } = project(s.lat, s.lng);
const isSelected = selectedId === s.id;
return (
<g key={s.id} className="cursor-pointer" onClick={() => onSelect(s.id)}>
{isSelected && (
<circle cx={x} cy={y} r="18" fill="#58a6ff" opacity="0.15">
<animate
attributeName="r"
from="14"
to="22"
dur="1.5s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
from="0.2"
to="0"
dur="1.5s"
repeatCount="indefinite"
/>
</circle>
)}
<circle
cx={x}
cy={y}
r={isSelected ? 8 : 6}
fill={isSelected ? "#58a6ff" : s.open ? "#7ee787" : "#f85149"}
stroke={isSelected ? "#79b8ff" : "#0d1117"}
strokeWidth={2}
/>
<text
x={x + 10}
y={y + 4}
fill={isSelected ? "#e6edf3" : "#8b949e"}
fontSize={isSelected ? 11 : 9}
fontWeight={isSelected ? "bold" : "normal"}
fontFamily="sans-serif"
>
{s.name.split("—")[0].trim().split(" ").slice(0, 2).join(" ")}
</text>
</g>
);
})}
{/* Compass */}
<g transform="translate(28, 28)">
<circle r="14" fill="#161b22" stroke="#30363d" strokeWidth="1" />
<text textAnchor="middle" y="-5" fill="#8b949e" fontSize="10" fontFamily="monospace">
N
</text>
<line y1="-10" y2="-2" stroke="#58a6ff" strokeWidth="1.5" />
</g>
</svg>
</div>
);
}
export default function StoreLocatorRC() {
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState<"distance" | "rating">("distance");
const [showOpen, setShowOpen] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(1);
const filtered = STORES.filter(
(s) =>
(!showOpen || s.open) &&
(s.name.toLowerCase().includes(search.toLowerCase()) ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.address.toLowerCase().includes(search.toLowerCase()))
).sort((a, b) => (sortBy === "distance" ? a.distanceNum - b.distanceNum : b.rating - a.rating));
const selected = STORES.find((s) => s.id === selectedId);
return (
<div className="min-h-screen bg-[#0d1117] p-4 flex justify-center">
<div className="w-full max-w-[1000px] space-y-3">
{/* Search bar */}
<div className="flex gap-2 flex-wrap">
<input
type="text"
placeholder="Search by city, name, or address…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-[200px] bg-[#161b22] border border-[#30363d] rounded-xl px-3 py-2.5 text-[13px] text-[#e6edf3] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors"
/>
<div className="flex gap-1.5">
{(["distance", "rating"] as const).map((s) => (
<button
key={s}
onClick={() => setSortBy(s)}
className={`px-3 py-2 rounded-xl text-[11px] font-semibold border transition-colors capitalize ${
sortBy === s
? "bg-[#58a6ff]/10 border-[#58a6ff]/30 text-[#58a6ff]"
: "border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]"
}`}
>
{s}
</button>
))}
<button
onClick={() => setShowOpen((v) => !v)}
className={`px-3 py-2 rounded-xl text-[11px] font-semibold border transition-colors ${
showOpen
? "bg-green-500/10 border-green-500/30 text-green-400"
: "border-[#30363d] text-[#8b949e] hover:text-[#e6edf3]"
}`}
>
Open now
</button>
</div>
</div>
{/* Main layout */}
<div className="flex gap-3 flex-col sm:flex-row" style={{ height: 440 }}>
{/* List */}
<div className="w-full sm:w-[280px] flex-shrink-0 space-y-1.5 overflow-y-auto">
{filtered.map((store) => {
const isSelected = selectedId === store.id;
return (
<div
key={store.id}
onClick={() => setSelectedId(store.id)}
className={`p-3 rounded-xl cursor-pointer border transition-colors ${
isSelected
? "bg-[#58a6ff]/[0.08] border-[#58a6ff]/30"
: "bg-[#161b22] border-[#30363d] hover:border-[#8b949e]/40"
}`}
>
<div className="flex items-start justify-between gap-2 mb-1">
<p
className={`text-[12px] font-semibold leading-tight ${isSelected ? "text-[#58a6ff]" : "text-[#e6edf3]"}`}
>
{store.name}
</p>
<span
className={`flex-shrink-0 text-[9px] font-bold px-1.5 py-0.5 rounded-full border ${
store.open
? "bg-green-500/10 text-green-400 border-green-500/20"
: "bg-red-500/10 text-red-400 border-red-500/20"
}`}
>
{store.open ? "Open" : "Closed"}
</span>
</div>
<p className="text-[11px] text-[#8b949e] truncate">
{store.address}, {store.city}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-[#484f58]">{store.distance}</span>
<span className="text-[10px] text-[#e3b341]">★ {store.rating}</span>
</div>
</div>
);
})}
{filtered.length === 0 && (
<div className="py-8 text-center text-[12px] text-[#484f58]">No stores found</div>
)}
</div>
{/* Map */}
<div className="flex-1 min-w-0 flex flex-col gap-2">
<div className="flex-1 min-h-0">
<MapView
stores={filtered.length ? filtered : STORES}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</div>
{/* Selected detail strip */}
{selected && (
<div className="flex items-center gap-3 bg-[#161b22] border border-[#30363d] rounded-xl px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-[12px] font-semibold text-[#e6edf3] truncate">
{selected.name}
</p>
<p className="text-[10px] text-[#8b949e]">
{selected.hours} · {selected.phone}
</p>
</div>
<button className="flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 bg-[#58a6ff] rounded-lg text-[11px] font-bold text-white hover:bg-[#79b8ff] transition-colors">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<polygon points="3 11 22 2 13 21 11 13 3 11" />
</svg>
Directions
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
}<script setup>
import { ref, computed } from "vue";
const STORES = [
{
id: 1,
name: "Flagship — Union Square",
address: "170 O'Farrell St",
city: "San Francisco",
state: "CA",
lat: 37.7869,
lng: -122.4072,
distance: "0.4 mi",
distanceNum: 0.4,
open: true,
hours: "Mon–Sat 9–9, Sun 11–7",
phone: "(415) 555-0101",
rating: 4.8,
},
{
id: 2,
name: "SoMa Outlet",
address: "899 Howard St",
city: "San Francisco",
state: "CA",
lat: 37.7793,
lng: -122.4024,
distance: "1.2 mi",
distanceNum: 1.2,
open: true,
hours: "Mon–Fri 10–7, Sat–Sun 10–6",
phone: "(415) 555-0102",
rating: 4.3,
},
{
id: 3,
name: "Mission District",
address: "2401 Mission St",
city: "San Francisco",
state: "CA",
lat: 37.7568,
lng: -122.4189,
distance: "2.8 mi",
distanceNum: 2.8,
open: false,
hours: "Tue–Sun 10–7",
phone: "(415) 555-0103",
rating: 4.6,
},
{
id: 4,
name: "Berkeley Marina",
address: "225 University Ave",
city: "Berkeley",
state: "CA",
lat: 37.8702,
lng: -122.2679,
distance: "5.5 mi",
distanceNum: 5.5,
open: true,
hours: "Daily 10–8",
phone: "(510) 555-0104",
rating: 4.7,
},
{
id: 5,
name: "Oakland City Center",
address: "20th & Broadway",
city: "Oakland",
state: "CA",
lat: 37.8083,
lng: -122.2712,
distance: "8.1 mi",
distanceNum: 8.1,
open: true,
hours: "Mon–Sat 10–8, Sun 11–6",
phone: "(510) 555-0105",
rating: 4.4,
},
{
id: 6,
name: "Palo Alto",
address: "340 University Ave",
city: "Palo Alto",
state: "CA",
lat: 37.4459,
lng: -122.1613,
distance: "29 mi",
distanceNum: 29,
open: false,
hours: "Mon–Sat 10–7",
phone: "(650) 555-0106",
rating: 4.5,
},
];
const search = ref("");
const sortBy = ref("distance");
const showOpen = ref(false);
const selectedId = ref(1);
const W = 540,
H = 380;
const minLat = 37.4,
maxLat = 37.93;
const minLng = -122.52,
maxLng = -122.1;
function project(lat, lng) {
return {
x: ((lng - minLng) / (maxLng - minLng)) * W,
y: H - ((lat - minLat) / (maxLat - minLat)) * H,
};
}
function shortName(name) {
return name.split("—")[0].trim().split(" ").slice(0, 2).join(" ");
}
const filtered = computed(() =>
STORES.filter(
(s) =>
(!showOpen.value || s.open) &&
(s.name.toLowerCase().includes(search.value.toLowerCase()) ||
s.city.toLowerCase().includes(search.value.toLowerCase()) ||
s.address.toLowerCase().includes(search.value.toLowerCase()))
).sort((a, b) =>
sortBy.value === "distance" ? a.distanceNum - b.distanceNum : b.rating - a.rating
)
);
const selected = computed(() => STORES.find((s) => s.id === selectedId.value));
const mapStores = computed(() => (filtered.value.length ? filtered.value : STORES));
const roads = [
"M 50 190 L 540 190",
"M 200 0 L 200 380",
"M 100 100 L 400 200",
"M 50 300 L 350 250",
];
</script>
<template>
<div class="page">
<div class="container">
<!-- Search bar -->
<div class="search-bar">
<input
type="text"
placeholder="Search by city, name, or address…"
v-model="search"
class="search-input"
/>
<div class="filter-buttons">
<button
v-for="s in ['distance', 'rating']"
:key="s"
:class="['filter-btn', sortBy === s ? 'active' : '']"
@click="sortBy = s"
>{{ s }}</button>
<button
:class="['filter-btn', showOpen ? 'open-active' : '']"
@click="showOpen = !showOpen"
>Open now</button>
</div>
</div>
<!-- Main layout -->
<div class="main">
<!-- List -->
<div class="store-list">
<div
v-for="store in filtered"
:key="store.id"
:class="['store-card', selectedId === store.id ? 'selected' : '']"
@click="selectedId = store.id"
>
<div class="store-header">
<p :class="['store-name', selectedId === store.id ? 'name-selected' : '']">{{ store.name }}</p>
<span :class="['badge', store.open ? 'badge-open' : 'badge-closed']">
{{ store.open ? "Open" : "Closed" }}
</span>
</div>
<p class="store-address">{{ store.address }}, {{ store.city }}</p>
<div class="store-meta">
<span class="meta-distance">{{ store.distance }}</span>
<span class="meta-rating">★ {{ store.rating }}</span>
</div>
</div>
<div v-if="filtered.length === 0" class="no-results">No stores found</div>
</div>
<!-- Map -->
<div class="map-col">
<div class="map-container">
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;">
<defs>
<pattern id="mapgrid" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#21262d" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#mapgrid)"/>
<path
d="M 350 50 L 400 80 L 420 160 L 400 240 L 360 280 L 380 340 L 450 380 L 540 380 L 540 0 L 380 0 Z"
fill="#1c2128" stroke="#30363d" stroke-width="1" opacity="0.7"
/>
<text x="460" y="190" fill="#484f58" font-size="10" font-family="monospace">San Francisco Bay</text>
<path
v-for="(d, i) in roads"
:key="'road-' + i"
:d="d"
fill="none"
stroke="#21262d"
:stroke-width="i < 2 ? 3 : 1.5"
/>
<g
v-for="s in mapStores"
:key="'pin-' + s.id"
style="cursor: pointer"
@click="selectedId = s.id"
>
<circle
v-if="selectedId === s.id"
:cx="project(s.lat, s.lng).x"
:cy="project(s.lat, s.lng).y"
r="18"
fill="#58a6ff"
opacity="0.15"
>
<animate attributeName="r" from="14" to="22" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.2" to="0" dur="1.5s" repeatCount="indefinite"/>
</circle>
<circle
:cx="project(s.lat, s.lng).x"
:cy="project(s.lat, s.lng).y"
:r="selectedId === s.id ? 8 : 6"
:fill="selectedId === s.id ? '#58a6ff' : s.open ? '#7ee787' : '#f85149'"
:stroke="selectedId === s.id ? '#79b8ff' : '#0d1117'"
stroke-width="2"
/>
<text
:x="project(s.lat, s.lng).x + 10"
:y="project(s.lat, s.lng).y + 4"
:fill="selectedId === s.id ? '#e6edf3' : '#8b949e'"
:font-size="selectedId === s.id ? 11 : 9"
:font-weight="selectedId === s.id ? 'bold' : 'normal'"
font-family="sans-serif"
>{{ shortName(s.name) }}</text>
</g>
<g transform="translate(28, 28)">
<circle r="14" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<text text-anchor="middle" y="-5" fill="#8b949e" font-size="10" font-family="monospace">N</text>
<line y1="-10" y2="-2" stroke="#58a6ff" stroke-width="1.5"/>
</g>
</svg>
</div>
<div v-if="selected" class="detail-strip">
<div class="detail-info">
<p class="detail-name">{{ selected.name }}</p>
<p class="detail-meta">{{ selected.hours }} · {{ selected.phone }}</p>
</div>
<button class="directions-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polygon points="3 11 22 2 13 21 11 13 3 11"/>
</svg>
Directions
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page {
min-height: 100vh;
background: #0d1117;
padding: 1rem;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 1000px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.search-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
font-size: 13px;
color: #e6edf3;
outline: none;
transition: border-color 0.15s;
}
.search-input::placeholder { color: #484f58; }
.search-input:focus { border-color: #58a6ff; }
.filter-buttons {
display: flex;
gap: 0.375rem;
}
.filter-btn {
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
font-size: 11px;
font-weight: 600;
border: 1px solid #30363d;
color: #8b949e;
background: transparent;
cursor: pointer;
text-transform: capitalize;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.filter-btn:hover { color: #e6edf3; }
.filter-btn.active {
background: rgba(88, 166, 255, 0.1);
border-color: rgba(88, 166, 255, 0.3);
color: #58a6ff;
}
.filter-btn.open-active {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.main {
display: flex;
gap: 0.75rem;
height: 440px;
}
.store-list {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
overflow-y: auto;
}
.store-card {
padding: 0.75rem;
border-radius: 0.75rem;
cursor: pointer;
border: 1px solid #30363d;
background: #161b22;
transition: border-color 0.15s;
}
.store-card:hover { border-color: rgba(139, 148, 158, 0.4); }
.store-card.selected {
background: rgba(88, 166, 255, 0.08);
border-color: rgba(88, 166, 255, 0.3);
}
.store-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.store-name {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
color: #e6edf3;
margin: 0;
}
.store-name.name-selected { color: #58a6ff; }
.badge {
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 999px;
}
.badge-open {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.badge-closed {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.store-address {
font-size: 11px;
color: #8b949e;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.meta-distance { font-size: 10px; color: #484f58; }
.meta-rating { font-size: 10px; color: #e3b341; }
.no-results {
padding: 2rem 0;
text-align: center;
font-size: 12px;
color: #484f58;
}
.map-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.map-container {
flex: 1;
min-height: 0;
background: #0d1117;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid #30363d;
}
.detail-strip {
display: flex;
align-items: center;
gap: 0.75rem;
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
}
.detail-info {
flex: 1;
min-width: 0;
}
.detail-name {
font-size: 12px;
font-weight: 600;
color: #e6edf3;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-meta {
font-size: 10px;
color: #8b949e;
margin: 0;
}
.directions-btn {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
background: #58a6ff;
border: none;
border-radius: 0.5rem;
font-size: 11px;
font-weight: 700;
color: white;
cursor: pointer;
transition: background 0.15s;
}
.directions-btn:hover { background: #79b8ff; }
@media (max-width: 640px) {
.main { flex-direction: column; height: auto; }
.store-list { width: 100%; max-height: 200px; }
}
</style><script>
const STORES = [
{
id: 1,
name: "Flagship — Union Square",
address: "170 O'Farrell St",
city: "San Francisco",
state: "CA",
lat: 37.7869,
lng: -122.4072,
distance: "0.4 mi",
distanceNum: 0.4,
open: true,
hours: "Mon–Sat 9–9, Sun 11–7",
phone: "(415) 555-0101",
rating: 4.8,
},
{
id: 2,
name: "SoMa Outlet",
address: "899 Howard St",
city: "San Francisco",
state: "CA",
lat: 37.7793,
lng: -122.4024,
distance: "1.2 mi",
distanceNum: 1.2,
open: true,
hours: "Mon–Fri 10–7, Sat–Sun 10–6",
phone: "(415) 555-0102",
rating: 4.3,
},
{
id: 3,
name: "Mission District",
address: "2401 Mission St",
city: "San Francisco",
state: "CA",
lat: 37.7568,
lng: -122.4189,
distance: "2.8 mi",
distanceNum: 2.8,
open: false,
hours: "Tue–Sun 10–7",
phone: "(415) 555-0103",
rating: 4.6,
},
{
id: 4,
name: "Berkeley Marina",
address: "225 University Ave",
city: "Berkeley",
state: "CA",
lat: 37.8702,
lng: -122.2679,
distance: "5.5 mi",
distanceNum: 5.5,
open: true,
hours: "Daily 10–8",
phone: "(510) 555-0104",
rating: 4.7,
},
{
id: 5,
name: "Oakland City Center",
address: "20th & Broadway",
city: "Oakland",
state: "CA",
lat: 37.8083,
lng: -122.2712,
distance: "8.1 mi",
distanceNum: 8.1,
open: true,
hours: "Mon–Sat 10–8, Sun 11–6",
phone: "(510) 555-0105",
rating: 4.4,
},
{
id: 6,
name: "Palo Alto",
address: "340 University Ave",
city: "Palo Alto",
state: "CA",
lat: 37.4459,
lng: -122.1613,
distance: "29 mi",
distanceNum: 29,
open: false,
hours: "Mon–Sat 10–7",
phone: "(650) 555-0106",
rating: 4.5,
},
];
let search = "";
let sortBy = "distance";
let showOpen = false;
let selectedId = 1;
const W = 540,
H = 380;
const minLat = 37.4,
maxLat = 37.93;
const minLng = -122.52,
maxLng = -122.1;
function project(lat, lng) {
return {
x: ((lng - minLng) / (maxLng - minLng)) * W,
y: H - ((lat - minLat) / (maxLat - minLat)) * H,
};
}
function shortName(name) {
return name.split("—")[0].trim().split(" ").slice(0, 2).join(" ");
}
$: filtered = STORES.filter(
(s) =>
(!showOpen || s.open) &&
(s.name.toLowerCase().includes(search.toLowerCase()) ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.address.toLowerCase().includes(search.toLowerCase()))
).sort((a, b) => (sortBy === "distance" ? a.distanceNum - b.distanceNum : b.rating - a.rating));
$: selected = STORES.find((s) => s.id === selectedId);
$: mapStores = filtered.length ? filtered : STORES;
const roads = [
"M 50 190 L 540 190",
"M 200 0 L 200 380",
"M 100 100 L 400 200",
"M 50 300 L 350 250",
];
</script>
<div class="page">
<div class="container">
<!-- Search bar -->
<div class="search-bar">
<input
type="text"
placeholder="Search by city, name, or address…"
bind:value={search}
class="search-input"
/>
<div class="filter-buttons">
{#each ["distance", "rating"] as s}
<button
class="filter-btn {sortBy === s ? 'active' : ''}"
on:click={() => (sortBy = s)}
>{s}</button>
{/each}
<button
class="filter-btn {showOpen ? 'open-active' : ''}"
on:click={() => (showOpen = !showOpen)}
>Open now</button>
</div>
</div>
<!-- Main layout -->
<div class="main">
<!-- List -->
<div class="store-list">
{#each filtered as store}
<div
class="store-card {selectedId === store.id ? 'selected' : ''}"
on:click={() => (selectedId = store.id)}
>
<div class="store-header">
<p class="store-name {selectedId === store.id ? 'name-selected' : ''}">{store.name}</p>
<span class="badge {store.open ? 'badge-open' : 'badge-closed'}">
{store.open ? "Open" : "Closed"}
</span>
</div>
<p class="store-address">{store.address}, {store.city}</p>
<div class="store-meta">
<span class="meta-distance">{store.distance}</span>
<span class="meta-rating">★ {store.rating}</span>
</div>
</div>
{/each}
{#if filtered.length === 0}
<div class="no-results">No stores found</div>
{/if}
</div>
<!-- Map -->
<div class="map-col">
<div class="map-container">
<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%;">
<defs>
<pattern id="mapgrid" width="30" height="30" patternUnits="userSpaceOnUse">
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#21262d" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#mapgrid)"/>
<path
d="M 350 50 L 400 80 L 420 160 L 400 240 L 360 280 L 380 340 L 450 380 L 540 380 L 540 0 L 380 0 Z"
fill="#1c2128" stroke="#30363d" stroke-width="1" opacity="0.7"
/>
<text x="460" y="190" fill="#484f58" font-size="10" font-family="monospace">San Francisco Bay</text>
{#each roads as d, i}
<path {d} fill="none" stroke="#21262d" stroke-width={i < 2 ? 3 : 1.5}/>
{/each}
{#each mapStores as s}
{@const p = project(s.lat, s.lng)}
{@const isSel = selectedId === s.id}
<g style="cursor:pointer" on:click={() => (selectedId = s.id)}>
{#if isSel}
<circle cx={p.x} cy={p.y} r="18" fill="#58a6ff" opacity="0.15">
<animate attributeName="r" from="14" to="22" dur="1.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.2" to="0" dur="1.5s" repeatCount="indefinite"/>
</circle>
{/if}
<circle
cx={p.x} cy={p.y} r={isSel ? 8 : 6}
fill={isSel ? "#58a6ff" : s.open ? "#7ee787" : "#f85149"}
stroke={isSel ? "#79b8ff" : "#0d1117"}
stroke-width="2"
/>
<text
x={p.x + 10} y={p.y + 4}
fill={isSel ? "#e6edf3" : "#8b949e"}
font-size={isSel ? 11 : 9}
font-weight={isSel ? "bold" : "normal"}
font-family="sans-serif"
>{shortName(s.name)}</text>
</g>
{/each}
<g transform="translate(28, 28)">
<circle r="14" fill="#161b22" stroke="#30363d" stroke-width="1"/>
<text text-anchor="middle" y="-5" fill="#8b949e" font-size="10" font-family="monospace">N</text>
<line y1="-10" y2="-2" stroke="#58a6ff" stroke-width="1.5"/>
</g>
</svg>
</div>
{#if selected}
<div class="detail-strip">
<div class="detail-info">
<p class="detail-name">{selected.name}</p>
<p class="detail-meta">{selected.hours} · {selected.phone}</p>
</div>
<button class="directions-btn">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polygon points="3 11 22 2 13 21 11 13 3 11"/>
</svg>
Directions
</button>
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.page {
min-height: 100vh;
background: #0d1117;
padding: 1rem;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 1000px;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.search-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
font-size: 13px;
color: #e6edf3;
outline: none;
transition: border-color 0.15s;
}
.search-input::placeholder { color: #484f58; }
.search-input:focus { border-color: #58a6ff; }
.filter-buttons {
display: flex;
gap: 0.375rem;
}
.filter-btn {
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
font-size: 11px;
font-weight: 600;
border: 1px solid #30363d;
color: #8b949e;
background: transparent;
cursor: pointer;
text-transform: capitalize;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.filter-btn:hover { color: #e6edf3; }
.filter-btn.active {
background: rgba(88, 166, 255, 0.1);
border-color: rgba(88, 166, 255, 0.3);
color: #58a6ff;
}
.filter-btn.open-active {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.main {
display: flex;
gap: 0.75rem;
height: 440px;
}
.store-list {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
overflow-y: auto;
}
.store-card {
padding: 0.75rem;
border-radius: 0.75rem;
cursor: pointer;
border: 1px solid #30363d;
background: #161b22;
transition: border-color 0.15s;
}
.store-card:hover { border-color: rgba(139, 148, 158, 0.4); }
.store-card.selected {
background: rgba(88, 166, 255, 0.08);
border-color: rgba(88, 166, 255, 0.3);
}
.store-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.store-name {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
color: #e6edf3;
margin: 0;
}
.store-name.name-selected { color: #58a6ff; }
.badge {
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 999px;
}
.badge-open {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.badge-closed {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.store-address {
font-size: 11px;
color: #8b949e;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.meta-distance { font-size: 10px; color: #484f58; }
.meta-rating { font-size: 10px; color: #e3b341; }
.no-results {
padding: 2rem 0;
text-align: center;
font-size: 12px;
color: #484f58;
}
.map-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.map-container {
flex: 1;
min-height: 0;
background: #0d1117;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid #30363d;
}
.detail-strip {
display: flex;
align-items: center;
gap: 0.75rem;
background: #161b22;
border: 1px solid #30363d;
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
}
.detail-info {
flex: 1;
min-width: 0;
}
.detail-name {
font-size: 12px;
font-weight: 600;
color: #e6edf3;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-meta {
font-size: 10px;
color: #8b949e;
margin: 0;
}
.directions-btn {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
background: #58a6ff;
border: none;
border-radius: 0.5rem;
font-size: 11px;
font-weight: 700;
color: white;
cursor: pointer;
transition: background 0.15s;
}
.directions-btn:hover { background: #79b8ff; }
@media (max-width: 640px) {
.main { flex-direction: column; height: auto; }
.store-list { width: 100%; max-height: 200px; }
}
</style>Store locator with a two-column layout: left sidebar shows a filterable, searchable store list with distance badges and hours; right side shows a CSS map placeholder that highlights the selected store.