Framework bindings
On this page 13
ts-maps ships thin bindings for React, Vue, Svelte, Solid, stx, Nuxt and React Native. They are wrappers, not forks: all behaviour lives in the core library, and each binding exposes the same component names and prop shapes so a screen sketched in one framework reads the same in another.
| React | Vue | Svelte | Solid | stx | Nuxt | React Native | |
|---|---|---|---|---|---|---|---|
Map | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | MapView |
TileLayer Source Layer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
Marker Popup | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | markers prop |
| Controls | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | controls prop |
TurnByTurn | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | turnByTurn prop |
OfflineMaps | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | offlineMaps prop |
Search | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | search prop |
MapType | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | mapType prop |
IndoorMap | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | indoor prop |
LookAround | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | lookAround prop |
Landmark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | landmarks prop |
Trees | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | trees prop |
| Map access | useMap | useMap | useMap | useMap | findMap | auto-imported | onReady |
| Event subscription | useMapEvent | useMapEvent | useMapEvent | useMapEvent | onMapEvent | auto-imported | ✅ |
Two bindings render the map somewhere a child component cannot reach it, and say so with a different shape rather than pretending otherwise: React Native puts it in a WebView, and stx emits a component's script once per definition rather than per use. Both are covered below.
Controls
Every control is a component, placed inside <Map> like any other child:
import { GeocoderControl, Map, NavigationControl, ScaleControl } from '@ts-maps/react'
<Map center={[34.02, -118.47]} zoom={14}>
<NavigationControl position="topright" showCompass />
<GeocoderControl placeholder="Search for a place" />
<ScaleControl position="bottomleft" />
</Map>
import { GeocoderControl, Map, NavigationControl } from '@ts-maps/vue'
</script>
<Map :center="[34.02, -118.47]" :zoom="14">
<NavigationControl position="topright" :options="{ showCompass: true }" />
<GeocoderControl :options="{ placeholder: 'Search for a place' }" />
</Map>
</template>
<script lang="ts">
import { GeocoderControl, Map, NavigationControl } from '@ts-maps/svelte'
</script>
<Map center={[34.02, -118.47]} zoom={14}>
<NavigationControl position="topright" options={{ showCompass: true }} />
<GeocoderControl options={{ placeholder: 'Search for a place' }} />
</Map>
import { GeocoderControl, Map, NavigationControl } from '@ts-maps/solid'
<Map center={[34.02, -118.47]} zoom={14}>
<NavigationControl position="topright" showCompass />
<GeocoderControl placeholder="Search for a place" />
</Map>
<Map:center="34.02-118.47:zoom="14
<NavigationControlposition="
<GeocoderControl:options="placeholder: ''
</Map
<TsMapsMap :center="[34.02, -118.47]" :zoom="14">
<TsMapsNavigationControl position="topright" />
<TsMapsGeocoderControl :options="{ placeholder: 'Search' }" />
</TsMapsMap>
</template>
The available components are ZoomControl, NavigationControl,
GeocoderControl, FullscreenControl, LocateControl, ScaleControl and
AttributionControl — see Controls for what each
one does and the options it takes.
Every control takes position ('topleft' | 'topright' | 'bottomleft' | 'bottomright') and an options object for anything else. React and Solid also
accept the common options as plain props; Vue and Svelte take them through
options, matching how those frameworks handle pass-through props elsewhere.
Adding a control mounts it; unmounting the component removes it. Changing
position rebuilds it, because that is what moving a control means. In React,
passing a fresh options object literal on every render does not rebuild
the control — only position does.
LayersControl
LayersControl is deliberately not a component in any binding. It takes
dictionaries of live layer instances rather than plain data, which does not
translate to props. Reach for the map directly:
const map = useMap()
useEffect(() => {
const layers = control.layers({ Streets: streetsLayer }, { Traffic: trafficLayer })
layers.addTo(map)
return () => { layers.remove() }
}, [map])
Turn-by-turn navigation
TurnByTurn puts Apple Maps–style navigation on the map (see
services). It is declarative
in every binding: from and to preview the routes between two places, and
active starts guidance — set it false, or tap End, to stop. Places are
[lat, lng], the order center takes, or { lat, lng }.
// React and Solid
<Map center={[37.79, -122.39]} zoom={13}>
<TurnByTurn
from={[37.7955, -122.3937]}
to={[37.8029, -122.4484]}
active={driving}
destinationName="Palace of Fine Arts"
onProgress={e => setEta(e.progress.arrival)}
onArrive={() => setDriving(false)}
/>
</Map>
<!-- Vue, and Nuxt as <TsMapsTurnByTurn> -->
<TsTurnByTurn :from="start" :to="end" :active="driving" @arrive="driving = false" />
<TurnByTurn from={start} to={end} active={driving} onArrive={() => (driving = false)} />
The events are the same everywhere — preview, routeselect, start,
progress, instruction, reroute, arrive, end, error — spelled as each
framework spells an event: onArrive props in React, Solid and Svelte, @arrive
in Vue, and a bubbling turnbyturn:arrive DOM event in stx. A ready event
(onReady, @ready, turnbyturn:ready) hands over the underlying
TurnByTurn, for selectRoute, recenter and feeding positions with
update. The other options — profile, units, voice, simulate,
alternatives, destinationName, directions — are followed as they change
too. Another profile or directions fetches a showing preview again; during
guidance it applies from the next reroute rather than pulling the route from
under the driver. units and destinationName redraw the cards in place.
On React Native it is a prop of MapView, carried over the bridge like
markers and followed as it changes, options included, with every event
arriving at one onTurnByTurn({ type, data }) as plain data. Only plain data
crosses the bridge, so directions is the WebView's default:
<MapView
runtime={runtime}
turnByTurn={{ from: start, to: end, active: driving, destinationName: 'Home' }}
onTurnByTurn={e => e.type === 'arrive' && setDriving(false)}
/>
Offline maps
OfflineMaps adds Apple Maps–style offline maps (see
offline maps): a button opening the list of downloaded
maps, an area picker with an estimated size, and a pill when the connection
drops. Two props are followed as they change — open shows the panel, and
onlyOffline keeps map data off the network — and both report back when the
panel's own ✕ or switch changes them.
// React and Solid
<Map center={[37.78, -122.42]} zoom={13}>
<OfflineMaps
open={showOffline}
onOpenChange={e => setShowOffline(e.open)}
onComplete={e => toast(`${e.region.name} is ready offline`)}
/>
</Map>
<!-- Vue, and Nuxt as <TsMapsOfflineMaps> -->
<TsOfflineMaps v-model:open="showOffline" v-model:onlyOffline="offlineOnly" @complete="done" />
<OfflineMaps bind:open={showOffline} bind:onlyOffline onComplete={done} />
The events are the same everywhere — change ({ regions }), progress,
complete and error ({ region }), delete ({ id }), modechange
({ onlyOffline }) and openchange ({ open }) — as onComplete props in
React, Solid and Svelte, @complete in Vue, and a bubbling
offlinemaps:complete DOM event in stx. ready hands over the control, whose
maps is the manager for downloading, listing and deleting from code. The other
options — position, maps, geocoder, resources, showStatus, title —
are followed as they change. A new maps moves the list and every event onto
that manager; a new position moves the button.
On React Native it is a prop of MapView, with every event arriving at one
onOfflineMaps({ type, data }). The manager is reached through api.call, whose
method names can now reach one level in:
<MapView
runtime={runtime}
offlineMaps={{ open: showOffline, onlyOffline }}
onOfflineMaps={e => e.type === 'complete' && refresh()}
onReady={api => api.call('offline.list').then(setRegions)}
/>
Downloads made in the WebView are kept in its IndexedDB, unless offlineStore
keeps them in the app's own files, out of the OS's reach when space runs low:
import * as FileSystem from 'expo-file-system/legacy'
import { expoFileSystemStore } from '@ts-maps/react-native'
<MapView runtime={runtime} offlineMaps={{}} offlineStore={expoFileSystemStore(FileSystem)} />
reactNativeFsStore(RNFS) does the same with react-native-fs.
Search
Search adds Apple Maps–style search (see the search control):
- "Search Maps", with Find Nearby and Recents;
- suggestions from the map itself as you type;
- a pin for every result;
- a place card with Directions and Save, Favorites shown as stars on the map.
query is followed as it changes: set it and the map searches, and a category's
name runs the category. Set it to '' to clear. turnByTurn is followed too,
so Directions can use a TurnByTurn whose ready arrives after mount.
// React and Solid
<Map center={[37.79, -122.41]} zoom={15}>
<TurnByTurn onReady={setNav} />
<Search turnByTurn={nav} onSelect={e => setPlace(e.place)} />
</Map>
<!-- Vue, and Nuxt as <TsMapsSearch> -->
<TsSearch :query="query" :turn-by-turn="nav" @select="({ place }) => (chosen = place)" />
<Search {query} turnByTurn={nav} onSelect={e => (chosen = e.place)} />
The events are the same everywhere. results carries
{ query, category, places }, select and directions carry { place },
details carries { place, details } once a chosen place's hours, phone and
website arrive, save and unsave carry { place } when Save on its card
adds it to Favorites or takes it out, and clear carries nothing. They arrive as onSelect props in React, Solid and
Svelte, @select in Vue, and a bubbling search:select DOM event in stx.
ready hands over the control, for search, searchCategory, select and
cancel. The other options — position, placeholder, provider, offline,
categories, recents, units, location, origin, language, details,
shareUrl, saved (where Save keeps Favorites — default the page's
savedPlaces(), null for none), showSaved — are followed as they change: a new provider is asked from the next query on, and
new categories redraw Find Nearby in place. In stx, a <Search> and a <TurnByTurn> in
the same map are linked automatically.
On React Native it is a prop of MapView, with every event arriving at one
onSearch({ type, data }). Directions previews the route on the map's
turnByTurn when there is one, and the directions event reaches the app
either way. A store cannot cross the bridge, so saved is not there: Save
keeps Favorites in the WebView's own storage, showSaved is followed, and
save and unsave reach onSearch too:
<MapView
runtime={runtime}
search={{ query }}
onSearch={e => e.type === 'directions' && setTrip({ to: e.data.place })}
/>
Map type
MapType adds Apple Maps' map type picker (see
the map type control): a button opening a
card of Explore, Driving, Transit and Satellite. Choosing one sets the map's style and
keeps the layers the page added to it. types says what to offer —
mapTypes({ tiles, imagery }) builds Apple's three from one basemap source and
one imagery source. Two props are followed as they change — value shows a
type, and open shows the card — and both report back when the card changes
them.
// React and Solid
const types = useMemo(() => mapTypes({ tiles, imagery }), [tiles])
<Map center={[37.78, -122.42]} zoom={13}>
<MapType types={types} value={type} onChange={e => setType(e.value)} />
</Map>
<!-- Vue, and Nuxt as <TsMapsMapType> -->
<TsMapType :types="types" v-model:value="type" v-model:open="picking" />
<MapType {types} bind:value={type} bind:open={picking} />
The events are the same everywhere — change ({ value }) when a type is
chosen, openchange ({ open }), and trafficchange ({ traffic }) when the
card's Traffic switch is turned — as onChange props in React, Solid and
Svelte, @change in Vue, and a bubbling maptype:change DOM event in stx.
ready hands over the control, for select. types and position are
followed as they change too.
Given a traffic layer, the card has a Traffic switch (see
traffic), and showTraffic turns it on or
off — followed as it changes, and bindable as v-model:showTraffic in Vue and
bind:showTraffic in Svelte:
const traffic = useMemo(() => trafficLayer({ source: trafficSources.tomtom(key), incidents: new TomTomIncidents({ key }) }), [key])
<MapType types={types} traffic={traffic} showTraffic={on} onTrafficChange={e => setOn(e.traffic)} />
A style cannot be written in markup, so stx's <MapType> takes the plain
options of mapTypes() — tiles, imagery, imageryAttribution,
attribution, maxzoom, theme, labels — and builds the types in the
browser. A traffic layer cannot be written in markup either, so it takes
trafficProvider (mapbox or tomtom), trafficKey, and with TomTom
incidents, alongside showTraffic. React Native does the same inside the
WebView, with every event arriving at one onMapType({ type, data }):
<MapView
runtime={runtime}
mapType={{ tiles, value: type, trafficProvider: 'tomtom', trafficKey: key, showTraffic: on }}
onMapType={e => e.type === 'change' && setType(e.data.value)}
/>
Indoor maps
IndoorMap draws a venue's floor plan, after Apple Maps: zoomed in on an
airport or a mall, its IMDF
archive is drawn over the map one level at a time, with a level picker beside
it. venue is the archive — a .zip URL, a folder URL, its bytes, its files,
or a venue already loaded with loadIMDF — and is read when the control is
made, with minZoom (default 16) and language; a new one makes the control
again, so keep its identity stable across renders. level (an ordinal, 0 the
ground floor) and position are followed as they change. Given a search —
the control from <Search>'s ready — the venue's shops and gates are found
there, and choosing one goes to its level.
// React and Solid
<Map center={[37.6155, -122.3866]} zoom={17}>
<Search onReady={setSearch} />
<IndoorMap venue="/imdf/sfo.zip" search={search} level={level} onLevelChange={e => setLevel(e.level)} />
</Map>
<!-- Vue, and Nuxt as <TsMapsIndoorMap> -->
<TsIndoorMap venue="/imdf/sfo.zip" :search="search" v-model:level="level" />
<IndoorMap venue="/imdf/sfo.zip" {search} bind:level />
The events are the same everywhere — load ({ venue }), levelchange
({ level, name }) when the picker or a search changes the level, and
visibilitychange ({ visible }) as the venue comes into view close enough
to see inside — as onLevelChange props in React, Solid and Svelte,
@levelchange in Vue, and a bubbling indoor:levelchange DOM event in stx.
ready hands over the control, for setLevel, search and levels.
stx's <IndoorMap> takes the archive's URL as venue, and is linked to a
<Search> in the same map without being told. React Native loads it inside
the WebView, linked to its search, with every event arriving at one
onIndoor({ type, data }) — the venue reduced to { id, name, levels }:
<MapView
runtime={runtime}
search={{}}
indoor={{ venue: 'https://example.org/imdf/sfo.zip', level }}
onIndoor={e => e.type === 'levelchange' && setLevel(e.data.level as number)}
/>
Look Around
LookAround adds Apple Maps' Look Around: a binoculars button that shows the
streets with pictures in blue, and a full-bleed viewer to turn in and walk
through them, with a small map in the corner. provider says where pictures
come from — new PanoramaxImagery() (the default, open, no key) or
new MapillaryImagery({ accessToken }). choosing shows the streets and
waits for a tap, at opens the viewer at the picture nearest a place (null
closes it), and heading turns it — each followed only when it changes, so
the viewer closed by its own Done stays closed. provider and position are
followed as they change; miniMap, locale and title are read when the
control is made, and a new one makes it again. Given to Search as
lookAround, a place's card offers the pictures near it.
// React and Solid
<Map center={[48.8606, 2.3376]} zoom={16}>
<LookAround onReady={setLook} at={at} onClose={() => setAt(null)} />
<Search lookAround={look} />
</Map>
<!-- Vue, and Nuxt as <TsMapsLookAround> -->
<TsLookAround v-model:choosing="choosing" :at="at" @ready="look = $event" />
<TsSearch :look-around="look" />
<LookAround bind:choosing {at} onReady={(c) => (look = c)} />
<Search lookAround={look} />
The events are the same everywhere — open and imagechange ({ image }),
close, viewchange ({ heading, pitch, fov }), choosingchange
({ choosing }) and notfound ({ at }) when there is no picture near — as
onOpen props in React, Solid and Svelte, @open in Vue, and a bubbling
lookaround:open DOM event in stx. ready hands over the control, for
open, close, setView and step.
A provider cannot be written in markup, so stx's <LookAround> names one —
provider panoramax (endpoint for another instance) or mapillary with
an accessToken — and is linked to a <Search> in the same map without being
told. React Native does the same inside the WebView, linked to its search
by lookAround: true, with every event arriving at one
onLookAround({ type, data }) — a picture reduced to
{ id, provider, lat, lng, heading, capturedAt }:
<MapView
runtime={runtime}
search={{ lookAround: true }}
lookAround={{ at, provider: 'mapillary', accessToken: token }}
onLookAround={e => e.type === 'close' && setAt(null)}
/>
Landmarks and trees
Landmark stands a glTF model where a building is, after Apple Maps'
landmarks: drawn with the buildings, hiding the labels behind it, and by
default leaving out the extruded building it stands on (replace). model
is a .glb or .gltf URL, a .glb's bytes, or a parsed glTF, and is read
when the landmark is made, with replace and minZoom (default 15); a new
one makes it again, so keep its identity stable across renders. position,
rotation (degrees clockwise), scale, altitude and opacity are
followed as they change. Trees plants low-poly trees in the basemap's woods
and parks as the map tilts, one set per map, following spacing,
maxPerTile, minZoom, minPitch, colors, height and match. Both need
a vector basemap and WebGL; see 3D.
// React and Solid
<Map center={[37.7952, -122.4028]} zoom={17} pitch={60}>
<Landmark model="/models/transamerica.glb" position={[37.7952, -122.4028]} rotation={45} />
<Trees spacing={12} />
</Map>
<!-- Vue, and Nuxt as <TsMapsLandmark> and <TsMapsTrees> -->
<TsLandmark model="/models/transamerica.glb" :position="[37.7952, -122.4028]" :rotation="45" />
<TsTrees :spacing="12" />
<Landmark model="/models/transamerica.glb" position={[37.7952, -122.4028]} rotation={45} />
<Trees spacing={12} />
Neither has events of its own. onReady (ready in Vue) hands over the
landmark, for ready() and the setters, or the trees, for setOptions.
stx's <Landmark> takes the model's URL, or a glTF's JSON, as model, and
hands the instances over as bubbling landmark:ready and trees:ready DOM
events; match cannot be written in markup. React Native loads each model
inside the WebView from its URL, matching landmarks across updates by id:
<MapView
runtime={runtime}
pitch={60}
landmarks={[{ id: 'transamerica', model: 'https://example.org/models/transamerica.glb', position: [37.7952, -122.4028], rotation: 45 }]}
trees
/>
Localization
locale on the map is the language its built-in controls speak: search,
Offline Maps, the map type and level pickers, the zoom, compass, locate and
fullscreen buttons, and turn-by-turn. Default the browser's. A control's own
locale wins over the map's; see Localization.
// React, Solid and Svelte; Vue as <TsMap locale="de">, Nuxt as <TsMapsMap locale="de">
<Map center={[52.52, 13.405]} zoom={13} locale="de">
<Search /> {/* "Karten durchsuchen" */}
<ZoomControl /> {/* "Vergrößern" */}
<OfflineMaps locale="en" />
</Map>
What follows a change:
Search,OfflineMaps,MapTypeandTurnByTurnfollow their ownlocaleas it changes, relabelling in place.IndoorMap,LookAroundand the control components (ZoomControl,NavigationControl,LocateControl,FullscreenControl) are made again for a newlocale; in stx they are read when built.- The map's
localeis read when it is built in stx. In the other bindings a change setsmap.options.locale, which a control without its own picks up the next time it draws its words. Give the control its ownlocaleto relabel it at once.
React Native takes locale on MapView. It is baked into the document, and
a change after load goes over the bridge: search, offline maps, the map type
picker and turn-by-turn relabel in place, and the indoor map and Look Around
are made again.
controls keep the language they were built in.
<MapView runtime={runtime} locale="de" search={{}} offlineMaps={{}} />
Subscribing to events
useMapEvent binds a handler for the lifetime of the calling component, in
every binding:
useMapEvent('moveend', () => console.log(map.getCenter()))
One difference worth knowing: in React and Vue, useMap() throws when called
outside a <Map> (with useMapOptional() for the tolerant version). In Svelte
and Solid, useMap() returns null instead. That follows each ecosystem's own
convention for missing context.
stx
Everything is a component, and a page needs no client script of its own:
<Map:center="34.02-118.47:zoom="14theme="
basemap="dark" tiles="{{ tileUrl }}">
<NavigationControlposition="
<Marker:lat="34.02:lng="-118.47
<PopupOcean Park</Popup
</Marker
</Map
Register @ts-maps/stx/stx-plugin in stx.config.ts and link the stylesheet
from your layout. Two rules are worth knowing up front:
- Write
className, neverclass. stx seeds every prop into the client scope as a variable, andclassis a reserved word — the generated script then fails to parse. - Children are read once, when the map mounts. stx emits a component's script
once per definition rather than per use, so a marker cannot build itself;
instead each child renders inert markup and
<Map>walks its subtree and builds what it finds. Markers added to the DOM later are not picked up — add those through the map.<OfflineMaps>,<Search>,<TurnByTurn>,<MapType>,<IndoorMap>,<LookAround>,<Landmark>and<Trees>keep following their props after that: a change to the markup is handed to the control. Live objects such asmapsorprovidercannot be written in markup; pass them to the control'ssyncfrom itsreadyevent.
Reach the map with findMap(el), and subscribe with onMapEvent(el, type, fn).
Marker taps arrive as a bubbling marker:click DOM event, since a callback
cannot cross a prop boundary that carries only data.
See @ts-maps/stx
for the full component list, and playground/incident-map for the same screen
built both imperatively and with these components.
React Native
The map runs inside a react-native-webview, so MapView takes no children.
Controls and markers are declared as data and built on the other side of the
bridge:
import { MapView } from '@ts-maps/react-native'
<MapView
runtime={{ source: 'cdn', url: 'https://unpkg.com/ts-maps' }}
center={[34.02, -118.47]}
zoom={14}
controls={[
{ type: 'navigation', position: 'topright' },
{ type: 'geocoder', options: { placeholder: 'Search' } },
]}
markers={incidents.map(i => ({
id: i.id,
coordinate: i.coords,
html: `<span class="pin">${i.emoji}</span>`,
iconSize: [46, 46],
iconAnchor: [23, 23],
popupHtml: `<b>${i.title}</b>`,
}))}
onMarkerPress={e => select(e.id)}
onReady={api => api.call('setTheme', 'dark')}
/>
markers is live: changing the array updates the map over the bridge, which is
what a feed of moving or filtered points needs. controls is read when the map
is built, so changing it after mount needs a remount — the same rule as
runtime.
html and popupHtml are inserted as markup inside the WebView. Treat them
the way you would dangerouslySetInnerHTML, and do not build them from
untrusted input.
For anything else, onReady hands you an api whose call(method, ...args)
invokes a method on the map inside the WebView.