# Location Map

> A React location map to pick or show a place: a draggable pin, an optional radius with a towns count, offline search and a static fallback.

- Type: React component (Media)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add location-map`
- Docs: https://wingo-ui.com/components/location-map

## Interaction

With a mouse the map is live in place: drag to pan, double-click or use the round + and − buttons to zoom, and the wheel zooms only after you clicked into the map, so scrolling the page never gets stuck on it. Click anywhere to drop the pin there (it falls in with a small squash and a ripple) or drag the pin itself; it lifts while you hold it and the circle follows. Type in the search field to find a town, an area or a county offline (an online geocoder finds street addresses); arrows move through the results, Enter picks one and the map flies there. The locate button asks for your position and moves the pin; a refusal is shown under the map. With a radius set, the slider under the map resizes the circle live, shows how many towns fall inside, and fits the view to the circle when you let go. On a phone the inline map does not steal the page scroll: tap it and a full-height sheet opens with the search on top, a fully interactive map (pinch, drag, tap to place) and Done at the bottom; closing the sheet any other way discards the changes. Keyboard: Tab reaches the search, the map (arrows pan, + and − zoom), the pin (arrows move it 10px, Shift+arrows 50px, committed a moment after you stop), the map buttons and the slider. Screen readers get the map as an application described by the place name and coordinates under it, and hear every move. Without network or before the map loads, a drawn static card shows the pin, the place and the coordinates.

## Installation

```bash
npx wingo-ui@latest add location-map
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/location-map` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/location-map.tsx`

