Skip to content

Writing a story file

A story file is one YAML document, type: scrollytelling, that describes a story map: a map, and the chapters that scroll over it. Each chapter moves the camera, and can show or hide layers and run actions (this is often called scrollytelling). This page explains how to write one. To put it on a page, see Story maps in plain HTML or Story maps in Astro; every key, with its type and default, is in the generated YAML reference for stories.

A story needs a type, an id, the map’s config: and at least one chapter. This one draws the past week’s earthquakes from the USGS feed and visits two places:

story.yaml
type: scrollytelling
id: quakes-this-week
# The map when the page opens: its basemap and camera.
config:
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
center: [0, 20]
zoom: 1.5
# Layers drawn for the whole story.
layers:
- id: quakes
type: circle
source:
type: geojson
url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson"
paint:
circle-radius: 5
circle-color: "#dc2626"
chapters:
- id: world
title: "A week of earthquakes"
description: "<p>Every earthquake of magnitude 4.5 or more in the past seven days.</p>"
center: [0, 20]
zoom: 1.5
- id: japan
title: "Japan"
description: "<p>Four plates meet under Japan.</p>"
center: [138, 37]
zoom: 4.5

Show it with <ml-scrollytelling src="story.yaml"> or the Astro <Scrollytelling> component. The map opens on the whole world; scroll and it flies to Japan as the second card reaches the middle of the screen.

  • id names the story. Keep it unique on the page.
  • config: is the map’s starting point, as in any map document: mapStyle (the basemap), center and zoom. Its other keys (maxZoom, locale, gestures) work too.
  • layers: are drawn for the whole story. Chapters show, hide and restyle them.
  • Each chapter needs an id, a title, a center and a zoom.

Check a story without a browser with npx maplibre-yaml@latest validate story.yaml. Each problem is listed with its line number and, for a misspelt key, a did-you-mean (centre → center). In the browser, the same list shows in place of the story.

Write a chapter’s text, pictures and video

Section titled “Write a chapter’s text, pictures and video”
  • description is HTML, shown as written: <p>, <strong>, <a href="…">. Markdown is not converted, so **bold** shows literally.
  • image: and video: show above the text. Give a full https:// URL, or the path of a file on your site: on an Astro or Vite site, a file in public/images/fault.jpg is /images/fault.jpg. Images load only as the reader reaches them, and are never taller than 40% of the screen.
chapters:
- id: fuji
title: "Mount Fuji"
description: "<p>Last erupted in <strong>1707</strong>.</p>"
image: "/images/fuji.jpg"
center: [138.73, 35.36]
zoom: 9

A chapter with hidden: true has no card: the map moves, and the reader sees nothing but the map until the next chapter.

alignment: puts the card on the left, on the right, in the center (the default), or across the full width. The camera is offset so the chapter’s center isn’t hidden behind its card: beside a left or right card, above a centred or full-width one. On phones every card sits in the lower part of the screen, and the chapter’s center shows above it.

chapters:
- id: intro
title: "Introduction"
center: [0, 20]
zoom: 1.5
alignment: full
- id: andes
title: "The Andes"
center: [-70, -20]
zoom: 4
alignment: left

cameraOffset: false, at the top of the file, turns the offset off: each center is then the middle of the whole map. A chapter’s own padding: (in pixels, one number or { top, right, bottom, left }) replaces the offset for that chapter.

Each chapter sets the camera with center and zoom, and optionally pitch (tilt, 0–85) and bearing (rotation, −180 to 180). animation: says how the map gets there:

  • flyTo (the default) zooms out, travels and zooms back in, in an arc. speed (default 0.6) and curve (default 1) tune the arc.
  • easeTo moves straight there, with no zoom-out arc. Use it between nearby places.
  • jumpTo cuts there at once.
chapters:
- id: tokyo
title: "Tokyo from above"
center: [139.69, 35.69]
zoom: 12
pitch: 60
bearing: -30
animation: easeTo

While a chapter is active, rotateAnimation: true slowly turns the camera, and spinGlobe: true slowly pans it westward (for a globe, set projection: { type: globe } at the top of the file). Both stop at the next chapter. When the reader’s system asks for reduced motion, every camera move is instant and neither runs.

layers: { show: [...], hide: [...] } on a chapter changes which layers are visible, by layer id. The names can also be the basemap’s own layers.

chapters:
- id: plates
title: "Plate boundaries"
center: [140, 20]
zoom: 3
layers:
show: [plate-boundaries]
hide: [quakes]

A chapter’s changes stay until another chapter undoes them. Give each layer its starting state in the layer itself: a layer that a later chapter reveals starts with layout: { visibility: none }. A name that is a near miss of one of the story’s layers (quake for quakes) gets a did-you-mean warning; a name unlike any of them is taken to be a basemap layer.

