Store Locator
A schematic map with numbered pins and a synced list: pick a pin or a row, filter open-now, and see distance and hours.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/store-locator.json'use client'
import { useEffect, useId, useRef, useState } from 'react'
import './store-locator.css'
export interface Store { id: string; name: string; x: number; y: number; km: number; open: boolean; hours: string; address: string }
export interface StoreLocatorProps {
stores: Store[]
onSelect?: (id: string) => void
openOnly?: boolean
map?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Map pins and list kept in sync. */
export function StoreLocator({ stores, onSelect, openOnly: o0 = false, map = true, fill = 'dark', className }: StoreLocatorProps) {
const [sel, setSel] = useState(stores[0]?.id)
const [hot, setHot] = useState<string | null>(null)
const [openOnly, setOpenOnly] = useState(o0)
const list = useRef<HTMLDivElement>(null)
const uid = useId()
const shown = stores.filter((s) => !openOnly || s.open).sort((a, b) => a.km - b.km)
const idx = (id: string) => stores.findIndex((s) => s.id === id) + 1
const pick = (id: string) => { setSel(id); onSelect?.(id) }
useEffect(() => {
const box = list.current, el = box?.querySelector<HTMLElement>(`[data-id="${sel}"]`)
if (!box || !el) return
if (el.offsetTop < box.scrollTop) box.scrollTo({ top: el.offsetTop - 6, behavior: 'smooth' })
else if (el.offsetTop + el.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTo({ top: el.offsetTop + el.offsetHeight - box.clientHeight + 6, behavior: 'smooth' })
}, [sel])
useEffect(() => { if (shown.length && !shown.some((s) => s.id === sel)) setSel(shown[0].id) }, [openOnly]) // eslint-disable-line react-hooks/exhaustive-deps
const key = (e: React.KeyboardEvent) => {
const i = shown.findIndex((s) => s.id === sel)
const d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0
if (d && shown.length) { e.preventDefault(); pick(shown[(i + d + shown.length) % shown.length].id) }
}
return (
<div className={['sd-loc', className].filter(Boolean).join(' ')} data-fill={fill}>
{map && (
<div className="sd-loc__map" aria-label="Map" role="group">
<svg viewBox="0 0 330 150" preserveAspectRatio="none" aria-hidden>
<path className="w" d="M-10 96 C80 80 140 120 340 70" /><path d="M-10 96 C80 80 140 120 340 70" />
<path d="M120 -10 L150 160" /><path d="M250 -10 C230 60 270 100 240 160" /><path d="M-10 30 L340 44" />
</svg>
<span className="sd-loc__me" style={{ left: '44%', top: '62%' }} aria-label="You are here" role="img" />
{stores.filter((s) => !openOnly || s.open).map((s) => (
<button key={s.id} type="button" className="sd-loc__pin" style={{ left: `${s.x}%`, top: `${s.y}%` }} data-on={sel === s.id || undefined} data-hot={hot === s.id || undefined} data-closed={!s.open || undefined}
aria-label={`${s.name}, ${s.km} km`} aria-pressed={sel === s.id} onClick={() => pick(s.id)}><span>{idx(s.id)}</span></button>
))}
</div>
)}
<div className="sd-loc__bar">
<span aria-live="polite">{shown.length} {shown.length === 1 ? 'store' : 'stores'} nearby</span>
<button type="button" role="switch" aria-checked={openOnly} className="sd-loc__sw" onClick={() => setOpenOnly(!openOnly)}>Open now <i aria-hidden /></button>
</div>
<div ref={list} className="sd-loc__list" role="listbox" aria-label="Stores" tabIndex={0} aria-activedescendant={sel ? `${uid}-${sel}` : undefined} onKeyDown={key}>
{shown.map((s) => (
<div key={s.id} id={`${uid}-${s.id}`} data-id={s.id} role="option" aria-selected={sel === s.id} className="sd-loc__row" data-act={hot === s.id || undefined}
onClick={() => pick(s.id)} onPointerEnter={() => setHot(s.id)} onPointerLeave={() => setHot(null)}>
<span className="sd-loc__n">{idx(s.id)}</span>
<span className="sd-loc__name">{s.name}</span>
<span className="sd-loc__d">{s.km.toFixed(1)} km</span>
<span className="sd-loc__st">{s.open ? <><b>Open</b> · closes {s.hours.split('–')[1]}</> : <>Closed · opens {s.hours.split('–')[0]}</>}</span>
{sel === s.id && <div className="sd-loc__more"><span>{s.address}</span><a href={`https://maps.google.com/?q=${encodeURIComponent(s.address)}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>Directions</a></div>}
</div>
))}
</div>
</div>
)
}.sd-loc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-loc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-loc :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-loc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-loc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-loc { width: min(100%, 330px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-loc__map { position: relative; height: 150px; background: var(--soft); border-bottom: 1px solid var(--line); overflow: hidden; }
.sd-loc__map svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.sd-loc__map svg path { fill: none; stroke: var(--line); stroke-width: 7; stroke-linecap: round; }
.sd-loc__map svg .w { stroke: var(--bg); stroke-width: 14; }
.sd-loc__me { position: absolute; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 99px; background: var(--fg); box-shadow: 0 0 0 4px color-mix(in srgb, var(--fg) 20%, transparent); }
.sd-loc__pin { position: absolute; display: grid; place-items: center; width: 26px; height: 26px; margin: -26px 0 0 -13px; border-radius: 99px 99px 99px 2px; transform: rotate(-45deg); background: var(--bg) !important; box-shadow: 0 2px 6px rgb(0 0 0 / 0.35), inset 0 0 0 1.5px var(--fg); transition: transform 200ms cubic-bezier(0.3, 1.6, 0.4, 1), background-color 150ms, opacity 150ms; transform-origin: 0 100%; }
.sd-loc__pin span { transform: rotate(45deg); font-size: 11.5px; font-weight: 700; }
.sd-loc__pin[data-on] { background: var(--strong) !important; color: var(--on) !important; transform: rotate(-45deg) scale(1.2); z-index: 2; }
.sd-loc__pin[data-hot]:not([data-on]) { transform: rotate(-45deg) scale(1.12); }
.sd-loc__pin[data-closed] { opacity: 0.45; }
.sd-loc__bar { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; font-size: 12.5px; color: var(--mute); }
.sd-loc__sw { display: inline-flex; align-items: center; gap: 8px; color: var(--fg) !important; font-size: 12.5px; }
.sd-loc__sw i { position: relative; width: 30px; height: 18px; border-radius: 99px; background: var(--line); transition: background-color 150ms; }
.sd-loc__sw i::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 99px; background: var(--fg); transition: transform 180ms cubic-bezier(0.3, 1.5, 0.4, 1); }
.sd-loc__sw[aria-checked='true'] i { background: var(--strong); }
.sd-loc__sw[aria-checked='true'] i::after { transform: translateX(12px); background: var(--on); }
.sd-loc__list { position: relative; max-height: 230px; overflow-y: auto; padding: 0 6px 6px; outline: 0; }
.sd-loc__row { display: grid; grid-template-columns: 24px 1fr auto; gap: 2px 10px; padding: 10px 8px; border-radius: 12px; cursor: pointer; transition: background-color 120ms; }
.sd-loc__row:hover, .sd-loc__row[data-act] { background: var(--soft); }
.sd-loc__n { grid-row: span 2; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--line); font-size: 11px; font-weight: 700; }
.sd-loc__row[aria-selected='true'] .sd-loc__n { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-loc__name { font-size: 14px; font-weight: 500; }
.sd-loc__d { font-size: 12.5px; color: var(--mute); font-variant-numeric: tabular-nums; text-align: right; }
.sd-loc__st { grid-column: 2 / 4; font-size: 12px; color: var(--mute); }
.sd-loc__st b { color: var(--fg); font-weight: 600; }
.sd-loc__more { grid-column: 2 / 4; display: flex; justify-content: space-between; align-items: center; margin-top: 6px; font-size: 12px; color: var(--mute); animation: sd-loc-in 200ms ease-out; }
.sd-loc__more a { padding: 6px 10px; border-radius: 99px; background: var(--strong); color: var(--on); font-weight: 600; text-decoration: none; }
@keyframes sd-loc-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-loc *, .sd-loc *::before, .sd-loc *::after { animation: none !important; transition: none !important; } }Overview
The map and list share one selection: hovering a row lifts its pin, choosing a pin scrolls its row into view. The selected store expands with hours and a directions link.
The “Open now” filter hides closed stores from both. The map is a lightweight SVG placeholder you can swap for any map library.
When to use
- Retail, pickup points, clinics, coworking spaces.
When not to use
- Turn-by-turn navigation: hand off to a maps app.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/store-locator.json$
npx shadcn@latest add https://sundaeui.dev/r/store-locator.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/store-locator.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/store-locator.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy store-locator.tsx and store-locator.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { StoreLocator } from '@/components/sundae/store-locator'
<StoreLocator />Props
| Prop | Type | Default |
|---|---|---|
stores*Stores with x/y map position, distance and hours. | Store[] | — |
onSelectCalled on selection. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Pins are buttons labelled with the store name and distance.
- The list is a listbox; the open-now filter is a switch.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-loc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Store Locator free?
- Yes. Store Locator is free for personal and commercial projects, with full source on this page.
- How do I install Store Locator?
- Run npx shadcn@latest add https://sundaeui.dev/r/store-locator.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Store Locator support keyboard and screen readers?
- Pins are buttons labelled with the store name and distance. The list is a listbox; the open-now filter is a switch.
- Does Store Locator respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Swipe Actions
Components · Refined - Pro
Sortable List
Components · Refined - Pro
Bulk Actions Bar
Components · Refined Grid / List View Switcher
Components · RefinedPull to Refresh
Components · RefinedLoad More List
Components · Refined