# location-picker: full documentation > Form-ready map location picker: pan a map under a fixed pin to pick one latitude/longitude. Web component + class, keyless OpenStreetMap by default, Google and MapLibre optional. Generated by scripts/build-llms-full.mjs from the README and docs/ of https://github.com/cyphercodes/location-picker. Short version: https://cyphercodes.github.io/location-picker/llms.txt --- Source: https://github.com/cyphercodes/location-picker/blob/master/README.md # location-picker **Let people pick an exact spot on a map, and get `lat,lng` in your form.** Form-ready map location picker: pan a map under a fixed pin to pick one latitude/longitude. Web component + class, keyless OpenStreetMap by default, Google and MapLibre optional. ![location-picker in a form: an OpenStreetMap map with a fixed red pin on the Eiffel Tower, a search box and a locate button, then a Save button and the value the form submits, location=48.85837,2.294481](https://cyphercodes.github.io/location-picker/readme-hero.png) **[Live demo](https://cyphercodes.github.io/location-picker/)** · [No-code embed](https://cyphercodes.github.io/location-picker/embed/) · [Pick link](https://cyphercodes.github.io/location-picker/pick/) · [API reference](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md) · [For AI agents](#for-ai-agents) · MIT One script tag, no API key, no build step. Put the element inside your form: ```html
``` People pan the map under the pin, search for a place, paste coordinates or tap the locate button (the browser asks for permission only then). On Save, the form posts `location=48.85837,2.294481`, latitude first, like any other field. In JavaScript, read the element's `value` or listen for `change`: `e.detail` has `value`, `lat`, `lng`, `source` and `address`. - **With npm:** `npm i location-picker`, then `import 'location-picker'`. Recipes for React, Vue, Angular, Svelte and more: [Frameworks](#frameworks). - **With a coding agent:** `npx skills add cyphercodes/location-picker` adds the location-picker Agent Skill (it also ships in the package, at `node_modules/location-picker/skills/location-picker/SKILL.md`). [llms.txt](https://cyphercodes.github.io/location-picker/llms.txt) is the short version of these docs, and [Common mistakes](#common-mistakes) lists the traps. - **Without code:** the [embed generator](https://cyphercodes.github.io/location-picker/embed/) writes the snippet for WordPress, Webflow, Squarespace, Shopify or a plain HTML form ([per-platform guide](https://github.com/cyphercodes/location-picker/blob/master/docs/embed.md)). - **To ask a person:** send them the [pick link](https://cyphercodes.github.io/location-picker/pick/). They confirm a point on a map with nothing to install, and you get it back as text they copy to you, an `https://` redirect after their click, or a `postMessage` to your origin ([how it works](#ask-a-person-for-a-location-pick-link)). Made for chat bots, AI agents and support tickets. Try [`/pick/?q=Eiffel Tower`](https://cyphercodes.github.io/location-picker/pick/?q=Eiffel%20Tower). ## Why location-picker Use it when a form needs **one precise point**: a store, branch or sensor location in an admin screen, a delivery or pickup pin at checkout, an event venue, the place of a report, a home or work location in a profile. - **A real form field.** `required` validates, form reset works and back/forward navigation restores it. Submit one `"lat,lng"` entry (`name`), separate entries (`lat-name`, `lng-name`), or fill inputs you already have (`lat-input`, `lng-input`). See [Forms](#forms). - **No API key to start.** OpenStreetMap through Leaflet by default; Google Maps or MapLibre when you choose them. The default OpenStreetMap tiles and Photon address search are free public services with no SLA: for production traffic, use your own or a commercial tile server and geocoder ([usage policies](#providers-and-usage-policies)). - **Pan, search, paste or locate.** Arrow keys and Enter work too, and the locate button asks for permission only when clicked. Typed or pasted coordinates jump straight there with no network call: `48.8584, 2.2945`, DMS, `geo:` URIs and Google, OpenStreetMap or Apple Maps links. - **Phone-first when you want it.** [Field mode](#phone-first-field-mode) shows a compact control that opens the map full screen; the value changes only on Confirm. - **Your stack.** Plain HTML forms and server templates (Laravel, Django, Rails), React, Vue, Angular, Svelte, Solid and Preact; jQuery, Livewire, Alpine and htmx through bound inputs. TypeScript types and JSX typings are included. - **No runtime npm dependencies.** Leaflet, MapLibre and the Google Maps types are optional peers. When the page has no Leaflet, it is loaded from jsDelivr ([Install](#install)). - **Clear errors, generated test results.** Every runtime problem has a stable `code` and a one-line fix ([error codes](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md)). Browser and device results come from the test matrix: [COMPATIBILITY.md](https://github.com/cyphercodes/location-picker/blob/master/COMPATIBILITY.md). **When not to use it:** drawing shapes, routes or areas; several markers; a full mapping application (use Leaflet, MapLibre or the Google Maps JS API directly); text-only address autocomplete with no map; offline maps; React Native or other native mobile apps. ## Install | How | Use | | ----------------------- | ---------------------------------------------------------------------------------------- | | Script tag (no bundler) | `` | | npm | `npm i location-picker`, then `import 'location-picker'` | | npm, class only | `import { LocationPicker } from 'location-picker/core'` (defines no element) | | Upgrading 1.x code | `import locationPicker from 'location-picker/v1'` ([migration guide](https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md)) | The script tag defines `` and sets `window.LocationPicker` (the class, with every export as a static). `https://unpkg.com/location-picker@3` serves the same file. Keep the `@3`: the unpinned `/dist/location-picker.min.js` path is the frozen v1 file. In ES modules from a CDN, `https://cdn.jsdelivr.net/npm/location-picker@3/+esm` defines the element too. Leaflet 1.9.4 is loaded on demand from jsDelivr with Subresource Integrity, only when `window.L` is absent. Bundler users can pass their own Leaflet instead (see [the JavaScript class](#javascript-class)). To watch the value change while you try it, here is a complete page: ```html Pick a location

