Guide

Vector map tiles in OpenLayers

OpenLayers draws vector tiles itself, and the ol-mapbox-style package turns a MapLibre style into OpenLayers layers. With script tags and with Vite, markers and GeoJSON on top, which of our styles OpenLayers draws, and MapLibre as an OpenLayers layer for the others. Tested with OpenLayers 10.11.0 and ol-mapbox-style 13.9.0.

With script tags, OpenLayers and ol-mapbox-style:
<link rel="stylesheet" href="https://unpkg.com/ol@10.11.0/ol.css">
<script src="https://unpkg.com/ol@10.11.0/dist/ol.js"></script>
<script src="https://unpkg.com/ol-mapbox-style@13.9.0/dist/olms.js"></script>

<style>#map { height: 480px; }</style>
<div id="map"></div>
<script>
const base = new ol.layer.Group();
olms.apply(base, "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en");

const map = new ol.Map({
  target: "map",
  layers: [base],
  view: new ol.View({ center: ol.proj.fromLonLat([8.54, 47.37]), zoom: 13 }),
  controls: ol.control.defaults.defaults({ attributionOptions: { collapsible: false } })
});
</script>
Go to:
Loading the map…
OpenLayers 10.11 with ol-mapbox-style and the code of this guide. Click a red dot.

With script tags

The full build of OpenLayers, dist/ol.js, defines the global ol, and dist/olms.js of ol-mapbox-style defines olms. olms.apply loads the style and fills the layer group with OpenLayers layers for its sources and layers. Put the base map in a layer group: layers you add to the map later stay above it.

OpenLayers counts zoom levels in tiles of 256 pixels, like Leaflet, so zoom 13 in OpenLayers shows the same area as zoom 12 in MapLibre. The view works in Web Mercator coordinates; ol.proj.fromLonLat converts longitude and latitude.

Keep the classes in the ol namespace in a classic script:

const { Map, View } = ol; // the map stays empty

A const at the top level of a classic script is visible to every script of the page. This one hides the Map built into the browser, which ol-mapbox-style needs, and the console shows “TypeError: seen.has is not a function”. Write ol.Map, or put your code in a function or a module.

Markers, popups and GeoJSON

Markers are features in a vector layer, a popup is an overlay, and GeoJSON loads into a vector source. Add them after the base map:

<style>
  .popup { background: #fff; padding: 4px 8px; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.3); }
</style>
<div id="popup" class="popup"></div>
<script>
// after the map code above
const red = new ol.style.Style({
  image: new ol.style.Circle({
    radius: 7,
    fill: new ol.style.Fill({ color: "#d0312d" }),
    stroke: new ol.style.Stroke({ color: "#fff", width: 2 })
  })
});
const places = new ol.layer.Vector({
  source: new ol.source.Vector({
    features: [
      new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([8.5402, 47.3782])), name: "Zürich HB" })
    ]
  }),
  style: red
});
const shops = new ol.layer.Vector({
  source: new ol.source.Vector({ url: "shops.geojson", format: new ol.format.GeoJSON() }),
  style: red
});
map.addLayer(places);
map.addLayer(shops);

const popup = new ol.Overlay({ element: document.getElementById("popup"), positioning: "bottom-center", offset: [0, -12] });
map.addOverlay(popup);
map.on("click", (e) => {
  const f = map.forEachFeatureAtPixel(e.pixel, (f) => f, { layerFilter: (l) => l === places || l === shops });
  popup.setPosition(f ? f.getGeometry().getCoordinates() : undefined);
  if (f) popup.getElement().textContent = f.get("name");
});
</script>
  • ol.format.GeoJSON reads longitude and latitude and converts them to the projection of the map.
  • The base map has features too. Without the layer filter, a click on a road finds one of them, and the code fails with “TypeError: f.getGeometry(...).getCoordinates is not a function”. The filter is called with each layer inside the group, never with the group itself, so name the layers you want.

With npm and Vite

npm install ol ol-mapbox-style @fontsource/noto-sans

This ran in a Vite 8.3 build:

import "ol/ol.css";
import "@fontsource/noto-sans/400.css";
import "@fontsource/noto-sans/500.css";
import "@fontsource/noto-sans/400-italic.css";
import Map from "ol/Map.js";
import View from "ol/View.js";
import LayerGroup from "ol/layer/Group.js";
import { fromLonLat } from "ol/proj.js";
import { defaults as defaultControls } from "ol/control/defaults.js";
import { apply } from "ol-mapbox-style";

const KEY = import.meta.env.VITE_OVERPASS_KEY;

const base = new LayerGroup();
apply(base, `https://api.overpass.ch/v1/${KEY}/maps/styles/light.json?lang=en`);

const map = new Map({
  target: "map",
  layers: [base],
  view: new View({ center: fromLonLat([8.54, 47.37]), zoom: 13 }),
  controls: defaultControls({ attributionOptions: { collapsible: false } }),
});

