
Mapbox Store Locator Patterns
- 1.1k installs
- 71 repo stars
- Updated August 4, 2026
- mapbox/mapbox-agent-skills
mapbox-store-locator-patterns is a Mapbox agent skill that implements reliable, performant store locators and location-based interfaces for developers building map-driven finder experiences.
About
mapbox-store-locator-patterns is an official Mapbox agent skill from mapbox/mapbox-agent-skills that documents architecture for store locators and location finders. The readme defines six core components: a map with markers, GeoJSON location data, an interactive list, search and filter controls, user location with distance calculation, and optional directions. It includes a GeoJSON FeatureCollection example with Point geometry, store properties such as name, address, phone, category, and hours, plus guidance on wiring list-map interaction. Developers reach for mapbox-store-locator-patterns when shipping retail or service locator pages that must stay performant with real coordinates, filtering, and distance-aware UX on Mapbox.
- Ready-to-use architecture for maps with markers, GeoJSON data, interactive lists, search, and directions
- Scalable marker strategies: HTML Markers for <100 locations, Symbol Layers for 100-1000, Clustering for >1000
- Complete GeoJSON data structure with properties for name, address, category, hours and phone
- Copy-paste JavaScript patterns for HTML markers with popups and Symbol Layer implementations
- Optimized patterns that prevent common performance pitfalls when displaying hundreds of store locations
Mapbox Store Locator Patterns by the numbers
- 1,142 all-time installs (skills.sh)
- +40 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #342 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mapbox/mapbox-agent-skills --skill mapbox-store-locator-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.1k |
|---|---|
| repo stars | ★ 71 |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | mapbox/mapbox-agent-skills ↗ |
How do you build a Mapbox store locator app?
Quickly implement reliable, performant store locators and location-based interfaces using Mapbox.
Who is it for?
Frontend developers implementing retail or service store finders with Mapbox maps, GeoJSON data, and interactive location search.
Skip if: Projects without location data, non-map UIs, or teams that only need server-side geocoding without a locator frontend.
When should I use this skill?
The user asks to build a store locator, location finder, Mapbox marker map, or GeoJSON-driven search and filter UI.
What you get
GeoJSON store datasets, Mapbox maps with markers, searchable store lists, and distance-aware locator UI patterns.
- GeoJSON store dataset
- Interactive locator UI
- Map marker and search components
By the numbers
- Documents 6 core store locator architecture components
- Uses GeoJSON FeatureCollection with Point geometry for store data
Files
Store Locator Patterns Skill
Comprehensive patterns for building store locators, restaurant finders, and location-based search applications with Mapbox GL JS. Covers marker display, filtering, distance calculation, interactive lists, and directions integration.
When to Use This Skill
Use this skill when building applications that:
- Display multiple locations on a map (stores, restaurants, offices, etc.)
- Allow users to filter or search locations
- Calculate distances from user location
- Provide interactive lists synced with map markers
- Show location details in popups or side panels
- Integrate directions to selected locations
Dependencies
Required:
- Mapbox GL JS v3.x
- @turf/turf - For spatial calculations (distance, area, etc.)
Installation:
npm install mapbox-gl @turf/turfCore Architecture
Pattern Overview
A typical store locator consists of:
1. Map Display - Shows all locations as markers 2. Location Data - GeoJSON with store/location information 3. Interactive List - Side panel listing all locations 4. Filtering - Search, category filters, distance filters 5. Detail View - Popup or panel with location details 6. User Location - Geolocation for distance calculation. For the blue dot location indicator, use the built-in mapboxgl.GeolocateControl — simpler than custom markers. 7. Directions - Route to selected location (optional)
Data Structure
GeoJSON format for locations:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-77.034084, 38.909671]
},
"properties": {
"id": "store-001",
"name": "Downtown Store",
"address": "123 Main St, Washington, DC 20001",
"phone": "(202) 555-0123",
"hours": "Mon-Sat: 9am-9pm, Sun: 10am-6pm",
"category": "retail",
"website": "https://example.com/downtown"
}
}
]
}Key properties:
id- Unique identifier for each locationname- Display nameaddress- Full address for display and geocodingcoordinates-[longitude, latitude]formatcategory- For filtering (retail, restaurant, office, etc.)- Custom properties as needed (hours, phone, website, etc.)
Basic Store Locator Implementation
Step 1: Initialize Map and Data
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
// Store locations data
const stores = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [-77.034084, 38.909671]
},
properties: {
id: 'store-001',
name: 'Downtown Store',
address: '123 Main St, Washington, DC 20001',
phone: '(202) 555-0123',
category: 'retail'
}
}
// ... more stores
]
};
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/standard',
center: [-77.034084, 38.909671],
zoom: 11
});Step 2: Add Markers to Map
Marker strategy by location count:
| Count | Strategy | Reason |
|---|---|---|
| Fewer than 100 | HTML Markers | Full DOM/CSS control; DOM node count is manageable |
| 100–1,000 | Symbol Layer (default) | Renders on the GPU via WebGL — one <canvas>, zero per-point DOM elements |
| More than 1,000 | Clustering | Reduces visual clutter at large scale |
HTML Markers create one DOM element per point. Beyond ~100 locations the browser spends too much time on layout/paint. Symbol layers bypass the DOM entirely — the GPU draws all points in a single WebGL draw call.
Symbol Layer implementation (best for 100–1,000 locations). For HTML Markers (fewer than 100) or Clustering (more than 1,000), see references/markers.md.
map.on('load', () => {
// Add store data as source
map.addSource('stores', {
type: 'geojson',
data: stores
});
// Add custom marker image
map.loadImage('/marker-icon.png', (error, image) => {
if (error) throw error;
map.addImage('custom-marker', image);
// Add symbol layer
map.addLayer({
id: 'stores-layer',
type: 'symbol',
source: 'stores',
layout: {
'icon-image': 'custom-marker',
'icon-size': 0.8,
'icon-allow-overlap': true,
'text-field': ['get', 'name'],
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-offset': [0, 1.5],
'text-anchor': 'top',
'text-size': 12
}
});
});
// Handle marker clicks using Interactions API (recommended)
map.addInteraction('store-click', {
type: 'click',
target: { layerId: 'stores-layer' },
handler: (e) => {
const store = e.feature;
flyToStore(store);
createPopup(store);
}
});
// Or using traditional event listener:
// map.on('click', 'stores-layer', (e) => {
// const store = e.features[0];
// flyToStore(store);
// createPopup(store);
// });
// Change cursor on hover
map.on('mouseenter', 'stores-layer', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'stores-layer', () => {
map.getCanvas().style.cursor = '';
});
});Step 3: Build Interactive Location List
function buildLocationList(stores) {
const listingContainer = document.getElementById('listings');
stores.features.forEach((store, index) => {
const listing = listingContainer.appendChild(document.createElement('div'));
listing.id = `listing-${store.properties.id}`;
listing.className = 'listing';
const link = listing.appendChild(document.createElement('a'));
link.href = '#';
link.className = 'title';
link.id = `link-${store.properties.id}`;
link.innerHTML = store.properties.name;
const details = listing.appendChild(document.createElement('div'));
details.innerHTML = `
<p>${store.properties.address}</p>
<p>${store.properties.phone || ''}</p>
`;
// Handle listing click
link.addEventListener('click', (e) => {
e.preventDefault();
flyToStore(store);
createPopup(store);
highlightListing(store.properties.id);
});
});
}
function flyToStore(store) {
map.flyTo({
center: store.geometry.coordinates,
zoom: 15,
duration: 1000
});
}
function createPopup(store) {
const popups = document.getElementsByClassName('mapboxgl-popup');
// Remove existing popups
if (popups[0]) popups[0].remove();
new mapboxgl.Popup({ closeOnClick: true })
.setLngLat(store.geometry.coordinates)
.setHTML(
`<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
<p>${store.properties.phone}</p>
${store.properties.website ? `<a href="${store.properties.website}" target="_blank">Visit Website</a>` : ''}`
)
.addTo(map);
}
// IMPORTANT: highlightListing MUST include scrollIntoView — without it,
// selecting a marker on the map won't scroll the sidebar to the listing.
function highlightListing(id) {
// Remove existing highlights
const activeItem = document.getElementsByClassName('active');
if (activeItem[0]) {
activeItem[0].classList.remove('active');
}
// Add highlight to selected listing
const listing = document.getElementById(`listing-${id}`);
listing.classList.add('active');
// Scroll the selected listing into view (critical UX requirement)
listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Build the list on load
map.on('load', () => {
buildLocationList(stores);
});Reference Files
Load these references for additional patterns as needed:
| Reference | File | Contents |
|---|---|---|
| HTML Markers & Clustering | references/markers.md | HTML Markers (< 100 locations), Clustering (> 1000 locations) |
| Search & Filter | references/search-filter.md | Text search, category filter |
| Geolocation & Directions | references/geolocation-directions.md | User location, distance calculation, route directions |
| Styling & Layout | references/styling-layout.md | Full HTML/CSS layout, custom marker CSS |
| Performance & A11y | references/optimization-a11y.md | Debounced search, data management, error handling, accessibility |
| Variations & React | references/variations-react.md | Mobile-first, fullscreen, map-only, React implementation |
Resources
- Turf.js - Spatial analysis library (recommended for distance calculations)
- Mapbox GL JS API
- Interactions API Guide
- GeoJSON Specification
- Directions API
- Store Locator Tutorial
Store Locator Patterns
Quick reference for building store locators and location finders with Mapbox.
Architecture
Core Components:
1. Map with markers 2. Location data (GeoJSON) 3. Interactive list 4. Search/filter 5. User location + distance 6. Directions (optional)
Data Structure
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-77.034084, 38.909671]
},
"properties": {
"id": "store-001",
"name": "Downtown Store",
"address": "123 Main St, DC 20001",
"phone": "(202) 555-0123",
"category": "retail",
"hours": "Mon-Sat: 9am-9pm"
}
}
]
}Marker Strategies
| Locations | Strategy | Implementation |
|---|---|---|
| < 100 | HTML Markers | new mapboxgl.Marker() |
| 100-1000 | Symbol Layer | addLayer({ type: 'symbol' }) |
| > 1000 | Clustering | cluster: true in source |
HTML Markers Pattern
stores.features.forEach((store) => {
const marker = new mapboxgl.Marker()
.setLngLat(store.geometry.coordinates)
.setPopup(
new mapboxgl.Popup().setHTML(`
<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
`)
)
.addTo(map);
});Symbol Layer Pattern
map.on('load', () => {
map.addSource('stores', {
type: 'geojson',
data: stores
});
map.addLayer({
id: 'stores',
type: 'symbol',
source: 'stores',
layout: {
'icon-image': 'custom-marker',
'icon-size': 0.8,
'text-field': ['get', 'name'],
'text-offset': [0, 1.5]
}
});
// Using Interactions API (recommended)
map.addInteraction('store-click', {
type: 'click',
target: { layerId: 'stores' },
handler: (e) => {
const store = e.feature;
showStoreDetails(store);
}
});
// Or using traditional event listener
// map.on('click', 'stores', (e) => {
// const store = e.features[0];
// showStoreDetails(store);
// });
});Clustering Pattern
map.addSource('stores', {
type: 'geojson',
data: stores,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
// Clusters
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'stores',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40]
}
});
// Unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'stores',
filter: ['!', ['has', 'point_count']],
paint: { 'circle-color': '#11b4da', 'circle-radius': 8 }
});Interactive List
function buildLocationList(stores) {
const container = document.getElementById('listings');
container.innerHTML = '';
stores.features.forEach((store) => {
const listing = document.createElement('div');
listing.className = 'listing';
listing.innerHTML = `
<a href="#" class="title">${store.properties.name}</a>
<p>${store.properties.address}</p>
${store.properties.distance ? `<p class="distance">${store.properties.distance} mi</p>` : ''}
`;
listing.querySelector('.title').addEventListener('click', (e) => {
e.preventDefault();
flyToStore(store);
createPopup(store);
});
container.appendChild(listing);
});
}
function flyToStore(store) {
map.flyTo({
center: store.geometry.coordinates,
zoom: 15
});
}Search/Filter
Text Search:
function filterStores(query) {
const filtered = {
type: 'FeatureCollection',
features: stores.features.filter((store) => {
const name = store.properties.name.toLowerCase();
const address = store.properties.address.toLowerCase();
return name.includes(query.toLowerCase()) || address.includes(query.toLowerCase());
})
};
map.getSource('stores').setData(filtered);
buildLocationList(filtered);
}
document.getElementById('search').addEventListener('input', (e) => {
filterStores(e.target.value);
});Category Filter:
function filterByCategory(category) {
const filtered =
category === 'all'
? stores
: {
type: 'FeatureCollection',
features: stores.features.filter((s) => s.properties.category === category)
};
map.getSource('stores').setData(filtered);
buildLocationList(filtered);
}Distance Calculation
Using Turf.js (recommended):
import * as turf from '@turf/turf';
// Calculate distance between two points
function calculateDistance(from, to) {
const fromPoint = turf.point(from);
const toPoint = turf.point(to);
const distance = turf.distance(fromPoint, toPoint, { units: 'miles' });
return distance.toFixed(1);
}
// Get user location
navigator.geolocation.getCurrentPosition((position) => {
const userLocation = [position.coords.longitude, position.coords.latitude];
// Add distances
stores.features = stores.features.map((store) => ({
...store,
properties: {
...store.properties,
distance: calculateDistance(userLocation, store.geometry.coordinates)
}
}));
// Sort by distance
stores.features.sort((a, b) => a.properties.distance - b.properties.distance);
buildLocationList(stores);
});Directions Integration
async function getDirections(from, to) {
const query = await fetch(
`https://api.mapbox.com/directions/v5/mapbox/driving/` +
`${from[0]},${from[1]};${to[0]},${to[1]}?` +
`geometries=geojson&access_token=${mapboxgl.accessToken}`
);
const route = (await query.json()).routes[0];
// Display route
map.getSource('route').setData({
type: 'Feature',
geometry: route.geometry
});
// Add route layer if not exists
if (!map.getLayer('route')) {
map.addLayer({
id: 'route',
type: 'line',
source: 'route',
paint: {
'line-color': '#3b9ddd',
'line-width': 5
}
});
}
return {
duration: Math.floor(route.duration / 60),
distance: (route.distance * 0.000621371).toFixed(1)
};
}Layout Patterns
Sidebar + Map:
<div style="display: flex; height: 100vh;">
<div class="sidebar" style="width: 400px; overflow-y: scroll;">
<input type="text" id="search" placeholder="Search..." />
<div id="listings"></div>
</div>
<div id="map" style="flex: 1;"></div>
</div>Mobile Responsive:
@media (max-width: 768px) {
#app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 40vh;
}
#map {
height: 60vh;
}
}Performance Tips
// Debounce search
function debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
const debouncedSearch = debounce(filterStores, 300);Geolocation Control
map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showUserHeading: true
})
);Common Patterns
Restaurant Finder:
- Category filters (cuisine type)
- Price range filters
- Rating display
- Hours of operation
- Delivery/pickup options
Office Locator:
- Department filters
- Floor/building numbers
- Contact information
- Meeting room availability
Retail Store Finder:
- Inventory availability
- Store hours
- Services offered
- Appointment booking
Quick Decisions
Need clustering? → Yes if > 1000 locations
Need search? → Always include for > 10 locations
Need directions? → Yes for physical locations users visit
Need distance sorting? → Yes if user location available
Need filters? → Yes if > 20 locations or multiple categories
Resources
- Turf.js - Spatial analysis (distance, area, etc.)
- Interactions API - Modern event handling
- Store Locator Tutorial
- Directions API
{
"skill_name": "mapbox-store-locator-patterns",
"evals": [
{
"id": 1,
"prompt": "Review this store locator implementation and identify the issues:\n\n```javascript\n// Store locator for 300 coffee shop locations\nmapboxgl.accessToken = TOKEN;\nconst map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/streets-v12' });\n\n// Display markers\nfunction loadMarkers(storeData) {\n storeData.features.forEach(store => {\n new mapboxgl.Marker()\n .setLngLat(store.geometry.coordinates)\n .addTo(map);\n });\n}\n\n// Filter: fetch filtered results from server on each keystroke\nasync function filterStores(searchTerm) {\n const results = await fetch(`/api/stores?q=${encodeURIComponent(searchTerm)}`).then(r => r.json());\n\n // Remove old layer and re-add with filtered data\n if (map.getLayer('stores-layer')) map.removeLayer('stores-layer');\n if (map.getSource('stores')) map.removeSource('stores');\n map.addSource('stores', { type: 'geojson', data: results });\n map.addLayer({ id: 'stores-layer', type: 'symbol', source: 'stores', layout: { 'icon-image': 'marker-15' } });\n\n document.getElementById('listings').innerHTML = '';\n buildLocationList(results);\n}\n\n// Highlight selected listing\nfunction highlightListing(id) {\n document.querySelectorAll('.listing').forEach(l => l.classList.remove('active'));\n const listing = document.getElementById(`listing-${id}`);\n listing.classList.add('active');\n}\n\ndocument.getElementById('search-input').addEventListener('input', e => filterStores(e.target.value));\n\nmap.on('load', () => loadMarkers(allStores));\n```",
"expectations": [
"Identifies 300 HTML markers exceeds the < 100 threshold — recommends switching to a symbol layer",
"Identifies the filter function fetches from the server on every keystroke — recommends loading all store data once and filtering in memory",
"Identifies remove/re-add layer pattern is expensive — recommends map.getSource('stores').setData(filtered) instead",
"Identifies highlightListing is missing scrollIntoView — the selected listing should scroll into view in the sidebar"
]
},
{
"id": 2,
"prompt": "I'm building a coffee shop finder. I have 150 locations in a GeoJSON file. I started with `new mapboxgl.Marker()` for each one but the page feels sluggish. My colleague says I should add clustering. What's the right marker strategy for 150 locations?",
"expectations": [
"Recommends symbol layer (not clustering) for 150 locations",
"States HTML markers are appropriate only for fewer than 100 locations",
"States clustering is appropriate for more than 1000 locations — 150 does not require it",
"Explains symbol layers render on the GPU via WebGL instead of creating 150 DOM elements"
]
},
{
"id": 3,
"prompt": "I want to add a location-aware feature to my store locator: when the user clicks 'Find Stores Near Me', I want to (a) show their location on the map as a blue dot and (b) sort the sidebar listing by distance from their location. What APIs and approach should I use?",
"expectations": [
"Recommends adding mapboxgl.GeolocateControl to the map for the blue dot / on-map location indicator",
"Recommends using navigator.geolocation.getCurrentPosition() separately to get coordinates for distance calculation",
"Recommends using Turf.js (turf.distance) for distance calculation rather than a custom Haversine formula",
"Shows sorting the stores array by the calculated distance before rebuilding the sidebar list"
]
}
]
}
Geolocation, Distance & Directions
Geolocation and Distance Calculation
Two separate APIs for two separate jobs:
1. `mapboxgl.GeolocateControl` — Adds the blue dot on the map showing the user's live position. This is a UI control only; use it for the visual indicator. 2. `navigator.geolocation.getCurrentPosition()` — Returns the raw coordinates you need for distance calculation and sorting. Call this separately because GeolocateControl does not expose coordinates in a convenient way for data processing.
let userLocation = null;
// 1. Add GeolocateControl for the blue dot on-map indicator
map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true,
showUserHeading: true
})
);
// 2. Use navigator.geolocation.getCurrentPosition() separately to get
// coordinates for distance calculation and sorting
navigator.geolocation.getCurrentPosition(
(position) => {
userLocation = [position.coords.longitude, position.coords.latitude];
// Calculate distances and sort
const storesWithDistance = stores.features.map((store) => {
const distance = calculateDistance(userLocation, store.geometry.coordinates);
return {
...store,
properties: {
...store.properties,
distance: distance
}
};
});
// Sort by distance
storesWithDistance.sort((a, b) => a.properties.distance - b.properties.distance);
// Update data
stores.features = storesWithDistance;
// Rebuild list with distances
document.getElementById('listings').innerHTML = '';
buildLocationList(stores);
},
(error) => {
console.error('Error getting location:', error);
}
);
// Calculate distance using Turf.js (recommended)
import * as turf from '@turf/turf';
function calculateDistance(from, to) {
const fromPoint = turf.point(from);
const toPoint = turf.point(to);
const distance = turf.distance(fromPoint, toPoint, { units: 'miles' });
return distance.toFixed(1); // Distance in miles
}
// Update listing to show distance
function buildLocationList(stores) {
const listingContainer = document.getElementById('listings');
stores.features.forEach((store) => {
const listing = listingContainer.appendChild(document.createElement('div'));
listing.id = `listing-${store.properties.id}`;
listing.className = 'listing';
const link = listing.appendChild(document.createElement('a'));
link.href = '#';
link.className = 'title';
link.innerHTML = store.properties.name;
const details = listing.appendChild(document.createElement('div'));
details.innerHTML = `
${store.properties.distance ? `<p class="distance">${store.properties.distance} mi</p>` : ''}
<p>${store.properties.address}</p>
<p>${store.properties.phone || ''}</p>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
flyToStore(store);
createPopup(store);
highlightListing(store.properties.id);
});
});
}Directions Integration
async function getDirections(from, to) {
const query = await fetch(
`https://api.mapbox.com/directions/v5/mapbox/driving/${from[0]},${from[1]};${to[0]},${to[1]}?` +
`steps=true&geometries=geojson&access_token=${mapboxgl.accessToken}`
);
const data = await query.json();
const route = data.routes[0];
// Display route on map
if (map.getSource('route')) {
map.getSource('route').setData({
type: 'Feature',
geometry: route.geometry
});
} else {
map.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
geometry: route.geometry
}
});
map.addLayer({
id: 'route',
type: 'line',
source: 'route',
paint: {
'line-color': '#3b9ddd',
'line-width': 5,
'line-opacity': 0.75
}
});
}
// Display directions info
const duration = Math.floor(route.duration / 60);
const distance = (route.distance * 0.000621371).toFixed(1); // Convert to miles
return { duration, distance, steps: route.legs[0].steps };
}
// Add "Get Directions" button to popup
function createPopup(store) {
const popups = document.getElementsByClassName('mapboxgl-popup');
if (popups[0]) popups[0].remove();
const popup = new mapboxgl.Popup({ closeOnClick: true })
.setLngLat(store.geometry.coordinates)
.setHTML(
`<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
<p>${store.properties.phone}</p>
${userLocation ? '<button id="get-directions">Get Directions</button>' : ''}`
)
.addTo(map);
// Handle directions button
if (userLocation) {
document.getElementById('get-directions').addEventListener('click', async () => {
const directions = await getDirections(userLocation, store.geometry.coordinates);
// Update popup with directions
popup.setHTML(
`<h3>${store.properties.name}</h3>
<p><strong>${directions.distance} mi • ${directions.duration} min</strong></p>
<p>${store.properties.address}</p>
<div class="directions-steps">
${directions.steps.map((step) => `<p>${step.maneuver.instruction}</p>`).join('')}
</div>`
);
});
}
}Markers: HTML Markers, Symbol Layers & Clustering
Choosing the Right Marker Strategy
| Location Count | Strategy | Why |
|---|---|---|
| Fewer than 100 | HTML Markers | Full DOM/CSS control; manageable DOM node count |
| 100–1,000 | Symbol Layer (recommended default) | Renders on the GPU via WebGL — no DOM elements created, so performance stays smooth even with hundreds of points |
| More than 1,000 | Clustering + Symbol Layer | Reduces visual clutter and keeps interaction snappy at large scale |
Key insight: Each HTML Marker creates a real DOM element. At 150+ markers that means 150+ nodes the browser must lay out, paint, and composite every frame. A symbol layer, by contrast, is drawn entirely on the GPU through WebGL — the browser sees only the single <canvas> element regardless of point count.Option 1: HTML Markers (fewer than 100 locations)
const markers = {};
stores.features.forEach((store) => {
// Create marker element
const el = document.createElement('div');
el.className = 'marker';
el.style.backgroundImage = 'url(/marker-icon.png)';
el.style.width = '30px';
el.style.height = '40px';
el.style.backgroundSize = 'cover';
el.style.cursor = 'pointer';
// Create marker
const marker = new mapboxgl.Marker(el)
.setLngLat(store.geometry.coordinates)
.setPopup(
new mapboxgl.Popup({ offset: 25 }).setHTML(
`<h3>${store.properties.name}</h3>
<p>${store.properties.address}</p>
<p>${store.properties.phone}</p>`
)
)
.addTo(map);
// Store reference for later access
markers[store.properties.id] = marker;
// Handle marker click
el.addEventListener('click', () => {
flyToStore(store);
createPopup(store);
highlightListing(store.properties.id);
});
});Option 2: Symbol Layer (100–1,000 locations) — see SKILL.md Step 2 for full implementation.
Option 3: Clustering (more than 1,000 locations)
map.on('load', () => {
map.addSource('stores', {
type: 'geojson',
data: stores,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'stores',
filter: ['has', 'point_count'],
paint: {
'circle-color': ['step', ['get', 'point_count'], '#51bbd6', 10, '#f1f075', 30, '#f28cb1'],
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 30, 40]
}
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'stores',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12
}
});
// Unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'stores',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': '#11b4da',
'circle-radius': 8,
'circle-stroke-width': 1,
'circle-stroke-color': '#fff'
}
});
// Zoom on cluster click
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, {
layers: ['clusters']
});
const clusterId = features[0].properties.cluster_id;
map.getSource('stores').getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
});
});
// Show popup on unclustered point click
map.on('click', 'unclustered-point', (e) => {
const coordinates = e.features[0].geometry.coordinates.slice();
const props = e.features[0].properties;
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(
`<h3>${props.name}</h3>
<p>${props.address}</p>`
)
.addTo(map);
});
});Performance, Best Practices & Accessibility
Performance Optimization
Debounced Search
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
const debouncedFilter = debounce(filterStores, 300);
document.getElementById('search-input').addEventListener('input', (e) => {
debouncedFilter(e.target.value);
});Best Practices
Data Management
// GOOD: Load data once, filter in memory
const allStores = await fetch('/api/stores').then((r) => r.json());
function filterStores(criteria) {
return {
type: 'FeatureCollection',
features: allStores.features.filter(criteria)
};
}
// BAD: Fetch on every filter
async function filterStores(criteria) {
return await fetch(`/api/stores?filter=${criteria}`).then((r) => r.json());
}Error Handling
// Geolocation error handling
navigator.geolocation.getCurrentPosition(
successCallback,
(error) => {
let message = 'Unable to get your location.';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Please enable location access to see nearby stores.';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information is unavailable.';
break;
case error.TIMEOUT:
message = 'Location request timed out.';
break;
}
showNotification(message);
},
{
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0
}
);
// API error handling
async function loadStores() {
try {
const response = await fetch('/api/stores');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to load stores:', error);
showNotification('Unable to load store locations. Please try again.');
return { type: 'FeatureCollection', features: [] };
}
}Accessibility
// Add ARIA labels
document.getElementById('search-input').setAttribute('aria-label', 'Search stores');
// Keyboard navigation
document.querySelectorAll('.listing').forEach((listing, index) => {
listing.setAttribute('tabindex', '0');
listing.setAttribute('role', 'button');
listing.setAttribute('aria-label', `View ${listing.querySelector('.title').textContent}`);
listing.addEventListener('keypress', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
listing.click();
}
});
});
// Focus management
function highlightListing(id) {
const listing = document.getElementById(`listing-${id}`);
listing.classList.add('active');
listing.focus();
listing.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}Search & Filter
Text Search:
function filterStores(searchTerm) {
const filtered = {
type: 'FeatureCollection',
features: stores.features.filter((store) => {
const name = store.properties.name.toLowerCase();
const address = store.properties.address.toLowerCase();
const search = searchTerm.toLowerCase();
return name.includes(search) || address.includes(search);
})
};
// Update map source
if (map.getSource('stores')) {
map.getSource('stores').setData(filtered);
}
// Rebuild listing
document.getElementById('listings').innerHTML = '';
buildLocationList(filtered);
// Fit map to filtered results
if (filtered.features.length > 0) {
const bounds = new mapboxgl.LngLatBounds();
filtered.features.forEach((feature) => {
bounds.extend(feature.geometry.coordinates);
});
map.fitBounds(bounds, { padding: 50 });
}
}
// Add search input handler
document.getElementById('search-input').addEventListener('input', (e) => {
filterStores(e.target.value);
});Category Filter:
function filterByCategory(category) {
const filtered =
category === 'all'
? stores
: {
type: 'FeatureCollection',
features: stores.features.filter((store) => store.properties.category === category)
};
// Update map and list
if (map.getSource('stores')) {
map.getSource('stores').setData(filtered);
}
document.getElementById('listings').innerHTML = '';
buildLocationList(filtered);
}
// Category dropdown
document.getElementById('category-select').addEventListener('change', (e) => {
filterByCategory(e.target.value);
});Styling & Layout
Layout Structure
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Store Locator</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.css" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
font-family: 'Arial', sans-serif;
}
#app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 400px;
height: 100vh;
overflow-y: scroll;
background-color: #fff;
border-right: 1px solid #ddd;
}
.sidebar-header {
padding: 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.sidebar-header h1 {
margin: 0 0 10px 0;
font-size: 24px;
}
/* Search */
.search-box {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.filter-group {
margin-top: 10px;
}
.filter-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
/* Listings */
#listings {
padding: 0;
}
.listing {
padding: 15px 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.listing:hover {
background-color: #f8f9fa;
}
.listing.active {
background-color: #e3f2fd;
border-left: 3px solid #2196f3;
}
.listing .title {
display: block;
color: #333;
font-weight: bold;
font-size: 16px;
text-decoration: none;
margin-bottom: 5px;
}
.listing .title:hover {
color: #2196f3;
}
.listing p {
margin: 5px 0;
font-size: 14px;
color: #666;
}
.listing .distance {
color: #2196f3;
font-weight: bold;
}
/* Map */
#map {
flex: 1;
height: 100vh;
}
/* Popups */
.mapboxgl-popup-content {
padding: 15px;
font-family: 'Arial', sans-serif;
}
.mapboxgl-popup-content h3 {
margin: 0 0 10px 0;
font-size: 18px;
}
.mapboxgl-popup-content p {
margin: 5px 0;
font-size: 14px;
}
.mapboxgl-popup-content button {
margin-top: 10px;
padding: 8px 16px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.mapboxgl-popup-content button:hover {
background-color: #1976d2;
}
/* Responsive */
@media (max-width: 768px) {
#app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 50vh;
}
#map {
height: 50vh;
}
}
</style>
</head>
<body>
<div id="app">
<div class="sidebar">
<div class="sidebar-header">
<h1>Store Locator</h1>
<input type="text" id="search-input" class="search-box" placeholder="Search by name or address..." />
<div class="filter-group">
<select id="category-select">
<option value="all">All Categories</option>
<option value="retail">Retail</option>
<option value="restaurant">Restaurant</option>
<option value="office">Office</option>
</select>
</div>
</div>
<div id="listings"></div>
</div>
<div id="map"></div>
</div>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.0/mapbox-gl.js"></script>
<script src="app.js"></script>
</body>
</html>Custom Marker Styling
/* Custom marker styles */
.marker {
background-size: cover;
width: 30px;
height: 40px;
cursor: pointer;
transition: transform 0.2s;
}
.marker:hover {
transform: scale(1.1);
}
/* Category-specific marker colors */
.marker.retail {
background-color: #2196f3;
}
.marker.restaurant {
background-color: #f44336;
}
.marker.office {
background-color: #4caf50;
}Variations & React Implementation
Mobile-First Layout
/* Mobile first: stack sidebar on top */
@media (max-width: 768px) {
#app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: 40vh;
max-height: 40vh;
}
#map {
height: 60vh;
}
/* Toggle sidebar */
.sidebar.collapsed {
height: 60px;
}
}Fullscreen Map with Overlay
// Map takes full screen, list appears as overlay
const listOverlay = document.createElement('div');
listOverlay.className = 'list-overlay';
listOverlay.innerHTML = `
<button id="toggle-list">View All Locations (${stores.features.length})</button>
<div id="listings" class="hidden"></div>
`;
document.getElementById('toggle-list').addEventListener('click', () => {
document.getElementById('listings').classList.toggle('hidden');
});Map-Only View
// No sidebar, everything in popups
function createDetailedPopup(store) {
const popup = new mapboxgl.Popup({ maxWidth: '400px' })
.setLngLat(store.geometry.coordinates)
.setHTML(
`
<div class="store-popup">
<h3>${store.properties.name}</h3>
<p class="address">${store.properties.address}</p>
<p class="phone">${store.properties.phone}</p>
<p class="hours">${store.properties.hours}</p>
${store.properties.distance ? `<p class="distance">${store.properties.distance} mi away</p>` : ''}
<div class="actions">
<button onclick="getDirections('${store.properties.id}')">Directions</button>
<button onclick="callStore('${store.properties.phone}')">Call</button>
${store.properties.website ? `<a href="${store.properties.website}" target="_blank">Website</a>` : ''}
</div>
</div>
`
)
.addTo(map);
}React Implementation
import { useEffect, useRef, useState } from 'react';
import mapboxgl from 'mapbox-gl';
function StoreLocator({ stores }) {
const mapContainer = useRef(null);
const map = useRef(null);
const [selectedStore, setSelectedStore] = useState(null);
const [filteredStores, setFilteredStores] = useState(stores);
useEffect(() => {
if (map.current) return;
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/standard',
center: [-77.034084, 38.909671],
zoom: 11
});
map.current.on('load', () => {
map.current.addSource('stores', {
type: 'geojson',
data: filteredStores
});
map.current.addLayer({
id: 'stores',
type: 'circle',
source: 'stores',
paint: {
'circle-color': '#2196f3',
'circle-radius': 8
}
});
map.current.on('click', 'stores', (e) => {
setSelectedStore(e.features[0]);
});
});
return () => map.current.remove();
}, []);
// Update source when filtered stores change
useEffect(() => {
if (map.current && map.current.getSource('stores')) {
map.current.getSource('stores').setData(filteredStores);
}
}, [filteredStores]);
return (
<div className="store-locator">
<Sidebar
stores={filteredStores}
selectedStore={selectedStore}
onStoreClick={setSelectedStore}
onFilter={setFilteredStores}
/>
<div ref={mapContainer} className="map-container" />
</div>
);
}Related skills
How it compares
Choose mapbox-store-locator-patterns for end-to-end locator UI architecture instead of generic map styling or backend-only geocoding skills.
FAQ
What components does a Mapbox store locator need?
mapbox-store-locator-patterns lists six core pieces: a marker map, GeoJSON location data, an interactive list, search and filter, user location with distance, and optional directions integration.
What GeoJSON structure does mapbox-store-locator-patterns use?
mapbox-store-locator-patterns uses a FeatureCollection of Point features with properties like id, name, address, phone, category, and hours, following standard GeoJSON coordinate ordering.
Is Mapbox Store Locator Patterns safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.