Picked: nothing yet

``` ## How the value works - The picker starts **unset** (`value === ""`) unless you give it a `value`. While unset, the map shows `center` (or a world view) and the pin is dimmed. - The user **commits** a point by panning the map, using the arrow keys, pressing Enter on the map, clicking the confirm button (shown while unset when `center` is set), picking a search result, or locating. `setLocation()` commits too. - Zooming never changes the value: zoom is anchored at the pin. - Every commit fires `input` (final value), then `change`, with `detail.source`: `user`, `api`, `geolocation` or `search`. Live `input` events fire while the user drags. - Writing `el.value = '48.8584,2.2945'` is silent (no events), like a native ``. - The value string is always latitude first, dot decimal, no spaces, rounded to `precision` decimals (6 by default): `"48.85837,2.294481"`. ## Forms The element is form-associated: it validates with `required`, resets with the form and restores on back/forward navigation. Choose what it submits: | Attributes | Submitted entries | | ------------------------------------------------ | ------------------------------------------------------------- | | `name="loc"` | `loc=48.85837,2.294481` | | `lat-name="lat" lng-name="lng"` | `lat=48.85837`, `lng=2.294481` | | `+ address-name="addr"` | `addr=
` (`""` while unknown) | | `lat-input="lat" lng-input="lng"` (bound inputs) | your existing inputs submit themselves; the picker fills them | The recommended server-rendered pattern puts the inputs inside the element. Without JavaScript they are visible and typed by hand; with it they are hidden, filled by the map, and still submitted. Their server values seed the picker, and a form reset brings them back. ```blade
@csrf @method('PUT') {{-- Without JS these are visible and typed by hand. With JS they are hidden, filled by the map, and still submitted. Their values seed the picker. No `required` here: the picker validates. --}} @error('lat')

{{ $message }}

@enderror
``` ```php // StoreController@update $data = $request->validate([ 'lat' => ['required', 'numeric', 'between:-90,90'], 'lng' => ['required', 'numeric', 'between:-180,180'], 'address' => ['nullable', 'string', 'max:255'], ]); $store->update($data); ``` The same in a Django template: ```django {% load l10n %} ``` jQuery, Livewire (`wire:model`) and Alpine (`x-model`) listen on the bound inputs, which get native `input` and `change` events. jQuery `.serialize()` ignores custom elements, so use bound inputs or `new FormData(form)`. Style errors with `location-picker:state(interacted):invalid`. More in [docs/forms.md](https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md). ## Phone-first: field mode On a phone, a map in the middle of a checkout form gets in the way of scrolling. `presentation="field"` turns the picker into a compact form control: the chosen address (or the coordinates) and a **Choose on map** button. The map opens in a full-screen sheet with search, the locate button, **Confirm** and **Cancel**; the value changes only on Confirm. The map engine loads when the sheet first opens. ```html
``` - `auto` shows the field on touch screens up to 640 px wide (`(pointer: coarse) and (max-width: 640px)`) and the inline map elsewhere; `field` always shows the field; `inline` (the default) always shows the map. - Names, `FormData`, `required` and events are the same in every mode. Pans, search picks and locates inside the sheet are a draft: Confirm commits them (`input` then `change`, with their `source`); Cancel, Escape and `el.close()` change nothing and fire no `input`/`change`. - `el.showPicker()` opens the sheet, `el.close()` cancels it, and the `toggle` event (a `ToggleEvent` with `oldState`/`newState`) fires on open and close. - In field mode, `ready` and `el.map` wait for the first open. Do not gate the form on `el.ready`. - Add `search locate="button"`: field mode uses the same attributes as the inline map and adds nothing on its own. - A height you set on the element applies to the compact field too. Leave it off (the inline map is 320px tall by default), or set it in a `@media` rule that excludes phones. Use the inline map when the map is the content, or when the form is used on desktops. Use `auto` (or `field`) in checkout, delivery and sign-up forms that people fill in on phones. ## Frameworks Bind `value` (a `"lat,lng"` string) and update your state from `change`; `input` is for showing live updates. Do not write the value back from an `input` listener: in 3.0 that stops a pan in progress (Vue's `v-model` is fine). Spell configuration as HTML attributes (`lat-name`, `api-key`), which is SSR-safe. **Event names per framework.** The class callbacks `onChange`/`onInput`/`onAddressChange`/`onReady`/`onError` are options of the JavaScript class only. | Framework | `change` | `input` | `addresschange` | `ready` | `error` | |---|---|---|---|---|---| | Plain JS | `addEventListener('change')` | `'input'` | `'addresschange'` | `'ready'` | `'error'` | | React 19 | `onChange` | `onInput` | `onaddresschange` | `onready` | `onerror` (NOT `onError`: React's synthetic `error` never fires for this element; a type error in `jsx/react`) | | Vue 3 | `@change` | `@input` | `@addresschange` | `@ready` | `@error` | | Angular | `(change)` | `(input)` | `(addresschange)` | `(ready)` | `(error)` | | Svelte 5 | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` | | Solid | `on:change` | `on:input` | `on:addresschange` | `on:ready` | `on:error` | | Preact | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` | React's lowercase native handlers are attached on client render only. After `hydrateRoot`, use a ref (React hydration recipe below). Field mode's `toggle` event is `ontoggle` in React, Preact and Svelte, `@toggle` in Vue, `(toggle)` in Angular and `on:toggle` in Solid. ### React 19 / Next.js App Router ```tsx 'use client'; import 'location-picker'; // defines ; SSR-safe no-op on the server import type {} from 'location-picker/jsx/react'; // JSX typings import { useState } from 'react'; export function StoreLocationField({ initial = '' }: { initial?: string }) { const [value, setValue] = useState(initial); // "lat,lng" or "" // Attributes, value and onChange/onInput survive SSR hydration; objects (messages…) and onaddresschange go in a ref (see the React hydration recipe). return ( <> setValue(e.currentTarget.value)} style={{ height: 360 }} />