Vite reads VITE_OVERPASS_KEY from a .env file next to package.json. The key still ends up in the JavaScript of the page, so limit it to your website (below). In a module, Map is a name of that module only, and the problem with const { Map } does not occur.

Fonts

OpenLayers draws labels with the fonts of the browser. Our styles use Noto Sans in three cuts: regular, medium and italic. When the page does not have them, ol-mapbox-style loads them from cdn.jsdelivr.net, 18 to 27 requests on the first map in our tests.

To keep the browsers of your visitors away from that server, load the font before the map. With Vite, the three @fontsource/noto-sans imports above do it. With script tags, copy 400.css, 500.css, 400-italic.css and the files folder of the npm package @fontsource/noto-sans to your server, and give ol-mapbox-style their address:

olms.apply(base, "https://api.overpass.ch/v1/YOUR_KEY/maps/styles/light.json?lang=en", {
  webfonts: "/fonts/{font-family}/{fontweight}{-fontstyle}.css"
});

ol-mapbox-style fills in noto-sans, 400 or 500, and -italic, so the files need to be at /fonts/noto-sans/400.css and so on.

Which styles OpenLayers draws

StyleWith ol-mapbox-style
light, dark, white, grayscale, blackYes
outdoorYes, with trails by grade, huts, peaks, contour lines and hillshading
drivingWith thin roads: the outlines that make them wide are missing
marineThe sea in one blue, without the colours by depth; sea marks and depth contours as in MapLibre
reliefGrey hillshading only, without the colours by height

ol-mapbox-style 13.9 lacks three parts of the MapLibre style specification that our styles use:

  • color-relief layers, which colour the land by height and the sea by depth. The console shows “layers[2].type "color-relief" not supported”.
  • line-gap-width, which draws the outlines of roads.
  • text-variable-anchor, so some labels sit on their icon.

Labels in other languages work with ?lang=de, and &hillshade=1 adds hillshading, as in MapLibre.

All nine styles: MapLibre as a layer

For relief, marine and driving as they look in MapLibre, let MapLibre draw the base map inside OpenLayers. The package @geoblocks/ol-maplibre-layer puts a MapLibre map into an OpenLayers layer; your OpenLayers layers, overlays and controls stay as they are. It needs a bundler. This ran in a Vite 8.3 build with MapLibre 6.16.0:

npm install ol @geoblocks/ol-maplibre-layer maplibre-gl
import "ol/ol.css";
import "maplibre-gl/dist/maplibre-gl.css";
import Map from "ol/Map.js";
import View from "ol/View.js";
import { fromLonLat } from "ol/proj.js";
import { defaults as defaultControls } from "ol/control/defaults.js";
import { MapLibreLayer } from "@geoblocks/ol-maplibre-layer";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";

setWorkerUrl(workerUrl);

const KEY = import.meta.env.VITE_OVERPASS_KEY;

const base = new MapLibreLayer({
  mapLibreOptions: { style: `https://api.overpass.ch/v1/${KEY}/maps/styles/relief.json?lang=en` },
});

const map = new Map({
  target: "map",
  layers: [base],
  view: new View({ center: fromLonLat([7.62, 46.12]), zoom: 10.6 }),
  controls: defaultControls({ attributionOptions: { collapsible: false } }),
});
  • MapLibre 6 needs setWorkerUrl with every bundler; for webpack, see the MapLibre migration guide.
  • The MapLibre map is base.mapLibreMap, from the moment the layer is on a map. The README of version 2.0.0 calls it maplibreMap, which is undefined. The layer fires load when the style has loaded.
  • MapLibre draws the labels with the fonts from the API, so the fonts section above does not apply.

Download sizes

SetupJavaScript, gzip
Script tags: ol.js and olms.js290 KB + 40 KB
Vite: OpenLayers and ol-mapbox-style, the code above151 KB
Vite: OpenLayers, ol-maplibre-layer and MapLibre332 KB, and 143 KB for the MapLibre worker

Vite takes only the parts of OpenLayers you import; the full build has all of them.

Your key and what counts

  • Your key is visible in the page. Create a key of the type “Maps only” in your dashboard and enter your website as an allowed referer, and localhost:5173 for the Vite dev server.
  • Every vector tile counts as one map tile, as with MapLibre. Every account includes 100'000 map tiles a month.
  • collapsible: false keeps the OpenStreetMap credit visible in the corner of the map, as its licence requires. Without it, OpenLayers folds the credits into an “i” button.

Limits

  • OpenLayers cannot tilt the map, so terrain=1 and 3D buildings have no use here. It rotates: drag with Alt and Shift held, or turn with two fingers.
  • OpenLayers places labels in its own way and shows fewer street names than MapLibre.
  • For the same map in Leaflet, see Vector map tiles in Leaflet; how the libraries differ is in MapLibre vs Leaflet vs OpenLayers.