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.
Quick start
Section titled “Quick start”-
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><linkrel="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>) --><scripttype="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> -
Create
story.yamlnext to ittype: scrollytellingid: three-citiestheme: darkshowMarkers: trueconfig:mapStyle: "https://demotiles.maplibre.org/style.json"center: [0, 20]zoom: 1.5layers:- id: citiestype: circlesource:type: geojsondata:type: FeatureCollectionfeatures:- type: Featureproperties: { name: Lagos }geometry: { type: Point, coordinates: [3.38, 6.52] }- type: Featureproperties: { name: Lima }geometry: { type: Point, coordinates: [-77.04, -12.05] }- type: Featureproperties: { name: Jakarta }geometry: { type: Point, coordinates: [106.85, -6.21] }paint:circle-radius: 8circle-color: "#f97316"circle-stroke-width: 2circle-stroke-color: "#ffffff"chapters:- id: lagostitle: Lagosdescription: "<p>The largest city in Africa.</p>"center: [3.38, 6.52]zoom: 5- id: limatitle: Limadescription: "<p>On the Pacific coast of South America.</p>"center: [-77.04, -12.05]zoom: 5pitch: 40alignment: left- id: jakartatitle: Jakartadescription: "<p>On the north coast of Java.</p>"center: [106.85, -6.21]zoom: 5alignment: rightonChapterEnter:- action: setPaintPropertylayer: citiesproperty: circle-colorvalue: "#22c55e"footer: "<p>Made with maplibre-yaml.</p>" -
Serve the folder and open it
Terminal window npx serve .Open the address it prints (for example http://localhost:3000) and scroll.
The story inline, in one file
Section titled “The story inline, in one file”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: scrollytellingid: inline-storyconfig: mapStyle: "https://demotiles.maplibre.org/style.json" center: [0, 20] zoom: 1.5chapters: - 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>What you get
Section titled “What you get”- 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 | fullplaces the card, and the camera is offset so the chapter’scenteris 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, andanimation: flyTo | easeTo | jumpTo(withspeed,curve). - Layers and actions:
layers.show/layers.hide, andonChapterEnter/onChapterExitactions (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: mapdocument does:title:anddescription:(the map’s name and description for screen readers),sources:,markers:,legend:,state:andparameters:(the params panel),images:,controls:,terrain:,projection:; andconfig.localetranslates the story’s own text (Accessibility & languages). Aclassify:layer, or a layer with its ownlegend:entry, pins a legend even without alegend: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: trueordragPan: trueinconfig: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.
In the middle of a page
Section titled “In the middle of a page”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>Sites under a subpath (GitHub Pages)
Section titled “Sites under a subpath (GitHub Pages)”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.
Corner slots
Section titled “Corner slots”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>Reacting to the story from JavaScript
Section titled “Reacting to the story from JavaScript”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.
Styling
Section titled “Styling”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.
With a bundler
Section titled “With a bundler”Under Vite, webpack or any bundler, install the packages and import the registration module once:
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.
Runnable example
Section titled “Runnable example”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.