{value || 'Pan the map to choose'}

); } //
: formData.get('lat'), formData.get('lng'), formData.get('location') ``` **React hydration recipe.** With `hydrateRoot` (Next.js App Router, Remix), React applies neither object props (`messages`, `mapOptions`, `engine`, `geocoder`, `geolocation`, an adapter-object `provider`) nor the lowercase native handlers to custom elements. Set them in a ref callback; refs are attached on hydration. Attributes, `value` and `onChange`/`onInput` keep working. ```tsx 'use client'; import 'location-picker'; import type {} from 'location-picker/jsx/react'; import type { AddressDetail } from 'location-picker'; import { useState } from 'react'; export function AddressField() { const [address, setAddress] = useState(null); return ( <> { if (!el) return; el.messages = { locate: 'Use my position' }; const onAddress = (e: CustomEvent) => setAddress(e.detail.address); el.addEventListener('addresschange', onAddress); return () => el.removeEventListener('addresschange', onAddress); }} />

{address ?? 'No address yet'}

); } ``` ### Vue 3 ```ts // vite.config.ts import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'location-picker' } } })], }); ``` ```vue ``` ### Angular 17+ (reactive forms, no custom ControlValueAccessor) ```ts import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import 'location-picker'; @Component({ selector: 'app-store-form', standalone: true, imports: [ReactiveFormsModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], template: ` @if (form.controls.location.touched && form.controls.location.invalid) {