onChapterEnter runs when a chapter becomes active, after its camera move and layer changes. onChapterExit runs when the reader scrolls past it, before the next chapter’s camera move. Each is a list of actions:

actionWhat it doesIts fields
setFilterFilter a layer’s featureslayer, filter (an expression, or null to clear it)
setPaintPropertyChange a paint propertylayer, property, value
setLayoutPropertyChange a layout propertylayer, property, value
flyToFly the cameraoptions: center, zoom, pitch, bearing, duration, …
easeToEase the camera, with no arcoptions, as for flyTo
fitBoundsFrame an areabounds: [west, south, east, north] or [[west, south], [east, north]]; options: padding, duration, …
customRun nothing itself; tells your page scriptoptions: anything your script reads
chapters:
- id: strongest
title: "The strongest"
center: [0, 20]
zoom: 1.5
onChapterEnter:
- action: setFilter
layer: quakes
filter: [">=", ["get", "mag"], 6]
- action: setPaintProperty
layer: quakes
property: circle-radius
value: 10
onChapterExit:
- action: setFilter
layer: quakes
filter: null
- action: setPaintProperty
layer: quakes
property: circle-radius
value: 5

An action that names a layer the map doesn’t have does nothing, and the map’s notice says so while you develop. Under reduced motion the camera actions are instant, unless their options set essential: true.

A custom action runs nothing itself. The story element dispatches an ml-scrollytelling:action event with the action, so a script on the page can do anything with it:

chapters:
- id: deep
title: "Deep earthquakes"
center: [-70, -20]
zoom: 4
callback: deep-quakes
onChapterEnter:
- action: custom
options: { banner: "Deeper than 300 km" }
const banner = document.getElementById("banner");
document.addEventListener("ml-scrollytelling:action", (event) => {
const { action, phase } = event.detail; // also: chapterId, and map (the MapLibre map)
if (phase === "enter") banner.textContent = action.options.banner;
});

callback: is a name you choose. It does nothing by itself: it arrives as callback on the ml-scrollytelling:chapter event, which fires on every chapter change. Both events are in the <ml-scrollytelling> reference.

showMarkers: true adds a column of dots at the right edge of the map, one per chapter. Despite the key’s name, nothing is drawn on the map itself; to mark chapter places on the map, add a layer. Readers click a dot to jump to its chapter, or Tab to it and use the arrow keys, Home and End. The current chapter’s dot is larger and filled, so it stands out by more than colour. markerColor fills the dots with a colour of your own.

showMarkers: true
markerColor: "#f97316"

A story’s legend is pinned: it stays in one corner, above the chapter cards, while the story scrolls. Write it as on a map, with title and items:

legend:
title: Earthquakes
collapsed: auto # auto (the default), true or false
# position: top-right # optional; the default follows the cards
items:
- { color: "#dc2626", label: "Magnitude 4.5 or more", shape: circle }
  • Corner. position: if you set one. Otherwise top-left (away from the chapter rail), or top-right on a wide screen whose cards are all alignment: left.
  • Open, or a button. With collapsed: auto the legend is a “Legend” button on phones, and on wide screens where cards pass its corner (cards on both sides, or full-width). It is open everywhere else. true always starts it as a button, false always open. The button’s word is translated by maplibre-yaml.Legend.Title in config.locale.
  • The panel. The button opens the legend above the cards. The page stays usable; Escape or (on a phone) a tap outside closes it.

A story with a classify: layer, or a layer with its own legend: entry, gets a legend without a legend: block, by the same rules. legend: false at the top turns it off. A slot="legend" child of the story element fills the panel with your own markup instead.

A story’s map is an ordinary map document, so the top-level keys a type: map document takes work here too, with the same rules: title: and description: (the map’s name and summary for screen readers), named sources:, standalone markers: and popups:, controls:, images:, the params panel (state: and parameters:), and terrain:, sky:, light: and projection:. Write them at the top of the story file, not under config:.

type: scrollytelling
id: harbour-tour
title: "New York harbour"
config:
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
center: [-74.02, 40.69]
zoom: 12
sources:
piers:
type: geojson
locations:
- { coordinates: [-74.017, 40.703], name: Battery Park }
- { coordinates: [-73.997, 40.705], name: South Street Seaport }
layers:
- id: pier-dots
type: circle
source: piers
paint:
circle-radius: 8
circle-color: "#0ea5e9"
markers:
- at: [-74.0445, 40.6892]
color: "#16a34a"
popup:
- h3:
- str: Statue of Liberty
chapters:
- id: piers
title: "The piers"
center: [-74.01, 40.70]
zoom: 13
- id: liberty
title: "Liberty Island"
center: [-74.0445, 40.6892]
zoom: 14