npm dependencies: `npm i leaflet motion lucide-react && npm i -D @types/leaflet`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Slider](https://wingo-ui.com/components/slider) ([Markdown](https://wingo-ui.com/components/slider.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Romanian locations](https://wingo-ui.com/components/ro-locations) ([Markdown](https://wingo-ui.com/components/ro-locations.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { LocationMap, type GeoPoint } from "@/components/ui/location-map"

export function ListingLocation() {
  const [point, setPoint] = useState<GeoPoint | null>({ lat: 46.77, lng: 23.5857 })
  const [radius, setRadius] = useState<number | null>(5)
  return (
    <LocationMap
      value={point}
      onValueChange={setPoint}
      radius={radius}
      onRadiusChange={setRadius}
      locale="en-US"
      labels={{
        search: "Search a town",
        towns: (count, formatted) => (count === 1 ? "1 town nearby" : `${formatted} towns nearby`),
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `GeoPoint \| null` |  | The pin, controlled: { lat, lng }. null shows no pin until one is placed. |
| `defaultValue?` | `GeoPoint \| null` | `"null"` | The pin while uncontrolled. The demo starts at Str. Memorandumului 28, Cluj-Napoca. |
| `onValueChange?` | `(value: GeoPoint \| null) => void` |  | After a drag ends, a click places the pin, a search result or the locate button moves it, or the keyboard rests. |
| `radius?` | `number \| null` |  | The radius in km, controlled. null or 0 hides the circle and the slider. |
| `defaultRadius?` | `number \| null` | `0` | The radius in km while uncontrolled; 0 (or null) means no circle and no slider. |
| `onRadiusChange?` | `(radius: number \| null) => void` |  | Every step of the slider. |
| `radiusRange?` | `LocationMapRange ([min, max] in km)` | `"[1, 50]"` | The slider's bounds in km. |
| `radiusStep?` | `number` | `1` | The slider's step in km. |
| `showRadiusSlider?` | `boolean` | `true` | The slider row under the map, while a radius is set and the map is not read-only. |
| `showTownsInRadius?` | `boolean` | `true` | Adds "14 towns in range" to the radius row, counted from ro-locations (0 outside Romania). |
| `center?` | `GeoPoint` |  | The first view, and a new view when it changes. Defaults to the value, then Cluj-Napoca (46.7712, 23.6236). |
| `zoom?` | `number` |  | The zoom level, controlled (1 to 19). The inline map and the sheet share it. |
| `defaultZoom?` | `number` | `13` | The zoom level while uncontrolled: 13 shows a neighbourhood, 10 a county, 16 a street. |
| `onZoomChange?` | `(zoom: number) => void` |  | After every zoom, from the buttons, the wheel, a pinch or a flight. |
| `draggable?` | `boolean` | `true` | The pin can be dragged, and moved with the arrow keys once focused. |
| `clickToPlace?` | `boolean` | `true` | A click or a tap on the map moves the pin there. |
| `readOnly?` | `boolean` | `false` | Shows the pin and the circle only: no search, no slider, no locate, the pin stays put. The map still pans and zooms. |
| `searchable?` | `boolean` | `true` | The search field above the map. |
| `geocoder?` | `(query: string, options: { signal: AbortSignal; locale: string }) => Promise<GeocodeResult[]>` | `"roLocationsGeocoder"` | What the search asks. roLocationsGeocoder finds Romanian towns, areas and counties offline; nominatimGeocoder({ email }) finds street addresses through OpenStreetMap (one request a second, debounced 700ms). The previous request is aborted. |
| `reverseGeocoder?` | `(point: GeoPoint, options: { signal: AbortSignal; locale: string }) => Promise<GeocodeResult \| null>` |  | Names the pin after it moves ("Str. Memorandumului 28, Cluj-Napoca"), 500ms after it rests. Without it the nearest town from ro-locations names it. nominatimReverseGeocoder({ email }) is ready to use. |
| `locate?` | `boolean` | `true` | The my-location button. Hidden where the browser has no geolocation; a refusal is shown and announced. |
| `height?` | `number` | `320` | The map's height in px. Phones show at most 220px inline, since the full map is a tap away. |
| `tileUrl?` | `string` | `"https://tile.openstreetmap.org/{z}/{x}/{y}.png"` | The raster tiles. OpenStreetMap's public tiles are for light use; a production app should point this at its own tile provider. |
| `attribution?` | `string` | `"© OpenStreetMap"` | The credit in the corner that the tile provider requires. Linked to the OpenStreetMap copyright page for OSM tiles. |
| `darkTiles?` | `"filter" \| "url" \| "none"` | `"filter"` | Dark mode tiles. filter inverts the light tiles with a hue rotation (no second provider needed), url swaps to darkTileUrl, none keeps light tiles in both themes. |
| `darkTileUrl?` | `string` | `""` | The tiles for dark mode when darkTiles is "url", e.g. a dark raster style of your provider. |
| `lazy?` | `boolean` | `true` | Loads Leaflet (42 KB) only once the map is within 200px of the viewport. Until then the static card shows. |
| `staticOnly?` | `boolean` | `false` | Never loads the map: the drawn static card with the pin, the place and the coordinates. For emails, prints, thumbnails and long lists. |
| `interactive?` | `"auto" \| "always" \| "expand"` | `"auto"` | auto: live in place with a mouse, tap to open a full-height sheet on touch (the page keeps scrolling over the inline map). always: live in place everywhere. expand: a click or tap always opens the sheet (a dialog on desktop). |
| `scrollWheelZoom?` | `"focus" \| "always" \| "never"` | `"focus"` | focus: the wheel zooms only after the map was clicked or focused, and stops when the pointer leaves. always and never do what they say. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The pin, the circle, the slider and the sheet's Done button. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone for the pin, the circle and the slider. |
| `locale?` | `string` | `""` | Coordinates, distances and the geocoder's language. Empty uses the app's locale, then the browser's. |
| `setApi?` | `(api: LocationMapApi) => void` |  | Called once with { flyTo(point, zoom?), fitRadius(), locate(): Promise<GeoPoint \| null>, getMap(): L.Map \| null }. The calls go to the sheet's map while it is open. |
| `labels?` | `Partial<LocationMapLabels>` |  | Every string, English by default (DEFAULT_LOCATION_MAP_LABELS): search, noResults, locate, locating, locateDenied, locateFailed, zoomIn, zoomOut, expand, done, radius(km, formatted), radiusLabel, towns(count, formatted), pin, map, sheetTitle, offline, placePin, tapToOpen, near(town, distance), coordinates(lat, lng), moved(place). |
| `classNames?` | `Partial<Record<"search" \| "map" \| "canvas" \| "controls" \| "control" \| "attribution" \| "hint" \| "radius" \| "slider" \| "radiusLabel" \| "summary" \| "error" \| "static" \| "sheet", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root, merged last. |

## Parts

### StaticMap (component)

The fallback card alone, no map library and no network: a drawn neighbourhood with the pin, an optional radius ring and a chip with the place and coordinates. Props: { value?: GeoPoint | null; place?: ReactNode; radius?: number | null; offline?: boolean; height?: number; tone?: Tone; color?: string; locale?: string; labels?: Partial<LocationMapLabels> }.

### roLocationsGeocoder (constant)

The offline default: Romanian cities, areas and counties from ro-locations, diacritics ignored. Type: Geocoder.

### nominatimGeocoder (function)

OpenStreetMap address search. countryCodes defaults to "ro", endpoint to nominatim.openstreetmap.org; requests wait for their turn so there is at most one a second. Signature: (options?: { email?: string; countryCodes?: string; endpoint?: string; limit?: number }) => Geocoder.

### nominatimReverseGeocoder (function)

The street and town under a point, "Strada Memorandumului 28, Cluj-Napoca", in the map's locale. Signature: (options?: { email?: string; endpoint?: string }) => ReverseGeocoder.

### DEFAULT_LOCATION_MAP_LABELS (constant)

Every English string of the map: search, locate, zoom, radius and towns count, the sheet, offline and pin announcements. Spread it and override what you translate.

## Examples

- **Nearby filter**: A 25 km radius around Bucharest; the towns inside come from citiesWithin and follow the slider.
- **Online address search**: nominatimGeocoder finds street addresses (one request a second), the reverse geocoder names the pin.
- **Read-only store location**: readOnly shows the pin only; a reverseGeocoder turns the point into an address line.
- **Dark tiles**: Switch to dark mode: "filter" inverts the light tiles, "none" keeps them light.
- **Tap to expand**: interactive="expand": the map opens in a full-height sheet (a dialog on desktop) and Done commits the pin.
- **Static card in an email**: StaticMap needs no map library and no network: emails, prints and long lists.
- **Tones and colors**: The pin and the radius take any tone or CSS color.

## See also

- [Romanian locations](https://wingo-ui.com/components/ro-locations) ([Markdown](https://wingo-ui.com/components/ro-locations.md))
- [Filter panel](https://wingo-ui.com/components/filter-panel) ([Markdown](https://wingo-ui.com/components/filter-panel.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))

---

Canonical: https://wingo-ui.com/components/location-map
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