Choose a location.

} `, }) export class StoreFormComponent { form = new FormGroup({ location: new FormControl('', { nonNullable: true, validators: [Validators.required] }) }); save() { const [lat, lng] = this.form.getRawValue().location.split(',').map(Number); console.log({ lat, lng }); } } ``` Svelte 5, Solid, Preact, Nuxt, Astro, Livewire, Alpine, htmx and component tests in jsdom or happy-dom: [docs/frameworks.md](https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md). ## Reading the result | Event | `detail` | Fires | | --------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `change` | `{ value, lat, lng, source, zoom, address, accuracy?, place? }` | on every commit; de-duplicated; never for property writes, form reset or restore | | `input` | same as `change` | live while the user drags (at most once per frame), and with the final value before every `change` | | `addresschange` | `{ value, lat, lng, address, result }` | when the address for the current value resolves (`search` or `reverse-geocode`) | | `ready` | `{ provider, map }` | when the map is mounted, and again after each remount | | `error` | a `LocationPickerError` with `code`, `fix`, `level` | on asynchronous failures; logged with its fix unless you call `preventDefault()` | | `toggle` | a native `ToggleEvent` (`oldState`, `newState`) | field mode: when the map sheet opens or closes | Read the current state from properties: `el.value` (`"lat,lng"` or `""`), `el.valueAsLatLng` (`{ lat, lng }` or `null`), `el.address` and `el.map` (the engine map, after `ready`). `el.ready` is a promise for the mounted picker. ## Google Maps Google is used only when you ask for it: `provider="google"`, or `api-key`, `map-id` or `marker="advanced"`. Search then uses Google's `PlaceAutocompleteElement` and reverse geocoding uses the Google Geocoder, so Google results never appear on an OpenStreetMap map and vice versa. ```html
``` If the page already loads the Maps JavaScript API, `provider="google"` uses it and no key attribute is needed. Wheel and double-click zoom stay anchored at the pin; a pinch on Google Maps zooms around the fingers and commits the new centre. ## JavaScript class `LocationPicker` renders into an element you own (light DOM), for pages that do not use the custom element or that need the engine map synchronously. ```html
``` ```ts import * as L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { LocationPicker } from 'location-picker/core'; // class only, no custom element import { leafletProvider } from 'location-picker/leaflet'; const picker = new LocationPicker('#map', { provider: leafletProvider({ engine: L }), value: '51.5074,-0.1278', zoom: 13, search: true, locate: 'button', latInput: '#lat', lngInput: '#lng', onChange: ({ lat, lng, source }) => console.log(source, lat, lng), }); L.circle(picker.valueAsLatLng!, { radius: 200 }).addTo(picker.map!); // synchronous: adapter object + engine present (never waits for CSS) // later (SPA route change): picker.destroy(); ``` Every option is also an element property and (for primitives) a kebab-case attribute: `latInput` = `lat-input`. Unknown option keys log a warning that lists the valid ones. ## Attributes | Attribute | Default | Notes | | -------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `value` | `""` (unset) | Initial value; lenient (`"48.8584, 2.2945"`, DMS, `geo:`, map URLs) | | `center`, `zoom` | world view `20,0`; zoom 15 with a value or center, else 2 | `center` is where the view starts while unset; it is never submitted | | `name`, `lat-name`, `lng-name`, `address-name` | `""` | FormData entries | | `lat-input`, `lng-input`, `value-input`, `address-input` | `""` | Existing inputs to fill (id or CSS selector) | | `required`, `disabled`, `readonly` | off | `readonly`: zoom allowed, the value cannot change | | `provider` | `leaflet` (`google` when `api-key` or `map-id` is set) | `leaflet`, `google` or `maplibre` | | `api-key`, `map-id` | `""` | Google only | | `tile-url`, `attribution` | OpenStreetMap tiles and credit | Leaflet raster tiles; use your own tiles in production | | `map-style` | `https://tiles.openfreemap.org/styles/liberty` | MapLibre only | | `engine-url`, `engine-css-url` | `""` (automatic) | Engine JS and CSS to load: a URL, or `"none"` to never load them (you provide the engine) | | `search`, `placeholder` | off | Search box; typed or pasted coordinates jump directly, with no network call | | `reverse-geocode` | off | Look up the address after each commit (fires `addresschange`) | | `locate` | `off` | `button` (prompts only on click) or `auto` (also tries once on load while unset); `""` = `button` | | `marker` | `pin` | `none` hides the pin; `advanced` = Google AdvancedMarkerElement (needs `map-id`) | | `gesture-handling` | `auto` | `cooperative`, `greedy` or `none`; `auto` is cooperative (Ctrl/⌘ + wheel) when the page scrolls | | `max-zoom`, `precision` | 19 for OSM tiles; 6 decimals | | | `presentation` | `inline` | `field` (compact control, full-screen map sheet) or `auto` (field on phones); see field mode | | `webmcp` | off | Register experimental WebMCP tools for browser agents | Methods: `setLocation(lat, lng)` or `setLocation('lat,lng')` (commits with source `api`), `locate()` (geolocates, never hangs), `geocode(query)`, `lookupAddress()`, `showPicker()` and `close()` (field mode), plus the native `checkValidity()`, `reportValidity()` and `setCustomValidity()`. Object properties (`messages`, `geocoder`, `engine`, `mapOptions`, `geolocation`) are set from JavaScript. Full list: [docs/api.md](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md). ## Styling The element renders into an open shadow root. Style it with CSS custom properties (`--lp-pin-color`, `--lp-pin-size`, `--lp-accent`, `--lp-tile-filter`, …), parts (`location-picker::part(pin)`), slots (`slot="marker"` for your own pin) and states (`location-picker:state(unset)`). It is 320px tall by default; set your own `height`. Dark mode follows `color-scheme`, and the layout uses logical properties for right-to-left pages. Field mode adds the parts `field`, `field-value`, `field-button`, `sheet`, `sheet-header` and `cancel-button` (Confirm is `confirm-button`) and the state `:state(open)`; its texts are the `chooseOnMap`, `fieldPlaceholder`, `cancel` and `confirm` keys of `messages`. ## Providers and usage policies - **OpenStreetMap tiles** (the default) come from `https://tile.openstreetmap.org` under the [OSM tile usage policy](https://operations.osmfoundation.org/policies/tiles/): the "© OpenStreetMap contributors" attribution stays visible, and the service is best-effort with no SLA. For production traffic, use your own or a commercial tile server with `tile-url` and `attribution`. - **Search and reverse geocoding** on Leaflet and MapLibre use [Photon](https://photon.komoot.io/), which is free under fair use with no SLA. Requests are debounced (at least 300 ms on the public endpoint), cached and need 3 characters. For production, self-host Photon or use a commercial endpoint: `geocoder: photonGeocoder({ url })`, or your own geocoder. - **Google Maps** content stays on Google maps. The [Google Maps Platform Terms](https://cloud.google.com/maps-platform/terms) do not allow Google results on a non-Google map, so each provider gets its paired geocoder and a mismatch is refused with `GEOCODER_MISMATCH`. Switching providers clears the address. - **MapLibre** is bring-your-own (`maplibre-gl` and its CSS), with OpenFreeMap vector tiles by default. Nominatim is never a default and is not used by any example here. Before writing your own Nominatim geocoder, read its usage policy at https://operations.osmfoundation.org/policies/nominatim/: it forbids client-side autocomplete and caps use at one request per second. Details, self-hosting and custom geocoders: [docs/providers.md](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md). Content Security Policy recipes for each setup: [docs/csp.md](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md). ## Common mistakes - **The unpinned v1 URL.** `https://unpkg.com/location-picker/dist/location-picker.min.js` (and the same path on jsDelivr) is the frozen v1 file forever: global `locationPicker`, Google Maps only, no `` element. For v3, load `https://cdn.jsdelivr.net/npm/location-picker@3` and keep the `@3`. - **Latitude first, no arrays.** Values are `"lat,lng"` strings or `{ lat, lng }` objects. Arrays are rejected because GeoJSON uses `[lng, lat]`. A swapped pair with both numbers in range (`"2.2945,48.8584"`) cannot be detected, so check the order. - **Choose Google explicitly.** Google Maps is used only with `provider="google"`, `api-key`, `map-id` or `marker="advanced"`. Otherwise the picker uses OpenStreetMap, even when `google.maps` is already loaded on the page. - **Put `required` on the picker,** never on hidden bound inputs. A hidden, empty, `required` input blocks the submit as "not focusable". - **jQuery `.serialize()` skips custom elements.** Use bound inputs (`lat-input`/`lng-input`) or `new FormData(form)`. - **`import 'location-picker'` defines the element.** `location-picker/core` is the class only and defines nothing, so importing only `core` leaves `` blank. - **Read results from `value` or `valueAsLatLng`,** not `getMarkerPosition()`. While the picker is unset, `getMarkerPosition()` returns the view centre (for example the 20,0 world view), not a chosen point. - **The `lat`/`lng` attributes are the initial value only.** Once the control is dirty (a pan, or the `el.value = ''` that `v-model` and `formControlName` write on mount), changing them is ignored with a warning. Bind `value="lat,lng"` or call `setLocation()`. - **React SSR (`hydrateRoot`: Next.js App Router, Remix):** React applies no object props (`messages`, `geocoder`, …) and no lowercase native handlers (`onaddresschange`, `onready`, `onerror`) during hydration. Set them in a ref callback (see the React hydration recipe). - **MapLibre CSS is yours to load:** `import 'maplibre-gl/dist/maplibre-gl.css'`, or set `engine-css-url`. The library never fetches MapLibre or its CSS by default. - **The stylesheet path is `location-picker/style.css`.** `location-picker/dist/location-picker.css` is not exported. You only need it with `injectStyles: false`. Every runtime problem is reported with a stable code and a one-line fix, for example `[location-picker] … Fix: Add api-key="YOUR_KEY", or load the Google Maps JS API before the picker.` See [docs/errors.md](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md) and [docs/troubleshooting.md](https://github.com/cyphercodes/location-picker/blob/master/docs/troubleshooting.md). ## Upgrading from 1.x or 2.x The old script URL keeps working: `dist/location-picker.min.js` is a frozen copy of the v1 API (global `locationPicker`, Google Maps) with the longitude-wrap fix. npm users of 1.x change one import; 2.x Google code keeps a synchronous map with an adapter object: ```ts // 1.x npm code: one-line change, identical behaviour (frozen file) import locationPicker from 'location-picker/v1'; // 2.x Google code: keep a synchronous google.maps.Map with an adapter object import { LocationPicker } from 'location-picker/core'; import { googleProvider } from 'location-picker/google'; const lp = new LocationPicker('map', { provider: googleProvider(), value: { lat: 45.5, lng: -73.56 }, onChange: (d) => console.log(d.lat, d.lng) }); google.maps.event.addListener(lp.map!, 'idle', () => console.log(lp.getMarkerPosition())); ``` Every v1 and v2 API, what changed and the one-line fixes: [docs/migration.md](https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md). ## Compatibility Browser, device and framework results are generated by the test matrix, never written by hand: see [COMPATIBILITY.md](https://github.com/cyphercodes/location-picker/blob/master/COMPATIBILITY.md) and the [compatibility page](https://cyphercodes.github.io/location-picker/compat/). ## Ask a person for a location: pick link `https://cyphercodes.github.io/location-picker/pick/` is a hosted page that asks a person for an exact location with nothing to install: send the link from a chat bot, an AI agent, a support ticket or a no-code automation. The person moves the map (or searches, or taps "Use my location") and confirms. - Start it with `?lat=48.8584&lng=2.2945` (a starting point to confirm or adjust), `zoom` (1 to 19), `q=Eiffel Tower` (picks the first search result) and `lang`. - By default the page shows the result with copy buttons (decimal, DMS, `geo:` URI, Google Maps and OpenStreetMap links) and Web Share. - `redirect=https://example.com/done` sends the person there after a click, with `lat`, `lng` and (when known) `address` appended. Only `https://` URLs are accepted, and the page shows the destination host first. - `return=postmessage&origin=https://example.com` posts `{ type: 'location-picker:pick', lat, lng, address }` to the page that opened it (`window.open`) or embeds it (an iframe), only to that exact origin. `address` is `null` while unknown. - The page has no server, no storage, no cookies and no analytics; the state lives only in the URL. It uses OpenStreetMap and Photon. For heavy use, host your own copy: `node scripts/build-site.mjs` builds the static site (MIT) into `_site/`, with the page at `pick/`. Details and a `postMessage` listener: [docs/ai-agents.md](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md#ask-a-person-for-a-location-the-pick-link). ## For AI agents - [llms.txt](https://cyphercodes.github.io/location-picker/llms.txt) and [llms-full.txt](https://cyphercodes.github.io/location-picker/llms-full.txt) - An Agent Skill, shipped in the npm package: [skills/location-picker/SKILL.md](https://github.com/cyphercodes/location-picker/blob/master/skills/location-picker/SKILL.md) - `custom-elements.json` (the package's `customElements` field) for editor and agent tooling - Typed errors with a `code` and a `fix`, and JSDoc with defaults and examples on every option - Opt-in WebMCP tools so browser agents can read and set the location: [docs/ai-agents.md](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md) - The [pick link](#ask-a-person-for-a-location-pick-link), the zero-install way for an agent or a bot to ask a person for a location Working on this repository: [AGENTS.md](https://github.com/cyphercodes/location-picker/blob/master/AGENTS.md). ## Documentation [API reference](https://github.com/cyphercodes/location-picker/blob/master/docs/api.md) · [Forms](https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md) · [No-code embed](https://github.com/cyphercodes/location-picker/blob/master/docs/embed.md) · [Frameworks](https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md) · [Providers](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md) · [CSP](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md) · [Errors](https://github.com/cyphercodes/location-picker/blob/master/docs/errors.md) · [Troubleshooting](https://github.com/cyphercodes/location-picker/blob/master/docs/troubleshooting.md) · [Migration](https://github.com/cyphercodes/location-picker/blob/master/docs/migration.md) · [AI agents](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md) · [Changelog](https://github.com/cyphercodes/location-picker/blob/master/CHANGELOG.md) · [Contributing](https://github.com/cyphercodes/location-picker/blob/master/CONTRIBUTING.md) ## License [MIT](https://github.com/cyphercodes/location-picker/blob/master/LICENSE) © 2018-2026 Rayan Salhab. The MIT License applies to all prior versions of location-picker as well. --- Source: https://github.com/cyphercodes/location-picker/blob/master/docs/forms.md # Forms `` is a form-associated custom element (built on `ElementInternals`). Inside a `
` it behaves like a native input: it submits a value, takes part in validation, resets with the form and restores its state on back/forward navigation. ## What gets submitted Pick the names you want. The value is always latitude first, dot decimal, no spaces. | Configuration | Entries when set | Entries when unset | | ------------------------------------------- | ------------------------------------- | ---------------------------- | | `name="loc"` | `loc=48.85837,2.294481` | `loc=` | | `lat-name="lat" lng-name="lng"` | `lat=48.85837`, `lng=2.294481` | `lat=`, `lng=` | | `name` + `lat-name` + `lng-name` | all three | all three empty | | `+ address-name="addr"` | `addr=
` (`""` while unknown) | `addr=` | | none of the above | none | none; validation still works | | `disabled`, or inside `
` | none | none | `address-name` needs `search` or `reverse-geocode`. The address may arrive a moment after the coordinates (reverse geocoding is debounced by 400 ms). ```html ``` ## Bound inputs When the page already has inputs, let the picker fill them instead of adding its own entries. Pass an id (tried first) or a CSS selector: | Attribute (class option) | Receives | | -------------------------------- | -------------------------------- | | `lat-input` (`latInput`) | the latitude | | `lng-input` (`lngInput`) | the longitude | | `value-input` (`valueInput`) | `"lat,lng"` | | `address-input` (`addressInput`) | the address (`""` while unknown) | - The bound inputs submit themselves; the picker never adds entries for them. - Their initial values seed the picker when it has no `value`. - They mirror the picker's events: a native `input` event for each live change, `input` + `change` on each commit, and nothing for silent writes (property writes, reset, restore). Their events are dispatched before the picker's own. - Listeners on the input itself keep working: jQuery `$('#lat').on('change', …)`, Livewire `wire:model`, Alpine `x-model`. - Clearing the value with a property write, a reset to an empty value or a restore sets them to `""` silently. They are never cleared at startup. ## Progressive enhancement (Laravel, Django, Rails) Put the bound inputs inside the element: - Without JavaScript they are visible and typed by hand. - After the element upgrades they are no longer rendered, but they are still submitted, and the map writes them. - Their server-rendered values seed the picker, even when the script runs in `` before they are parsed. - Their native events do not bubble, so a `change` listener on the picker only ever receives the picker's own `CustomEvent`. - A form reset brings back the server values in the inputs and in the picker. ```blade
@csrf @method('PUT') {{-- Without JS these are visible and typed by hand. With JS they are hidden, filled by the map, and still submitted. Their values seed the picker. No `required` here: the picker validates. --}} @error('lat')

