Skip to content

Story maps in plain HTML

<ml-scrollytelling> turns a type: scrollytelling YAML document into a story map: a map that stays on screen while chapters scroll past it, flying the camera, showing and hiding layers and running actions as each chapter comes into view. It is a web component, so it works in a plain HTML page loaded from a CDN: no npm, no bundler, no framework.

It is the same engine Astro’s <Scrollytelling> renders, so a story file works unchanged in both. For every field a story takes, see the Story file reference.

  1. Create index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My story map</title>
    <link
    rel="stylesheet"
    href="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.css"
    />
    <!-- Tell the browser where to find maplibre-gl -->
    <script type="importmap">
    { "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs" } }
    </script>
    <!-- Registers <ml-scrollytelling> (and <ml-map>) -->
    <script
    type="module"
    src="https://unpkg.com/@maplibre-yaml/core@0.9/register.js"
    ></script>
    <style>
    body {
    margin: 0;
    font-family: system-ui, sans-serif;
    }
    </style>
    </head>
    <body>
    <ml-scrollytelling src="story.yaml"></ml-scrollytelling>
    </body>
    </html>
  2. Create story.yaml next to it

    type: scrollytelling
    id: three-cities
    theme: dark
    showMarkers: true
    config:
    mapStyle: "https://demotiles.maplibre.org/style.json"
    center: [0, 20]
    zoom: 1.5
    layers:
    - id: cities
    type: circle
    source:
    type: geojson
    data:
    type: FeatureCollection
    features:
    - type: Feature
    properties: { name: Lagos }
    geometry: { type: Point, coordinates: [3.38, 6.52] }
    - type: Feature
    properties: { name: Lima }
    geometry: { type: Point, coordinates: [-77.04, -12.05] }
    - type: Feature
    properties: { name: Jakarta }
    geometry: { type: Point, coordinates: [106.85, -6.21] }
    paint:
    circle-radius: 8
    circle-color: "#f97316"
    circle-stroke-width: 2
    circle-stroke-color: "#ffffff"
    chapters:
    - id: lagos
    title: Lagos
    description: "<p>The largest city in Africa.</p>"
    center: [3.38, 6.52]
    zoom: 5
    - id: lima
    title: Lima
    description: "<p>On the Pacific coast of South America.</p>"
    center: [-77.04, -12.05]
    zoom: 5
    pitch: 40
    alignment: left
    - id: jakarta
    title: Jakarta
    description: "<p>On the north coast of Java.</p>"
    center: [106.85, -6.21]
    zoom: 5
    alignment: right
    onChapterEnter:
    - action: setPaintProperty
    layer: cities
    property: circle-color
    value: "#22c55e"
    footer: "<p>Made with maplibre-yaml.</p>"
  3. Serve the folder and open it

    Terminal window
    npx serve .

    Open the address it prints (for example http://localhost:3000) and scroll.

Instead of src, put the YAML in a <script type="text/yaml"> inside the element, the same way <ml-map> takes it. Keep the YAML at the left margin: indentation is part of YAML.

<ml-scrollytelling>
<script type="text/yaml">
type: scrollytelling
id: inline-story
config:
mapStyle: "https://demotiles.maplibre.org/style.json"
center: [0, 20]
zoom: 1.5
chapters:
- id: start
title: Start here
description: "<p>Scroll down.</p>"
center: [0, 20]
zoom: 1.5
- id: europe
title: Europe
center: [10, 50]
zoom: 3.5
</script>
</ml-scrollytelling>
  • A sticky map and a chapter column. The map fills the screen and stays put while the story scrolls; each chapter is a card. alignment: left | right | center | full places the card, and the camera is offset so the chapter’s center is not hidden behind it: beside a left or right card, above a centred or full-width one. On phones the card sits in the lower part of the screen and the map’s subject shows above it; on a phone on its side every card is a narrow one on the left, with the subject beside it. Nothing on the map moves with the scroll: the legend is pinned in one corner above the cards (a “Legend” button on phones that opens above them; see The legend), and a chapter photo is never taller than 40% of the screen.
  • Camera moves per chapter: center, zoom, pitch, bearing, and animation: flyTo | easeTo | jumpTo (with speed, curve).
  • Layers and actions: layers.show / layers.hide, and onChapterEnter / onChapterExit actions (setFilter, setPaintProperty, setLayoutProperty, flyTo, easeTo, fitBounds, custom).
  • A chapter rail (showMarkers: true): one dot per chapter at the right edge. Click a dot to jump to its chapter, or Tab to it and use the arrow keys, Home and End.
  • The whole map document. A story takes the same map-level keys a type: map document does: title: and description: (the map’s name and description for screen readers), sources:, markers:, legend:, state: and parameters: (the params panel), images:, controls:, terrain:, projection:; and config.locale translates the story’s own text (Accessibility & languages). A classify: layer, or a layer with its own legend: entry, pins a legend even without a legend: block. Corner slots work too (below).
  • Reading comes first. The mouse wheel and touch scroll the story, not the map (the story map’s own pan and zoom gestures are off; set scrollZoom: true or dragPan: true in config: to turn one back on). Links, images and video in a chapter work. Under the reader’s reduce motion setting every camera move is instant and ambient rotation is off.
  • One error card. A story that fails to load or validate shows a single card listing each problem with its line number and any did-you-mean hint (centre → center), instead of a map. A story map that is slow to start shows a small “Loading map…” until its basemap draws, as <ml-map> does.

The element is an ordinary block. Put content before and after it and the map is sticky only while the story’s chapters are on screen:

<header>
<h1>Ring of Fire</h1>
<p>Scroll down to start the story.</p>
</header>
<ml-scrollytelling src="story.yaml"></ml-scrollytelling>
<article>
<h2>After the story</h2>
<p>The page carries on as normal.</p>
</article>

A GitHub Pages project site lives at https://<user>.github.io/<repo>/, so a path written for the site, like /images/photo.jpg, would load from the domain root and 404. Add base with the subpath and leave the YAML alone:

<ml-scrollytelling src="story.yaml" base="/my-repo/"></ml-scrollytelling>

The story’s src, every path in its map document and chapter image / video paths then resolve under /my-repo/; full https:// URLs are never changed. It works exactly like <ml-map base>: the details, and how to preview a subpath on your computer, are in Publishing & subpaths.

Children with a slot attribute are handed to the story’s map, exactly as on <ml-map>: top-left, top-right, bottom-left and bottom-right. They stay in their corner, under the cards. A slot="legend" child fills the story’s pinned legend panel instead of the built-in entries.

<ml-scrollytelling src="story.yaml">
<p slot="bottom-left">Data: Smithsonian GVP</p>
</ml-scrollytelling>

The element dispatches bubbling events: ml-scrollytelling:chapter when a chapter becomes active, ml-scrollytelling:action when a custom action runs, and ml-scrollytelling:error when the story can’t be loaded or validated. It also carries the active chapter’s id in data-active-chapter. The chapter and action events carry map, the MapLibre map, so a page script can do anything MapLibre can:

<script type="module">
const story = document.querySelector("ml-scrollytelling");
story.addEventListener("ml-scrollytelling:action", (event) => {
const { action, map } = event.detail;
if (action.options?.name === "pulse") {
map.setPaintProperty("cities", "circle-radius", 14);
}
});
// Or wait for the map directly
const map = await story.mapReady();
console.log("story map loaded at zoom", map.getZoom());
</script>

Two attributes help while you work on a story and once it is published. debug outlines each chapter (green when active) and shows the story’s author hints, such as a chapter naming a layer that doesn’t exist, wherever the page is served; without it they show only while you develop. quiet hides the on-page notes on a finished site (the map’s “Part of this map couldn’t load” notice and a chapter’s “image didn’t load” note); the console keeps them.

Every attribute (src, base, debug, quiet), property, method (mapReady(), goToChapter(id), …), event and its detail, slot and styling hook is listed in the <ml-scrollytelling> reference. mapElement gives you the story’s own <ml-map>, whose methods and events work as on any map.

The element’s styles are added to the page for you, before your own stylesheets, so plain CSS overrides them:

/* A shorter map: the story then fits between other page content. */
ml-scrollytelling {
--ml-scrolly-map-height: 80vh;
}
/* Under a 64px fixed header: stick the map below it, and shorten it to fit. */
ml-scrollytelling {
--ml-scrolly-map-top: 64px;
--ml-scrolly-map-height: calc(100vh - 64px);
}
/* Wider, squarer cards */
ml-scrollytelling .chapter-content {
max-width: 520px;
border-radius: 4px;
}

The parts are .scrolly-map-container (the sticky map), .scrolly-chapters (the column), .scrolly-chapter (one chapter; .is-active while active, .align-left and so on), .chapter-content (the card), .chapter-markers / .chapter-marker (the rail) and .scrolly-footer.

Under Vite, webpack or any bundler, install the packages and import the registration module once:

Terminal window
npm install @maplibre-yaml/core maplibre-gl
// maplibre-gl 6 loads its worker from a separate file. Inside a bundle it
// cannot find that file by itself, so tell it where it is (Vite shown).
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import "maplibre-gl/dist/maplibre-gl.css";
import "@maplibre-yaml/core/register";
setWorkerUrl(workerUrl);

The element is then available in your HTML or templates. On maplibre-gl 4 or 5, delete the two worker lines and the setWorkerUrl call (there is no worker file to import). Other bundlers: see Installation.

examples/scrollytelling/ in the repository is a complete story in a plain page (index.html with story.yaml, and inline.html with the YAML inline), driven in a real browser by the test suite. From a clone, run pnpm verify:serve and open http://localhost:4174/examples/scrollytelling/index.html.