A .map is an interactive map built on
MapLibre, which the page
supplies. Markers, popups and controls are HTML. Routes, arcs and
clusters are data added from script.
Loop store
Open until 9 p.m.
Lincoln Park store
Open until 8 p.m.
<div class="map" role="region" aria-label="Chicago stores">
<button type="button" class="map-marker" data-lng-lat="-87.6278,41.8820"
aria-label="Loop store" aria-controls="loop">
<span class="map-marker-label">Loop</span>
</button>
<div class="map-popup" id="loop">
<h3>Loop store</h3>
<p>Open until 9 p.m.</p>
</div>
<div class="map-controls">
<div class="map-control-group">
<button type="button" class="icon-button" data-action="zoom-in" aria-label="Zoom in">
<span class="material-symbols" aria-hidden="true">add</span>
</button>
<button type="button" class="icon-button" data-action="zoom-out" aria-label="Zoom out">
<span class="material-symbols" aria-hidden="true">remove</span>
</button>
</div>
</div>
</div>
Setup
ExpressiveCSS does not bundle MapLibre. To set it up:
- Install MapLibre.
- Load its stylesheet before ExpressiveCSS.
- Pass the module to
MapView.init().
AutoInit() does not start the map. The class is
MapView so importing it does not hide JavaScript's
Map.
npm install maplibre-gl
import 'maplibre-gl/dist/maplibre-gl.css';
import * as maplibregl from 'maplibre-gl';
import { MapView } from '@expressivecss/expressive';
const map = MapView.init(document.querySelector('.map'), {
maplibregl,
center: [-87.65, 41.89],
zoom: 11
});
Without a bundler, import maplibre-gl.mjs from a CDN.
MapLibre loads its worker from the same folder, so a page with a
Content Security Policy needs worker-src blob: and the
CDN in script-src.
Styles
By default the map uses OpenFreeMap's
Positron style in the light theme and its Dark style in the dark
theme. Neither needs an API key. The map changes style when the
theme attribute or the system color scheme changes.
To keep one style, pass style as a style URL or object.
To choose both, pass { light, dark }. Any
MapLibre style works, such as MapTiler, Stadia Maps or Protomaps.
Follow your provider's terms and keep its attribution visible.
MapView.init(el, {
maplibregl,
style: {
light: 'https://tiles.openfreemap.org/styles/liberty',
dark: 'https://tiles.openfreemap.org/styles/dark'
}
});
Markers and popups
Set data-lng-lat on a .map-marker to place
it, written as longitude then latitude. The
script skips a value that is not two numbers with a latitude between
-90 and 90, and logs a console warning. A marker with no content of
its own is a primary dot. Put an
avatar, icon or image inside to
replace the dot. A .map-marker-label shows below it, or
above with .top. A child
.tooltip shows on hover
and focus.
Make a marker a <button> with
aria-controls naming a .map-popup. The
marker then opens that popup and sets aria-expanded. A
click on the
map or Escape closes it, and focus returns to the marker.
A .map-popup with its own data-lng-lat and
no marker opens when the map starts.
Add data-draggable to let the reader drag a marker, and
make the marker a named <button>. A drag is not
the only way to move it. A tap picks the marker up and sets
aria-pressed="true". The next tap on the map drops it
there. Escape or a second tap on the marker puts it back.
The arrow keys nudge a focused marker. Each move leaves the new
position in its data-lng-lat and fires
change.
<button type="button" class="map-marker" data-lng-lat="-87.62,41.88" data-draggable aria-label="Drop-off point">
<span class="map-marker-label top">Drop-off</span>
</button>
<div class="map-popup" data-lng-lat="-87.70,41.85">Depot</div>
<script>
document.querySelector('.map').addEventListener('change', (event) => {
console.log(event.target.dataset.lngLat);
});
</script>
Routes and arcs
addRoute(id, coordinates, options) draws a line through
[longitude, latitude] points in the primary color.
addArcs(id, arcs, options) draws a curve for each
{ from, to } pair in the tertiary color. Both take
color, width, opacity and
dashArray. The map does not plan routes, so get the
points from a routing service such as OSRM or Valhalla.
const instance = MapView.init(el, { maplibregl });
instance.addRoute('delivery', [[-87.70, 41.85], [-87.65, 41.87], [-87.62, 41.88]]);
instance.addArcs('flights', [
{ from: [-87.90, 41.97], to: [-73.78, 40.64] },
{ from: [-87.90, 41.97], to: [-118.41, 33.94] }
], { dashArray: [2, 2] });
Clusters
addClusters(id, data, options) draws GeoJSON points, or
the URL of a GeoJSON file, and groups those close together. A
cluster shows its count when the style has fonts. A click on a
cluster zooms in until it splits. Its layers are
${id}-clusters, ${id}-count and
${id}-points. To handle a click on a point, use
instance.map.on('click', `${id}-points`, …).
instance.addClusters('stores', 'https://example.com/stores.geojson', { radius: 50, maxZoom: 14 });
instance.map.on('click', 'stores-points', (event) => {
console.log(event.features[0].properties);
});
Lines and clusters take their colors from the theme tokens where the
map is. When the style changes, the map redraws them in the new
theme's colors. removeOverlay(id) removes one.
Controls
Put icon buttons in a
.map-controls to stack them in the top right corner.
Add .top-left, .bottom-left or
.bottom-right for another corner. A
.map-control-group joins buttons on one surface. Each
button's data-action sets what it does:
zoom-inandzoom-outzoom the map. They are disabled at the zoom limits.compassturns the map back to north. Its icon points north as the map rotates.locatecenters the map on the reader's location and marks it. It is disabled if the reader refuses.fullscreenshows the map full screen and setsaria-pressed. It is hidden where full screen is unavailable.pan-up,pan-down,pan-leftandpan-rightmove the view a third of the map's size, so it pans without a drag.
Accessibility
Name the map with aria-label and
role="region". MapLibre makes the canvas focusable. On
it, the arrow keys pan, + and - zoom, and
Shift with the arrows rotates. A marker that does
something should be a named <button>. A
decorative one can be a <span>. Put what matters,
such as store addresses, in text on the page as well, because a map
is hard to use without sight.
Options
| Name | Type | Default | Description |
|---|---|---|---|
maplibregl |
Module | null |
The maplibre-gl module. Required. |
style |
String, Object | OpenFreeMap Positron and Dark | A style URL or object, or { light, dark } to follow the theme. |
center |
Array | [0, 20] |
Starting [longitude, latitude]. |
zoom |
Number | 1 |
Starting zoom level. |
mapOptions |
Object | {} |
More MapLibre Map options, such as maxZoom or bearing. |
Methods
const instance = Expressive.MapView.getInstance(document.querySelector('.map'));
instance.map; // the MapLibre map, for anything else
instance.markers; // the MapLibre markers, in markup order
instance.addRoute(id, coordinates, options);
instance.addArcs(id, arcs, options);
instance.addClusters(id, data, options);
instance.removeOverlay(id);
instance.destroy(); // removes the map and puts the markup back
Tokens
Override these on the .map.
| Token | Default |
|---|---|
--md-comp-map-height | 400px |
--md-comp-map-shape | 16px |
--md-comp-map-marker-color | primary |
--md-comp-map-marker-ring-color | surface |
--md-comp-map-route-color | primary |
--md-comp-map-arc-color | tertiary |
--md-comp-map-cluster-color | primary |
--md-comp-map-cluster-label-color | on-primary |
--md-comp-map-popup-max-width | 280px |
--md-comp-map-control-offset | 12px |