Use Mapbox GL JS in a Vue app
Vue is a popular JavaScript web framework that focuses on declarative rendering and component composition. Since Vue manipulates the DOM, like Mapbox GL JS, it is worth spending some time to understand how to combine the two.
In this tutorial, you will learn how to create a Vue web app that uses Mapbox GL JS to render a map, display the coordinates of the map center point and its zoom level, then update the display when a user interacts with the map. This app also shows map bearing and pitch if the map has been rotated or pitched. You will be able to use the principles discussed in this tutorial to create more complex apps that use both Vue and Mapbox GL JS. This tutorial shows code for the Vue Options API and Component Events.
Getting started
To get started, you will need:
- A Mapbox access token. Your Mapbox access tokens are on your Account page.
- A text editor. Use the text editor of your choice for writing HTML, CSS, and JavaScript.
- Node.js and npm. To run the commands necessary to run your Vue app locally, install Node.js and npm.
Set up the Vue app structure
To get started, create a Vue project using npm. This will create a "base layer" to build any Vue app upon. Choose a root directory to place your project within; typically, this is a directory that contains other project folders or repositories. Use cd
in your terminal to navigate to this root directory and run the following command:
npm create vue@3.3.4
You will be prompted to install vue
. After successful installation, you will be prompted to name the project and asked about some add-ons. Name the project whatever you like; you may safely reject the add-ons.
Then, in your command line, cd
into the new Vue project directory and run the following command:
npm install mapbox-gl
This will install the dependencies that your app requires, including Mapbox GL JS. This will create the file package-lock.json
and the folder node_modules
.
Finally, delete unnecessary files that will clutter your development process. In the src
folder, delete all preexisting components. This means deleting all files in src
with the .vue
file extension except for App.vue
. Delete the contents of the components
folder, and delete the entire icons
folder.
Setup is complete and you should have the following folder structure:
use-mapbox-gl-js-with-vue
node_modules
public
src
assets
components
App.vue
main.js
.gitignore
index.html
package-lock.json
package.json
README.md
vite.config.js
Save your changes.
Most of the programming in this tutorial will occur in two files: src/App.vue
, and a component called Map.vue
which does not yet exist. Open App.vue
, and create Map.vue
inside of src/components
.
Set up global CSS
The first thing to edit isn't in either of these files, but rather some CSS in src/assets/main.css
. Open this file.
In the #app
selector, edit the first two lines. These lines should be max-width
and margin
styles. Replace these lines with the following:
display: flex;
height: 100vh;
This will make the map full-screen.
If there is a line defining style rules for padding
under the #app
selector, delete this rule. Then, below existing CSS rules, add the following rules to the #app
selector:
padding: 0;
flex: 1;
If there are any style rules for #app
inside of the @media (min-width: 1024px)
selector, delete them and replace them completely with the above four rules. Your final main.css
file should look like the following:
@import url('./base.css');
#app {
display: flex;
height: 100vh;
padding: 0;
flex: 1;
}
a,
.green {
text-decoration: none;
color: hsl(160deg 100% 37% / 100%);
transition: 0.4s;
}
@media (hover: hover) {
a:hover {
background-color: hsl(160deg 100% 37% / 20%);
}
}
@media (width >= 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: flex;
height: 100vh;
padding: 0;
flex: 1;
}
}
Set up the Vue app
Set up the app itself next. Take a look inside of App.vue
. The entirety of the file is HTML elements. Specifically, it begins with <template>
tags to use components in the page, <script>
tags to import the components, and <style>
tags to apply CSS.
Delete any HTML in App.vue
and replace it with the following:
<template>
<div id="layout">
<Map />
</div>
</template>
<script>
import Map from './components/Map.vue';
import '@site/src/node_modules/mapbox-gl/dist/mapbox-gl.css';
export default {
components: {
Map
}
};
</script>
<style>
#layout {
flex: 1;
display: flex;
position: relative;
}
</style>
In the <script>
tags, the import
statement consumes the components from another file and the export
statement makes this component available to the app. It also imports CSS from Mapbox GL JS. The <template>
tags correspond to the elements shown on the page. The <style>
tags apply styles to elements in the <template>
. Essentially all that this app will do is display the component Map
at the viewport's maximum dimensions.
At this point, Map.vue
is not rendering anything. Our next steps will be to add an element to serve as the map container and to instantiate a new Mapbox GL JS map.
Set up the Map component
Open the Map.vue
file. Typical Vue components include two sections: <template>
tags to show / use the component on-screen, and <script>
tags that instruct the component how to behave using JavaScript. <style>
tags are optional, but are often included as a third section to add CSS rules to the component.
Set up your empty component file like this:
<template> </template>
<script></script>
<style></style>
Edit the template
We'll add to the <template>
section first. You need an HTML element capable as acting as your map's map container. To reference this element in the <script>
section, we must also use a ref
attribute. Add the following between your <template>
tags:
<div ref="mapContainer" class="map-container"></div>
Add CSS
Now, we'll style the map container so that it fills up the entire viewport and responds to resizing of the browser window. Between your opening and closing <style>
tags, add the following:
.map-container {
flex: 1;
}
Add Mapbox GL JS
We can start creating a map by importing mapboxgl
and attaching Mapbox your access token; between your <script>
tags, add the following:
import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken =YOUR_MAPBOX_ACCESS_TOKEN;
The rest of the JavaScript between your <script>
tags will define what is exported by this file when the component is imported by other files. Under the assignment of your access token, add the following:
export default {};
Finally, we can instantiate the Mapbox map. Inside of the default export object, add the following:
mounted() {
const map = new mapboxgl.Map({
container: this.$refs.mapContainer,
style: "mapbox://styles/mapbox/streets-v12", // Replace with your preferred map style
center: [-71.224518, 42.213995],
zoom: 9,
});
this.map = map;
},
This mounted()
event is part of the lifecycle of Vue components and occurs after the component binds to the DOM. After the Map
component is mounted, the Mapbox map object is instantiated. Its container points to the <div>
we created in the component's <template>
section through $refs
.
We also give the component a map
property in the last line. This saves a reference to the Mapbox map object in this.map
. If, for example, you wanted to remove the map in the case of an unmounted()
event, you would call remove()
on this.map
.
Let's do it. Although our app does not intend to unmount with the unmounted()
event at any point, it is generally good form to include instructions to handle this event in components that use a mounted()
event to avoid a memory leak. Under the mounted()
event, add the following:
unmounted() {
this.map.remove();
this.map = null;
}
Save all your work. Run npm run dev
in a terminal in your app's root directory, and you should see an interactive map of Boston.
Add a user interface
This app will need to be able to store and render the longitude, latitude, and zoom for the map in a UI element. These values will all change as your user interacts with the map, and the UI will update with it.
Add sidebar HTML
It's time to add to the <div>
tags that will become the informational sidebar UI element. In App.vue
, add the following HTML between your opening <div>
tag with id
"layout" and the <Map/>
component:
<div id="sidebar">Longitude: -71.224518 | Latitude: 42.213995 | Zoom: 9</div>
This information will not update until we create additional variables and functions in the component, but it is accurate to the starting position of the map. If you load the development server with only this HTML added, the information will likely appear in a white box to the side of the map. This sidebar should instead lay on top of the map. We need to style the sidebar with CSS.
Add sidebar CSS
Before we work on updating the display with current data, we should style the display. Between your <style>
tags, under the rules for the layout
id, add the following:
#sidebar {
background-color: rgb(35 55 75 / 90%);
color: #fff;
padding: 6px 12px;
font-family: monospace;
z-index: 1;
position: absolute;
top: 0;
left: 0;
margin: 12px;
border-radius: 4px;
}
This will place the sidebar in the top-left corner of the map in a styled box.
Save your changes.
Update center and zoom information
To pass center and zoom information from the App
to the Map
component (to be able to reset the location of the map), the component must use props. All this information will be stored in one variable, modelValue
.
Open Map.vue
. To create such a prop, we can add it in a similar way to the component's data
. Inside of export default
but above the definition of data
, add the following:
props: ['modelValue'],
Make sure that the name of the prop is "modelValue
". Then, in the mounted()
event, add the following above the instantiation of the map:
const { lng, lat, zoom, bearing, pitch } = this.modelValue;
This setup may seem strange at first. Instead of assigning to modelValue
the values of lng
, lat
, etcetera, we use destructuring assignment syntax to assign these values to the values inside the modelValue
object. But our prop modelValue
doesn't contain anything yet.
Add data to the app
modelValue
needs an initial value so that we can assign lng
, lat
, and so on. That initial value will come from App.vue
; open this file.
In the app's export default
, add the following section under the components
section:
data: () => ({
location: {
lng: -71.224518,
lat: 42.213995,
bearing: 0,
pitch: 0,
zoom: 9
}
}),
This data will serve as the initial state of modelValue
. To connect the location
data to the modelValue
prop so that it can be delivered to lng
, lat
, etc in the map component, change your implementation of <Map/>
in the <template>
section of the app. Replace the current tag with the following tag:
<Map v-model="location" />
Component v-model can be used within Vue to specifically update the modelValue
prop. It is one of many Vue directives. Now, the modelValue
prop will be updated with the initial value provided in data
by the time the component attempts to assign variables like lng
and lat
.
You should replace the properties of the instantiated map object in Map.vue
Replace center: [-71.224518, 42.213995],
and zoom: 9,
with the following:
center: [lng, lat],
bearing,
pitch,
zoom,
Add event listeners for an emitted event
Now, let's constantly re-assign modelValue
to store the current properties of the map. Go back to the Map.vue
component file. We can use a Vue emitted event to update the value of modelValue
. In the mounted()
event, after the Map instantiation but before the assignment this.map = map
, add the following:
const updateLocation = () =>
this.$emit('update:modelValue', this.getLocation());
map.on('move', updateLocation);
map.on('zoom', updateLocation);
map.on('rotate', updateLocation);
map.on('pitch', updateLocation);
This is a good way to constantly pass the state of the map component back to the app so the UI can update. Event listeners are added to the map so that if it is moved in any way (panned, zoomed, rotated, or pitched), the updateLocation
function is called. This function updates modelValue
with the contents of this.getLocation()
.
Define a function to get location data
You may have noticed that the function this.getLocation()
does not exist yet. We can add our first function to this component by defining a new methods
section in export default
. Under the unmounted()
event, add the following section:
methods: {
getLocation() {
return {
...this.map.getCenter(),
bearing: this.map.getBearing(),
pitch: this.map.getPitch(),
zoom: this.map.getZoom(),
}
}
},
This function uses methods from Mapbox GL JS to return information about the map's position. Notice that the results of the call to getCenter()
are unpacked with spread syntax into the two key-value pairs of a LngLat
object, lng
and lat
. The returned object has the same keys as the object assigned to the value of this.modelValue
at the beginning of the mounted()
event.
Replace UI data with component data
If you launch your app at this point, you may notice that the data in the sidebar element doesn't update as you pan and zoom the map. This is because we never updated the "default" starting text we placed in the element. Go back to App.vue
and find the <div>
with an id of "sidebar". Delete its contents and replace them with the following:
Longitude: {{ location.lng }} | Latitude: {{ location.lat }} | Zoom:
{{ location.zoom }} |
Now, the app updates the data in the UI as the map is moved. Save all your work.
The data presented in the UI at this point is far too specific. Upon moving the map, the longitude, latitude, and zoom all show precision of more than 10 decimal points. We should add some functions to the app to clean these up. Alter the contents of the <div>
(shown above) to the following:
Longitude: {{ location.lng.toFixed(4) }} | Latitude: {{
location.lat.toFixed(4) }} | Zoom: {{ location.zoom.toFixed(2) }} |
The toFixed()
function rounds numbers to a specified number of decimal points and can be used to make the map's positional data values look nicer.
All that's needed to make this project complete is conditionally adding bearing and pitch to the UI if they are not set to 0 (as they are by default). Right under the line Zoom: {{ location.zoom }} |
, add the following to the sidebar <div>
:
<template v-if="location.bearing">
Bearing: {{ location.bearing.toFixed(2) }} |
</template>
<template v-if="location.pitch">
Pitch: {{ location.pitch.toFixed(2) }} |
</template>
This takes advantage of Vue's conditional rendering by adding a directive that evaluates the truthiness of an expression. In this case, the expression is the value of the map's bearing and pitch respectively, which evaluate to true
only if they are nonzero.
Optional: Add a reset button
Though the app does what we originally set out to do, it will be instructive for new Vue developers to add a "reset" button that resets both the data in the UI and the map to the initial state. This section of the tutorial makes use of Vue Watchers and shorthand for Vue directives.
Directly under the code you were editing (the Pitch <template>
), add the following HTML to the sidebar <div>
:
<button
@click="location = { lng: -71.224518, lat: 42.213995, zoom: 9, pitch: 0, bearing: 0 }"
>
Reset
</button>
This adds a button to the UI that, on click, resets the location
data to what it was when the map was loaded. Because the sidebar shows what is stored in location
, this updates the UI but map does not jump back to its initial position. Although the Map
component passes the prop value to mapbox-gl
during creation, mapbox-gl
is not aware of Vue's reactivity model, so we need to watch
for changes from Vue and trigger appropriate mapbox-gl
methods in response.
To fix this, we should watch
the modelValue
prop. watch()
watches a data source and invokes a function when the source changes. In this case, the watcher will keep track of when the modelValue
prop changes. When it does, it will test to see if modelValue
matches the current state of the map as determined by getLocation()
, which uses GL JS functions to directly get the map's position. If it does, nothing happens; if it doesn't, the map's position is set to match the values of modelValue
.
Add the following to the default export
of your Map.vue
component, under the unmounted()
event and before the methods
section:
watch: {
modelValue(next) {
const curr = this.getLocation()
const map = this.map
if (curr.lng != next.lng || curr.lat != next.lat)
map.setCenter({ lng: next.lng, lat: next.lat })
if (curr.pitch != next.pitch) map.setPitch(next.pitch)
if (curr.bearing != next.bearing) map.setBearing(next.bearing)
if (curr.zoom != next.zoom) map.setZoom(next.zoom)
}
},
Now, when the reset button is pressed, modelValue
updates to contain the data newly provided to location
, and the map realizes it should update to match modelValue
.
That's it! Now you have a fully-functioning Mapbox map with custom UI working in Vue.
Final product
You have created a Vue app that uses Mapbox GL JS to render a map, display the center coordinates and the zoom level of the map, and then update that display when a user interacts with the map.
The final App.vue
file will look like the following:
<script>
import Map from './components/Map.vue';
import '@site/src/node_modules/mapbox-gl/dist/mapbox-gl.css';
export default {
components: {
Map
},
data: () => ({
location: {
lng: -71.224518,
lat: 42.213995,
bearing: 0,
pitch: 0,
zoom: 9
}
})
};
</script>
<template>
<div id="layout">
<div id="sidebar">
Longitude: {{ location.lng.toFixed(4) }} | Latitude: {{
location.lat.toFixed(4) }} | Zoom: {{ location.zoom.toFixed(2) }} |
<template v-if="location.bearing">
Bearing: {{ location.bearing.toFixed(2) }} |
</template>
<template v-if="location.pitch">
Pitch: {{ location.pitch.toFixed(2) }} |
</template>
<button
@click="location = { lng: -71.224518, lat: 42.213995, zoom: 9, pitch: 0, bearing: 0 }"
>
Reset
</button>
</div>
<Map v-model="location" />
</div>
</template>
<style>
#layout {
flex: 1;
display: flex;
}
#sidebar {
background-color: rgb(35 55 75 / 90%);
color: #fff;
padding: 6px 12px;
font-family: monospace;
z-index: 1;
position: absolute;
top: 0;
left: 0;
margin: 12px;
border-radius: 4px;
}
</style>
The final Map.vue
file will look like the following:
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script>
import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken =YOUR_MAPBOX_ACCESS_TOKEN;
export default {
props: ['modelValue'],
data: () => ({ map: null }),
mounted() {
const { lng, lat, zoom, bearing, pitch } = this.modelValue;
const map = new mapboxgl.Map({
container: this.$refs.mapContainer,
style: 'mapbox://styles/mapbox/streets-v12',
center: [lng, lat],
bearing,
pitch,
zoom
});
const updateLocation = () =>
this.$emit('update:modelValue', this.getLocation());
map.on('move', updateLocation);
map.on('zoom', updateLocation);
map.on('rotate', updateLocation);
map.on('pitch', updateLocation);
this.map = map;
},
unmounted() {
this.map.remove();
this.map = null;
},
watch: {
modelValue(next) {
const curr = this.getLocation();
const map = this.map;
if (curr.lng != next.lng || curr.lat != next.lat)
map.setCenter({ lng: next.lng, lat: next.lat });
if (curr.pitch != next.pitch) map.setPitch(next.pitch);
if (curr.bearing != next.bearing) map.setBearing(next.bearing);
if (curr.zoom != next.zoom) map.setZoom(next.zoom);
}
},
methods: {
getLocation() {
return {
...this.map.getCenter(),
bearing: this.map.getBearing(),
pitch: this.map.getPitch(),
zoom: this.map.getZoom()
};
}
}
};
</script>
<style>
.map-container {
flex: 1;
}
</style>
Next steps
Now that you have created a Vue app that uses Mapbox GL JS, you can explore examples using other JavaScript frameworks, such as React or Svelte.