@asammad48/react-map-place
v0.1.0
Published
React location autocomplete, place selection, road-distance calculation and route-polyline rendering using OpenStreetMap
Downloads
75
Maintainers
Readme
@scope/react-map-place
React location autocomplete, place selection, road-distance calculation, and route-polyline rendering powered by OpenStreetMap — no API key required.
Contents
- Features
- Installation
- Quick Start
- Modes
- MapPlaceMap — standalone map
- Props reference
- Vehicle types
- Result types
- Error handling
- Headless hooks
- Imperative utilities
- Customisation
- Endpoint configuration
- CORS and self-hosting
- TypeScript
- SSR / Next.js
- Browser compatibility
- Building and publishing
- License
Features
- Geocoding autocomplete via Photon (OpenStreetMap data)
- Road-distance calculation via OSRM (no API key)
- Full route polylines with GeoJSON
LineStringgeometry - Five vehicle types: car, truck, motorbike, bicycle, walking — each with its own OSRM profile and speed multiplier
- Marker clustering with automatic spider layout for co-located pins
- GeoJSON layer overlays on the standalone map
- Multi-stop routing via
useMultiStopRoutehook - Fully typed — all results, errors, props, and slots are TypeScript interfaces
- Headless hooks for building your own UI on top of the same data layer
- No API key — uses free, open-source tile/routing/geocoding endpoints by default
- Dual ESM + CJS build — works with Vite, webpack, Next.js, and plain Node.js
Installation
npm install @scope/react-map-place
# peer dependencies
npm install react react-dom maplibre-glThen import the stylesheet once in your app entry point:
import '@scope/react-map-place/styles';Quick Start
import { MapPlacePicker } from '@scope/react-map-place';
import '@scope/react-map-place/styles';
export default function App() {
return (
<MapPlacePicker
mode="place"
placeholder="Search for a place..."
showMap
mapHeight={400}
onResult={(result) => console.log(result.place.displayName)}
onError={(err) => console.error(err.code, err.message)}
/>
);
}Modes
MapPlacePicker is a single component with three distinct operating modes selected by the mode prop.
Place mode
Searches for a location and returns its coordinates. No routing is performed.
<MapPlacePicker
mode="place"
placeholder="Search location..."
showMap
mapHeight={350}
initialCenter={{ latitude: 53.795, longitude: -1.759 }}
initialZoom={12}
onResult={(result) => {
// result.mode === "place"
console.log(result.latitude, result.longitude);
console.log(result.place.formattedAddress);
}}
onError={(err) => console.error(err.message)}
/>Distance mode
Searches for a destination and calculates the road distance and travel time from a fixed origin. Returns distance in metres, kilometres, and miles plus duration in seconds, minutes, and a human-readable string.
<MapPlacePicker
mode="distance"
origin={{ latitude: 53.795, longitude: -1.759 }}
vehicleType="car"
showMap
showRouteSummary
onResult={(result) => {
// result.mode === "distance"
console.log(result.distance.miles, 'mi');
console.log(result.duration.formatted); // e.g. "12 min"
}}
onError={(err) => console.error(err.message)}
/>Use origin="current-location" to resolve the browser GPS position as the starting point:
<MapPlacePicker
mode="distance"
origin="current-location"
vehicleType="bicycle"
onResult={(result) => console.log(result.distance.kilometers, 'km')}
onError={(err) => console.error(err.message)}
/>Route mode
Same as distance mode but also renders the full route polyline on the map and returns the GeoJSON LineString geometry.
<MapPlacePicker
mode="route"
origin={{ latitude: 53.795, longitude: -1.759 }}
vehicleType="walking"
showMap
showPolyline
showRouteSummary
routeColor="#10b981"
routeOutlineColor="#065f46"
routeWidth={5}
onResult={(result) => {
// result.mode === "route"
console.log(result.routeFeature); // GeoJSON Feature<LineString>
console.log(result.distance.meters);
console.log(result.duration.formatted);
}}
onError={(err) => console.error(err.message)}
/>MapPlaceMap — standalone map
Use MapPlaceMap when you want the map canvas without the search input — for displaying markers, overlaying GeoJSON, or building a fully custom layout.
import { MapPlaceMap } from '@scope/react-map-place';
import type { MapPlaceMapHandle } from '@scope/react-map-place';
import { useRef } from 'react';
function MyMap() {
const mapRef = useRef<MapPlaceMapHandle>(null);
return (
<MapPlaceMap
ref={mapRef}
height={500}
center={{ latitude: 53.795, longitude: -1.759 }}
zoom={13}
markers={[
{
id: 'office',
coordinates: { latitude: 53.795, longitude: -1.759 },
name: 'Office',
address: '1 Bradford Road',
tooltipData: { Floor: '3rd', Capacity: '200' },
},
]}
showTooltip
showStyleSwitcher
onMarkerClick={(marker) => console.log(marker.id)}
onBoundsChange={(bounds, zoom) => console.log(bounds, zoom)}
/>
);
}Imperative handle
mapRef.current?.flyTo({ center: { latitude: 51.5, longitude: -0.1 }, zoom: 14 });
mapRef.current?.fitBounds(
{ minLongitude: -2, minLatitude: 53, maxLongitude: 0, maxLatitude: 54 },
40
);
const center = mapRef.current?.getCenter();
const zoom = mapRef.current?.getZoom();Props reference
Common props
These props apply to all three MapPlacePicker modes.
| Prop | Type | Default | Description |
|---|---|---|---|
| photonEndpoint | string | https://photon.komoot.io | Geocoding API base URL |
| osrmEndpoint | string | https://router.project-osrm.org | Routing API base URL |
| mapStyleUrl | string | MapLibre demo tiles | MapLibre style JSON URL |
| language | string | — | BCP-47 language code for results |
| countryCodes | string[] | — | ISO 3166-1 alpha-2 country filters |
| searchLimit | number | 5 | Maximum autocomplete results |
| minimumQueryLength | number | 3 | Characters before search fires |
| debounceMs | number | 300 | Autocomplete debounce delay (ms) |
| requestTimeoutMs | number | — | Fetch timeout in ms |
| cacheTtlMs | number | 60000 | In-memory result cache TTL (ms) |
| locationBias | Coordinates | — | Bias results toward a point |
| boundingBox | BoundingBox | — | Restrict results to a bounding box |
| placeholder | string | — | Input placeholder text |
| disabled | boolean | false | Disable the input |
| required | boolean | false | Mark input as required |
| autoFocus | boolean | false | Focus input on mount |
| showMap | boolean | false | Render the embedded map |
| mapHeight | number \| string | 300 | Map container height |
| initialCenter | Coordinates | — | Initial map centre |
| initialZoom | number | 2 | Initial map zoom level |
| selectedZoom | number | 15 | Zoom after a place is selected |
| showDestinationMarker | boolean | true | Show a pin at the selected place |
| showOriginMarker | boolean | true | Show a pin at the origin |
| allowMapClickSelection | boolean | false | Select a place by clicking the map |
| allowMarkerDrag | boolean | false | Allow dragging the destination marker |
| showRouteSummary | boolean | false | Show distance/duration summary panel |
| routeColor | string | #3b82f6 | Route line fill colour |
| routeOutlineColor | string | #1d4ed8 | Route line outline colour |
| routeWidth | number | 5 | Route line width in pixels |
| routeOutlineWidth | number | 9 | Route outline width in pixels |
| routeOpacity | number | 1 | Route line opacity (0-1) |
| fitBoundsPadding | number \| FitBoundsPadding | — | Padding when fitting bounds |
| primaryColor | string | — | Override accent/primary colour |
| className | string | — | Root element class |
| inputClassName | string | — | Input element class |
| resultsClassName | string | — | Results dropdown class |
| mapClassName | string | — | Map container class |
| components | ComponentSlots | — | Custom component overrides |
| value | MapPlace \| null | — | Controlled selected place |
| defaultValue | MapPlace \| null | — | Uncontrolled initial place |
| inputValue | string | — | Controlled input text |
| defaultInputValue | string | — | Uncontrolled initial input text |
| onInputValueChange | (value: string) => void | — | Fired on every keystroke |
| onValueChange | (place: MapPlace \| null) => void | — | Fired when selected place changes |
| onError | (error: MapPlacePickerError) => void | — | Fired on any error |
| onLoadingChange | (loading: LoadingState) => void | — | Loading state changes |
| includeRawProviderData | boolean | false | Attach raw Photon feature to MapPlace.raw |
Place mode props
| Prop | Type | Required | Description |
|---|---|---|---|
| mode | "place" | Yes | Selects place mode |
| onResult | (result: PlaceModeResult) => void | — | Fires when a place is selected |
Distance mode props
| Prop | Type | Required | Description |
|---|---|---|---|
| mode | "distance" | Yes | Selects distance mode |
| origin | Coordinates \| "current-location" | Yes | Route start point |
| vehicleType | VehicleType | — | Vehicle profile (see vehicle types) |
| routingProfile | string | — | Raw OSRM profile override |
| showPolyline | boolean | — | Draw a straight-line fallback polyline |
| onResult | (result: DistanceModeResult) => void | — | Fires with distance/duration data |
Route mode props
| Prop | Type | Required | Description |
|---|---|---|---|
| mode | "route" | Yes | Selects route mode |
| origin | Coordinates \| "current-location" | Yes | Route start point |
| vehicleType | VehicleType | — | Vehicle profile (see vehicle types) |
| routingProfile | string | — | Raw OSRM profile override |
| showPolyline | boolean | true | Draw the full route polyline on the map |
| onResult | (result: RouteModeResult) => void | — | Fires with full route data including GeoJSON |
MapPlaceMap props
| Prop | Type | Default | Description |
|---|---|---|---|
| styleUrl | string | MapLibre demo tiles | MapLibre style JSON URL |
| center | Coordinates | { lat: 20, lng: 0 } | Initial map centre |
| zoom | number | 2 | Initial zoom level |
| height | number \| string | 300 | Container height |
| markers | MapMarker[] | — | Markers with automatic clustering |
| selectedMarkerId | string \| null | — | Highlights a marker and shows tooltip |
| clusterRadius | number | 50 | Pixel radius for clustering |
| showTooltip | boolean | true | Tooltip above selected marker |
| showStyleSwitcher | boolean | false | Style-switcher control |
| styleSwitcherOptions | MapStyleOption[] | DEFAULT_MAP_STYLES | Styles shown in the switcher |
| originCoordinates | Coordinates \| null | — | Origin pin coordinates |
| destinationCoordinates | Coordinates \| null | — | Destination pin coordinates |
| routeGeojson | GeoJSON.Feature<LineString> \| null | — | Route polyline |
| showPolyline | boolean | — | Render routeGeojson on the map |
| legRouteGeojsons | Array<{ feature, color, outlineColor? }> | — | Multi-leg coloured polylines |
| geojsonLayers | GeoJSONLayer[] | — | Arbitrary GeoJSON fill/line/circle layers |
| primaryColor | string | — | Accent colour for pins, clusters, switcher |
| renderClusterMarker | (count: number) => HTMLElement | — | Custom cluster DOM element |
| renderPinMarker | (marker, isSelected) => HTMLElement | — | Custom pin DOM element |
| TooltipContent | React.ComponentType<{ marker: MapMarker }> | — | Custom tooltip content |
| onMarkerClick | (marker: MapMarker) => void | — | Single-marker click handler |
| onClusterClick | (markers: MapMarker[], coords: Coordinates) => void | — | Cluster click handler |
| onTooltipClose | () => void | — | Tooltip dismissed |
| onMapClick | (coords: Coordinates) => void | — | Background map click |
| onBoundsChange | (bounds: BoundingBox, zoom: number) => void | — | Viewport change |
| onDestinationMarkerDragEnd | (coords: Coordinates) => void | — | Destination pin drag |
| onOriginMarkerDragEnd | (coords: Coordinates) => void | — | Origin pin drag |
| onError | (error: MapPlacePickerError) => void | — | Map initialisation errors |
Vehicle types
type VehicleType = "car" | "truck" | "motorbike" | "bicycle" | "walking";| Value | OSRM profile | Speed multiplier |
|---|---|---|
| "car" | driving | 1.0x |
| "truck" | driving | 0.75x (duration 1.33x longer) |
| "motorbike" | driving | 1.0x |
| "bicycle" | cycling | 1.0x |
| "walking" | foot | 1.0x |
The speed multiplier adjusts the OSRM-reported duration to account for vehicle-specific speed differences without requiring a separate OSRM instance per vehicle.
Result types
PlaceModeResult
interface PlaceModeResult {
mode: "place";
place: MapPlace;
latitude: number;
longitude: number;
}DistanceModeResult
interface DistanceModeResult {
mode: "distance";
place: MapPlace;
latitude: number;
longitude: number;
distance: RouteDistance; // { meters, kilometers, miles }
duration: RouteDuration; // { seconds, minutes, formatted }
origin: Coordinates;
destination: Coordinates;
vehicleType?: VehicleType;
}RouteModeResult
interface RouteModeResult {
mode: "route";
place: MapPlace;
latitude: number;
longitude: number;
distance: RouteDistance;
duration: RouteDuration;
origin: Coordinates;
destination: Coordinates;
polyline: GeoJSON.LineString;
routeFeature: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>;
vehicleType?: VehicleType;
}MapPlace
interface MapPlace {
id: string;
name: string;
displayName: string;
formattedAddress: string;
latitude: number;
longitude: number;
coordinates: Coordinates;
city?: string;
country?: string;
countryCode?: string;
postcode?: string;
street?: string;
// ... additional address fields
provider: "photon";
raw?: unknown;
}Error handling
Always supply onError — without it, routing or geocoding failures are silently dropped and previously displayed values remain on screen indefinitely.
import type { MapPlacePickerError } from '@scope/react-map-place';
<MapPlacePicker
mode="route"
origin={{ latitude: 53.795, longitude: -1.759 }}
onResult={(result) => setRoute(result.routeFeature)}
onError={(err: MapPlacePickerError) => {
console.error(err.code, err.message, err.recoverable);
if (err.recoverable) showToast(err.message);
else showFatalBanner(err.message);
}}
/>Error codes
| Code | Meaning | Recoverable |
|---|---|---|
| SEARCH_FAILED | Photon geocoding request failed | Yes |
| NO_RESULTS | Search returned no places | Yes |
| ROUTING_FAILED | OSRM returned an error | Yes |
| ROUTE_NOT_FOUND | OSRM found no route between points | Yes |
| CORS_ERROR | Network request blocked by CORS | Yes |
| REQUEST_TIMEOUT | Fetch timed out | Yes |
| REQUEST_ABORTED | Request cancelled (new search started) | Yes |
| GEOLOCATION_UNSUPPORTED | Browser has no geolocation API | No |
| GEOLOCATION_DENIED | User denied location permission | No |
| GEOLOCATION_TIMEOUT | GPS timed out | Yes |
| INVALID_COORDINATES | Origin or destination coordinates invalid | No |
| ORIGIN_REQUIRED | Distance/route mode used without origin | No |
| MAP_INITIALISATION_FAILED | MapLibre GL failed to initialise | No |
| INVALID_CONFIGURATION | Invalid prop combination | No |
| REVERSE_GEOCODING_FAILED | Photon reverse geocode failed | Yes |
Headless hooks
Use these hooks to build a completely custom UI while keeping the same geocoding and routing data layer.
usePhotonAutocomplete
import { usePhotonAutocomplete } from '@scope/react-map-place';
const { query, setQuery, results, loading, error, clearResults } =
usePhotonAutocomplete({
endpoint: 'https://photon.komoot.io',
language: 'en',
countryCodes: ['gb'],
limit: 5,
minimumQueryLength: 3,
debounceMs: 300,
cacheTtlMs: 60_000,
locationBias: { latitude: 53.795, longitude: -1.759 },
});Returns { query, setQuery, results: MapPlace[], loading, error, clearResults }.
useRoadDistance
import { useRoadDistance } from '@scope/react-map-place';
const { calculateDistance, result, loading, error } = useRoadDistance({
endpoint: 'https://router.project-osrm.org',
vehicleType: 'car',
});
const r = await calculateDistance(origin, destination);
// r?.distance.miles, r?.duration.formatteduseRouteGeometry
import { useRouteGeometry } from '@scope/react-map-place';
const { calculateRoute, route, loading, error, clearRoute } = useRouteGeometry({
vehicleType: 'bicycle',
});
const r = await calculateRoute(origin, destination);
// r?.routeFeature -- GeoJSON Feature<LineString>
// r?.distance, r?.durationuseCurrentLocation
import { useCurrentLocation } from '@scope/react-map-place';
const { coordinates, loading, error, requestLocation } = useCurrentLocation();
const coords = await requestLocation();
// coords?.latitude, coords?.longitudeusePhotonReverseGeocode
Hook for reverse-geocoding a coordinate to a MapPlace.
import { usePhotonReverseGeocode } from '@scope/react-map-place';useSearchHistory
import { useSearchHistory } from '@scope/react-map-place';
const { history, addToHistory, removeFromHistory, clearHistory } =
useSearchHistory({ maxEntries: 10 });useMultiStopRoute
import { useMultiStopRoute } from '@scope/react-map-place';
const { calculateMultiStopRoute, result, loading, error } = useMultiStopRoute({
vehicleType: 'car',
});
// result.legs -- individual leg distances/durations
// result.totalDistance, result.totalDurationImperative utilities
These standalone async functions work outside of React hooks.
searchPlaces
import { searchPlaces } from '@scope/react-map-place';
const places = await searchPlaces('Bradford', {
endpoint: 'https://photon.komoot.io',
limit: 5,
language: 'en',
});reverseGeocode
import { reverseGeocode } from '@scope/react-map-place';
const place = await reverseGeocode(
{ latitude: 53.795, longitude: -1.759 },
{ endpoint: 'https://photon.komoot.io' }
);Customisation
Theming
Override the accent colour system-wide via the primaryColor prop:
<MapPlacePicker mode="place" primaryColor="#8b5cf6" />Or target CSS variables directly in your stylesheet:
.rmp-container {
--rmp-primary: #8b5cf6;
--rmp-primary-dark: #7c3aed;
}All class names are prefixed with rmp- to avoid collisions with your own styles.
Component slots (headless UI)
Replace any part of the built-in UI by passing a components object. Each slot receives fully typed props.
import type { SearchInputSlotProps, SearchResultSlotProps } from '@scope/react-map-place';
<MapPlacePicker
mode="place"
components={{
SearchInput: ({ value, onChange, onKeyDown, loading, ...rest }: SearchInputSlotProps) => (
<div className="my-input-wrapper">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
{...rest}
/>
{loading && <span>Loading...</span>}
</div>
),
SearchResult: ({ place, isHighlighted, onSelect }: SearchResultSlotProps) => (
<div
className={isHighlighted ? 'result highlighted' : 'result'}
onClick={() => onSelect(place)}
>
{place.displayName}
</div>
),
RouteSummary: ({ distanceMiles, durationFormatted }) => (
<p>{distanceMiles.toFixed(1)} mi - {durationFormatted}</p>
),
}}
/>Available slots: SearchInput, SearchResult, SearchResultsContainer, LoadingIndicator, EmptyState, ErrorState, RouteSummary, OriginMarker, DestinationMarker.
Custom markers
<MapPlaceMap
markers={[
{
id: 'store-1',
coordinates: { latitude: 53.795, longitude: -1.759 },
name: 'Bradford Store',
address: '1 Market Street',
tooltipData: { Hours: '9am-6pm', Phone: '01274 000000' },
icon: { url: '/icons/store-pin.svg', width: 36, height: 36 },
},
]}
selectedMarkerId="store-1"
onMarkerClick={(marker) => setSelected(marker.id)}
renderPinMarker={(marker, isSelected) => {
const el = document.createElement('div');
el.className = isSelected ? 'my-pin my-pin--active' : 'my-pin';
el.textContent = marker.name ?? '';
return el;
}}
/>Map styles
Four built-in styles are available via DEFAULT_MAP_STYLES:
| ID | Label | Provider |
|---|---|---|
| positron | Positron | CARTO |
| liberty | Liberty | OpenFreeMap |
| bright | Bright | OpenFreeMap |
| dark | Dark | OpenFreeMap |
import { DEFAULT_MAP_STYLES } from '@scope/react-map-place';
<MapPlaceMap
showStyleSwitcher
styleSwitcherOptions={DEFAULT_MAP_STYLES}
styleUrl={DEFAULT_MAP_STYLES[0].url}
/>Or supply any MapLibre-compatible style URL:
<MapPlacePicker
mode="place"
mapStyleUrl="https://tiles.openfreemap.org/styles/liberty"
/>Endpoint configuration
| Service | Default endpoint | Purpose |
|---|---|---|
| Geocoding | https://photon.komoot.io | Place search and reverse geocoding |
| Routing | https://router.project-osrm.org | Distance and route calculation |
| Map tiles | MapLibre demo tiles | Background map rendering |
Override per component instance:
<MapPlacePicker
mode="route"
origin={{ latitude: 53.795, longitude: -1.759 }}
photonEndpoint="https://geocoding.mycompany.com"
osrmEndpoint="https://routing.mycompany.com"
mapStyleUrl="https://tiles.mycompany.com/style.json"
/>CORS and self-hosting
The default public endpoints (photon.komoot.io, router.project-osrm.org) are shared, rate-limited, unauthenticated services. They are suitable for development and low-traffic demos only.
For production, self-host:
- Photon — github.com/komoot/photon
- OSRM — github.com/Project-OSRM/osrm-backend
- Map tiles — OpenFreeMap or any MapLibre-compatible tile server
A CORS_ERROR in onError means the browser blocked the request. Ensure your server sends the correct Access-Control-Allow-Origin header.
TypeScript
The library ships full TypeScript declarations (.d.ts) with no any types on public interfaces. Import types directly:
import type {
MapPlace,
Coordinates,
OriginInput,
VehicleType,
MapPlacePickerMode,
MapPlacePickerProps,
PlaceModeResult,
DistanceModeResult,
RouteModeResult,
MapPlacePickerResult,
MapPlacePickerError,
MapPlacePickerErrorCode,
RouteDistance,
RouteDuration,
NormalizedRoute,
RouteFeatureProperties,
BoundingBox,
FitBoundsPadding,
ComponentSlots,
LoadingState,
MapMarker,
GeoJSONLayer,
MapStyleOption,
MapPlaceMapHandle,
} from '@scope/react-map-place';SSR / Next.js
MapPlaceMap and MapPlacePicker both use maplibre-gl, which requires a browser window. Wrap them in a dynamic import with ssr: false:
// Next.js App Router
import dynamic from 'next/dynamic';
const MapPlacePicker = dynamic(
() => import('@scope/react-map-place').then((m) => m.MapPlacePicker),
{ ssr: false }
);The headless hooks (usePhotonAutocomplete, useRoadDistance, useRouteGeometry, etc.) and the imperative utilities (searchPlaces, reverseGeocode) are SSR-safe and can be called in server components or getServerSideProps.
Browser compatibility
| Browser | Minimum version | |---|---| | Chrome | 80+ | | Firefox | 78+ | | Safari | 14+ | | Edge | 80+ |
Requires fetch, AbortController, ResizeObserver, and WebGL (for MapLibre). All are natively available in evergreen browsers without polyfills.
Building and publishing
# Run the demo app in development
npm run demo
# Type-check without emitting files
npm run typecheck
# Run tests
npm test
# Build the library (ESM + CJS + type declarations)
npm run build
# Publish to npm
npm publish --access publicBuild output written to dist/:
| File | Format | Description |
|---|---|---|
| index.js | ESM | ES module bundle |
| index.cjs | CJS | CommonJS bundle |
| index.d.ts | DTS | TypeScript declarations |
| map-place-picker.css | CSS | Component styles |
License
MIT