{{ $message }}

@enderror
``` ```php // StoreController@update $data = $request->validate([ 'lat' => ['required', 'numeric', 'between:-90,90'], 'lng' => ['required', 'numeric', 'between:-180,180'], 'address' => ['nullable', 'string', 'max:255'], ]); $store->update($data); ``` ```django {% load l10n %} ``` `unlocalize` keeps Django from writing a decimal comma. Validate on the server too, for example with Django's `DecimalField(min_value=-90, max_value=90)` for latitude: ```python from django import forms class StoreForm(forms.Form): lat = forms.DecimalField(min_value=-90, max_value=90, decimal_places=6) lng = forms.DecimalField(min_value=-180, max_value=180, decimal_places=6) ``` Hidden inputs outside the element work the same way (the most common pattern in existing apps): ```html
``` ## Validation - `required` reports `valueMissing` while the picker is unset, with the message "Please choose a location on the map." (`messages.valueMissing`). `reportValidity()` focuses the map. - `setCustomValidity(message)` works as on native inputs. - **Put `required` on the picker, never on hidden bound inputs.** A hidden, empty, `required` input blocks the submit as "not focusable", and the picker warns about it. - `readonly` (zoom allowed, the value cannot change) and `disabled` bar the element from validation, as on native inputs. - Style errors after the user interacted with `location-picker:state(interacted):invalid`. `:user-invalid` is not reliable on custom elements. - `:required` and `:read-only` never work on form-associated custom elements. Use `location-picker[required]` and `location-picker[readonly]`. - `center` is only where the view starts. It is never submitted; the user has to commit a point (pan, Enter, the confirm button, search, locate). ```html
``` ## Reset and back/forward - `form.reset()` returns the picker to its reset value: the `value` attribute, else the `lat` + `lng` attributes, else the bound inputs' server-rendered values, else unset. It then writes that value into the bound inputs. Nothing fires: no `input`, no `change`. - Back/forward navigation (bfcache, history) restores the value, the zoom and the address, again silently. - The dirty flag works like a native input: once the user (or a property write) changed the value, later changes to the `value`, `lat` or `lng` attributes only update the default. A `lat`/`lng` change ignored this way logs one warning. To control the value, bind the `value` property or call `setLocation()`. ## jQuery, AJAX, Livewire, Alpine, htmx - **jQuery `.serialize()` ignores custom elements:** it only serializes `input`, `select` and `textarea`. Use bound inputs, or `new FormData(form)`. - **Livewire and Alpine:** bind the inputs (`wire:model="lat"`, `x-model="lat"`) and point `lat-input`/`lng-input` at them. - **Delegated listeners** (`$(form).on('change', 'input', …)`, htmx `hx-trigger="change"` on the form) do not see the native events of inputs nested in the picker, because those do not bubble. Listen to the picker's own `change` instead: `hx-trigger="change from:location-picker"`. ## Name collisions If a `*-name` attribute equals the `name` of a bound input in the same form, the value would be submitted twice. The picker logs one warning ("use either lat-name or lat-input") and never changes your inputs. ## The JavaScript class in a form `LocationPicker` is not a form control. In class mode, bind inputs with `latInput`/`lngInput`/`valueInput`/`addressInput`; their native events bubble normally. ```ts import { LocationPicker } from 'location-picker'; const picker = new LocationPicker('#map', { latInput: '#lat', lngInput: '#lng', search: true }); picker.addEventListener('change', (e) => console.log(e.detail.value)); ``` --- Source: https://github.com/cyphercodes/location-picker/blob/master/docs/embed.md # No-code embed You can add a map location field to a site builder or a hosted form without writing code. The [snippet generator](https://cyphercodes.github.io/location-picker/embed/) asks a few questions (the map, what the form should receive, the language, inline map or phone field) and gives you one block to paste, with a live preview. This page explains where to paste it on each platform, and where it cannot work. Every snippet loads `https://cdn.jsdelivr.net/npm/location-picker@3`. Keep the `@3`: it pins the major version, so a future major release never changes your page. The unpinned `/dist/location-picker.min.js` URL is the old v1 file. ## What the form receives The picker joins the form it sits in, like any input. Choose one of these in the generator: | Choice | Attributes | The form receives | | ------------------------------- | ----------------------------------------------- | ------------------------------------------------------ | | One field | `name="location"` | `location=48.85837,2.294481` (latitude first) | | Separate latitude and longitude | `lat-name="lat" lng-name="lng"` | `lat=48.85837` and `lng=2.294481` | | Hidden inputs | `lat-input`, `lng-input` pointing at ``s | whatever those inputs are named; the picker fills them | | An address too | `address-name="address"` (or `address-input`) | the street address found for the point | Choose **hidden inputs** when the platform only collects real `` elements (Webflow, Netlify Forms, Shopify, most WordPress form plugins). The picker writes the coordinates into them and fires their native `input` and `change` events. For a form that people fill in on phones (delivery, pickup, sign-up), choose **Automatic** under Presentation (`presentation="auto"`): on a phone the picker is a compact field with a **Choose on map** button that opens the map full screen, and on a desktop it is the inline map. See [field mode](https://github.com/cyphercodes/location-picker/blob/master/README.md#phone-first-field-mode). A minimal snippet (OpenStreetMap, one field, search and a required check): ```html ``` ## Plain HTML forms Tick **Include a complete form** in the generator and set the form's action. Form services such as [Formspree](https://formspree.io/) and [Basin](https://usebasin.com/) accept any field names, so one `location` field or `lat` and `lng` both work. ```html
``` [Netlify Forms](https://docs.netlify.com/forms/setup/) registers the fields it finds in your HTML when the site is deployed, so it never sees fields that a script adds later. Choose **hidden inputs** so the fields are in the HTML, and add `data-netlify="true"` and a `name` to the form: ```html
``` ## WordPress Add a **Custom HTML** block and paste the snippet. WordPress keeps ` ``` Without a form plugin, use a complete form posting to a form service (see [plain HTML forms](#plain-html-forms)). ## Webflow Put a **Code Embed** element inside your **Form Block** and paste the snippet. Webflow's form handler only collects real inputs, so choose **Hidden inputs** in the generator: the snippet then carries its own hidden ``s, named as you chose, inside the embed. Custom code needs a Webflow plan that allows it. ## Squarespace The built-in Form Block cannot take extra fields. Add a **Code Block** instead and paste a snippet with **Include a complete form** ticked, posting to Formspree, Basin or your own endpoint (see [plain HTML forms](#plain-html-forms)). JavaScript in Code Blocks needs a Squarespace plan that allows it. ## Shopify To save a delivery or pickup spot with an order line, add a **Custom Liquid** block to the product page and use hidden inputs named as line item properties, such as `properties[Delivery location]`. The inputs must sit inside the product form, or carry its `form="…"` id. That id depends on the theme; in Dawn-based themes it is `product-form-{{ section.id }}`. ```liquid ``` The order then shows `Delivery location: 48.85837,2.294481`. Shopify's checkout does not run custom scripts, so the picker cannot be added to the checkout pages themselves. ## Where it cannot work - **Google Forms** and **Microsoft Forms** have no custom fields and run no scripts. Ask for coordinates as text instead, or send people the [pick link](https://github.com/cyphercodes/location-picker/blob/master/docs/ai-agents.md#ask-a-person-for-a-location-the-pick-link) and have them paste the result. - **Wix** runs HTML embeds inside an iframe, so the picker cannot join a Wix form. A complete form inside the same embed, posting to Formspree or Basin, does work. The "Use my location" button may be blocked inside that iframe. - **Any iframe-based embed** has the same limit: an element inside an iframe cannot add fields to a form outside it. ## Before you go live - **Maps and search have usage policies.** The default OpenStreetMap tiles and the Photon search are free, best-effort services for light use. A busy site should use its own or a commercial tile server and geocoder: see [providers](https://github.com/cyphercodes/location-picker/blob/master/docs/providers.md). - **Google Maps** needs a browser key restricted by HTTP referrer, with the Maps JavaScript API and Places API (New) enabled, and is billed by Google. Paste it into the generator's key field; the generator itself stores nothing. - **Content Security Policy:** if your platform lets you set one, see [csp.md](https://github.com/cyphercodes/location-picker/blob/master/docs/csp.md). - **Validate on the server too:** latitude in [-90, 90], longitude in [-180, 180]. --- Source: https://github.com/cyphercodes/location-picker/blob/master/docs/frameworks.md # Frameworks `` is a standard custom element, so every framework can render it. The rules are the same everywhere: - **Bind `value`**, a `"lat,lng"` string (`""` while unset). Writing it is silent (no events), so bindings never loop. - **Update your state from `change`** (committed values). Use **`input`** only to display live updates. Every commit fires `input` (final value) then `change`. - **Do not write the live value back from an `input` listener.** In 3.0, setting `value` while the user is moving the map (a drag or an arrow-key pan) stops the move without a commit, so `value` bound together with `onInput`/`oninput`/`on:input`/`@input` loses pans. Vue's `v-model` is not affected: Vue skips writes of an unchanged value. - **Spell configuration as HTML attributes** (`lat-name`, `api-key`, `reverse-geocode`). That is SSR-correct; the hyphen-less spellings (`latname`) are accepted too. - **Never bind `lat`/`lng`** in a template: they only set the initial value. - Objects (`messages`, `mapOptions`, `engine`, `geocoder`, `geolocation`) are properties, set from JavaScript. - `import 'location-picker'` defines the element and is safe on the server (it does nothing there). ## Value binding | Framework | Value binding | Events | Notes | | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Plain `
` | FormData via ElementInternals | `change`, `input` | reset and bfcache restore handled | | React 19 | `value={str}` (property) | `onChange`/`onInput` (synthetic); lowercase `onaddresschange`, `onready`, `onerror` (native; client render only) | `import type {} from 'location-picker/jsx/react'`; kebab-case config; after `hydrateRoot`, objects and native listeners go in a ref | | Vue 3.5 | `v-model` (`input` + `el.value`); `v-model.lazy` (`change`) | `@change`, `@addresschange` | `compilerOptions.isCustomElement` | | Angular 17+ | `formControlName`/`ngModel` + **`ngDefaultControl`** (DefaultValueAccessor: `value` property, `input`, `blur`) | `(change)`, `(addresschange)` | `CUSTOM_ELEMENTS_SCHEMA`; no custom ControlValueAccessor needed | | Svelte 5 | `value={v}` | `onchange`, `onaddresschange` | `jsx/svelte` types | | Solid | `prop:value` | `on:change` | `jsx/solid` types | | jQuery / Livewire / Alpine / htmx | bound inputs (native events on the input itself) or `new FormData(form)` | `change` on the picker for delegated or form-level listeners (htmx `hx-trigger="change from:location-picker"`) | `.serialize()` ignores custom elements | **Event names per framework.** The class callbacks `onChange`/`onInput`/`onAddressChange`/`onReady`/`onError` are options of the JavaScript class only. | Framework | `change` | `input` | `addresschange` | `ready` | `error` | |---|---|---|---|---|---| | Plain JS | `addEventListener('change')` | `'input'` | `'addresschange'` | `'ready'` | `'error'` | | React 19 | `onChange` | `onInput` | `onaddresschange` | `onready` | `onerror` (NOT `onError`: React's synthetic `error` never fires for this element; a type error in `jsx/react`) | | Vue 3 | `@change` | `@input` | `@addresschange` | `@ready` | `@error` | | Angular | `(change)` | `(input)` | `(addresschange)` | `(ready)` | `(error)` | | Svelte 5 | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` | | Solid | `on:change` | `on:input` | `on:addresschange` | `on:ready` | `on:error` | | Preact | `onchange` | `oninput` | `onaddresschange` | `onready` | `onerror` | React's lowercase native handlers are attached on client render only. After `hydrateRoot`, use a ref (React hydration recipe below). Field mode's `toggle` event (the map sheet opened or closed): `ontoggle` in React, Preact and Svelte, `@toggle` in Vue, `(toggle)` in Angular, `on:toggle` in Solid. ## React 19 and Next.js Import `location-picker` in a client component, and the JSX typings once. React 19 sets `value`, `search`, `locate` and other props as properties when they exist on the element, and fires `onChange`/`onInput` for the element's events. React 18 sets attributes instead, so `value` binding needs React 19. ```tsx 'use client'; import 'location-picker'; // defines ; SSR-safe no-op on the server import type {} from 'location-picker/jsx/react'; // JSX typings import { useState } from 'react'; export function StoreLocationField({ initial = '' }: { initial?: string }) { const [value, setValue] = useState(initial); // "lat,lng" or "" // Attributes, value and onChange/onInput survive SSR hydration; objects (messages…) and onaddresschange go in a ref (see the React hydration recipe). return ( <> setValue(e.currentTarget.value)} style={{ height: 360 }} />