The story map’s own pan and zoom gestures start off, so the reader’s wheel and finger scroll the story. Markers, popups, the legend and the params panel stay clickable. To let readers move the map, turn a gesture back on in config: (scrollZoom: true, dragPan: true).

  • theme: dark gives the cards light text on a dark background; light is the default.
  • footer: is HTML shown after the last chapter.
  • className: adds classes to the story element, and style: adds inline CSS to it, for example style: "--ml-scrolly-map-height: 80vh". The class names and custom properties you can style are in Styling.
theme: dark
className: quake-story
footer: "<p>Data: <a href=\"https://earthquake.usgs.gov/\">USGS</a></p>"
  • The active chapter is the one whose card is on screen and nearest the middle of it, recomputed as the page scrolls. The story starts reacting once its first chapter reaches the middle 20% of the screen. On phones the map moves on once the next card is on screen.
  • On load, the map opens on the config: camera, then at once applies the chapter in that middle zone. On a page that starts with the story, the first chapter’s camera, layers and onChapterEnter actions all run.
  • Changing chapter runs the previous chapter’s onChapterExit actions, moves the camera, applies layers, then runs onChapterEnter.
  • Reading comes first. The wheel and touch scroll the story, not the map. The rail, legend, markers, popups and slots stay clickable. Nothing on the map moves with the scroll: the legend, controls and corner slots stay in their corners, under the cards.
  • Reduced motion. Cards stop animating, every camera move and camera action is instant (an action whose options set essential: true still animates), and rotateAnimation and spinGlobe are off.
  • Problems. A story that fails to load or validate shows one card listing each problem with its line number, instead of the story. A story map that is slow to start shows “Loading map…” until its basemap draws.

The past week’s earthquakes, coloured by magnitude, with the chapter rail, a legend and a chapter that filters to the strongest:

story.yaml
type: scrollytelling
id: earthquake-story
title: "A week of earthquakes"
theme: dark
showMarkers: true
markerColor: "#f97316"
config:
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
center: [0, 20]
zoom: 1.5
legend:
title: Magnitude
items:
- { color: "#fbbf24", label: "4.5 to 6", shape: circle }
- { color: "#dc2626", label: "6 or more", shape: circle }
layers:
- id: quakes
type: circle
source:
type: geojson
url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson"
paint:
circle-radius: ["interpolate", ["linear"], ["get", "mag"], 4.5, 4, 8, 16]
circle-color: ["step", ["get", "mag"], "#fbbf24", 6, "#dc2626"]
circle-stroke-width: 1
circle-stroke-color: "#ffffff"
chapters:
- id: world
title: "A week of earthquakes"
description: "<p>Every earthquake of magnitude 4.5 or more in the past seven days.</p>"
center: [0, 20]
zoom: 1.5
alignment: full
- id: ring-of-fire
title: "The Ring of Fire"
description: "<p>Most of them follow the edges of the Pacific.</p>"
center: [160, 10]
zoom: 2
alignment: left
- id: japan
title: "Japan"
description: "<p>Four plates meet under Japan.</p>"
center: [138, 37]
zoom: 4.5
pitch: 40
alignment: right
- id: strongest
title: "The strongest"
description: "<p>Only magnitude 6 and above.</p>"
center: [0, 20]
zoom: 1.5
onChapterEnter:
- action: setFilter
layer: quakes
filter: [">=", ["get", "mag"], 6]
onChapterExit:
- action: setFilter
layer: quakes
filter: null
footer: "<p>Data: USGS earthquake feed, updated every minute.</p>"

To change this story and watch it update as you type, open it in the playground. Download there saves it as story.yaml with an index.html that shows it.

The repository’s examples/scrollytelling story uses every kind of action, on a plain HTML page.

Problems every map shares are in Troubleshooting.

  • **bold** shows literally in a chapter. description is HTML; write <strong>bold</strong>.
  • “Two chapters have id …”. Each chapter needs its own id; the message names both lines. Copying a chapter and keeping its id is the usual cause.
  • A layer doesn’t appear or disappear. Check the chapter’s layers ids against the layers’ ids. A repeated layer id warns, and only the first layer with it is drawn.
  • A key under config: does nothing, such as legend: or sources:. These go at the top of the story file; the map’s notice says so while you develop.
  • The map scrolls away with the chapters. A wrapper around the story has overflow: hidden, auto or scroll, which stops position: sticky.

Putting a story on a page is on Story maps in plain HTML and Story maps in Astro.

The table of every key is the generated YAML reference for stories.