{value || 'Pan the map to choose'}

); } // : formData.get('lat'), formData.get('lng'), formData.get('location') ``` **React hydration recipe.** With `hydrateRoot` (Next.js App Router, Remix), React applies neither object props (`messages`, `mapOptions`, `engine`, `geocoder`, `geolocation`, an adapter-object `provider`) nor the lowercase native handlers to custom elements. Set them in a ref callback; refs are attached on hydration. Attributes, `value` and `onChange`/`onInput` keep working. ```tsx 'use client'; import 'location-picker'; import type {} from 'location-picker/jsx/react'; import type { AddressDetail } from 'location-picker'; import { useState } from 'react'; export function AddressField() { const [address, setAddress] = useState(null); return ( <> { if (!el) return; el.messages = { locate: 'Use my position' }; const onAddress = (e: CustomEvent) => setAddress(e.detail.address); el.addEventListener('addresschange', onAddress); return () => el.removeEventListener('addresschange', onAddress); }} />

{address ?? 'No address yet'}

); } ``` - Use `onerror` (lowercase) for the picker's errors. `onError`, `onAddressChange` and `onReady` are type errors in `location-picker/jsx/react`: those names never fire for this element. - In a Server Action, read `formData.get('location')` (or `lat`/`lng` with `lat-name`/`lng-name`). - `` works in React 19: the `form` property writes the attribute. ## Vue 3 and Nuxt Tell the compiler that `location-picker` is a custom element, then use `v-model` (live, `input`) or `v-model.lazy` (`change`). ```ts // vite.config.ts import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'location-picker' } } })], }); ``` ```vue ``` In Nuxt, set the same compiler option in `nuxt.config.ts`. The import is safe during server rendering. ```ts // nuxt.config.ts export default defineNuxtConfig({ vue: { compilerOptions: { isCustomElement: (tag) => tag === 'location-picker' } }, }); ``` Vue's server-side compiler refuses `v-model` on custom elements (`v-model can only be used on ,