Story maps in Astro
A story map is a map that moves as the reader scrolls: chapters of text and
images scroll over a sticky map, and each chapter flies the camera somewhere,
shows or hides layers, and can run actions (often called scrollytelling).
In an Astro site, the <Scrollytelling> component from
@maplibre-yaml/astro shows a story written as one YAML file.
<Scrollytelling> renders core’s <ml-scrollytelling>
web component, the same element a plain HTML page uses from a CDN. Astro adds
build-time loading: with config, the chapters, the rail and the map’s
document are rendered into the page at build time and the element takes them
over in the browser; with src, the element fetches and renders the story
itself. Either way one engine drives the camera, layers, actions and rail, so
a story file behaves the same in an Astro site and in a plain page.
This page covers the Astro side. The story file itself (every chapter key,
action and theme) is described in the Story file reference;
the same story in a plain HTML page is in
Story maps in plain HTML. For the
<Map> and <FullPageMap> components, installation and deploying, see
Astro.
Quick start
Section titled “Quick start”A story is one YAML file: the map (config: and layers:, as on any map)
and the chapters: the reader scrolls through. This one is self-contained
(its data is inline), so it works as soon as you save it as
src/stories/earthquakes.yaml:
type: scrollytellingid: earthquake-storytheme: dark # 'light' or 'dark'showMarkers: true # Show chapter navigation dotsmarkerColor: "#3FB1CE" # Custom marker color
# Base map configurationconfig: mapStyle: "https://tiles.openfreemap.org/styles/liberty" center: [-118.2437, 34.0522] zoom: 9 pitch: 0 bearing: 0
# Map layers (inline sources here; a `sources:` block works too, as on a map).# For your own data, put a GeoJSON file in public/data/ and replace `data:`# with `url: "/data/earthquakes.geojson"`.layers: - id: earthquake-circles type: circle source: type: geojson data: type: FeatureCollection features: - type: Feature geometry: { type: Point, coordinates: [-118.537, 34.213] } properties: { name: "Northridge, 1994", magnitude: 6.7 } - type: Feature geometry: { type: Point, coordinates: [-118.40, 34.42] } properties: { name: "San Fernando, 1971", magnitude: 6.6 } - type: Feature geometry: { type: Point, coordinates: [-118.08, 34.06] } properties: { name: "Whittier Narrows, 1987", magnitude: 5.9 } - type: Feature geometry: { type: Point, coordinates: [-118.00, 34.26] } properties: { name: "Sierra Madre, 1991", magnitude: 5.6 } - type: Feature geometry: { type: Point, coordinates: [-117.76, 33.95] } properties: { name: "Chino Hills, 2008", magnitude: 5.4 } paint: circle-radius: 8 circle-color: "#ff0000" circle-stroke-width: 2 circle-stroke-color: "#ffffff"
# Story chapterschapters: - id: intro title: "Los Angeles Earthquakes" description: "<p>Exploring seismic activity in Southern California.</p>" center: [-118.2437, 34.0522] zoom: 9 alignment: center
- id: detail title: "Major Fault Lines" description: "<p>The San Andreas Fault runs through this region.</p>" # To show a picture above the text, add a file in public/images/ and # write `image: "/images/fault-lines.jpg"` (or use a full https:// URL). center: [-118.0, 34.0] zoom: 11 pitch: 45 bearing: 30 animation: flyTo speed: 0.8 alignment: left layers: show: [earthquake-circles] hide: []
- id: conclusion title: "Preparing for the Future" description: "<p>Understanding earthquake patterns helps us prepare.</p>" center: [-118.2437, 34.0522] zoom: 9 alignment: full
footer: "<p>Data source: USGS</p><p>© 2024</p>"Then load the story at build time and pass it to <Scrollytelling> as
config, on a whole page such as src/pages/story.astro:
---import { Scrollytelling, loadScrollytellingConfig } from '@maplibre-yaml/astro';const story = await loadScrollytellingConfig('./src/stories/earthquakes.yaml');---<html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Los Angeles earthquakes</title> </head> <body style="margin: 0;"> <Scrollytelling config={story} /> </body></html>The viewport meta makes phones lay the story out at their own width (without
it, the map and chapter text come out tiny), and margin: 0 lets the map
reach the edges. The other snippets on this page show only the component:
put them on a page like this one, or in a
layout.
Or load it in the browser from public/. The component fetches, validates
and renders the story there, and a validation failure shows the errors in
place of the chapters:
<Scrollytelling src="/stories/cascadia.yaml" />Features
Section titled “Features”- Scroll-Driven Navigation: Map automatically transitions as you scroll through chapters
- Smooth Camera Transitions: Choose from flyTo, easeTo, or jumpTo animations
- Layer Control: Show/hide map layers per chapter for progressive disclosure
- Chapter Actions: Execute custom MapLibre actions on chapter enter/exit
- Optional Markers: A clickable chapter rail, also driven by the keyboard
- The whole map document:
sources:,markers:,legend:, the params panel and the rest work in a story - Build-time Loading: Load and validate the story with
loadScrollytellingConfig - One error card: a story that fails in the browser lists each problem with its line number and any did-you-mean
- Debug Mode: Visual indicators to help during development
- Accessible: keyboard navigation, reduced motion, and the wheel scrolls the story rather than the map
Scrollytelling component API
Section titled “Scrollytelling component API”interface ScrollytellingProps { src?: string; // Path to YAML config file (in public directory) config?: ScrollytellingBlock; // Pre-loaded scrollytelling configuration class?: string; // Additional CSS class for container debug?: boolean; // Debug mode - chapter outlines, author hints on any host // Where the story's paths start (default: import.meta.env.BASE_URL). // false = resolve relative paths against the page. base?: string | false; // Hide on-page notices for non-fatal problems on the story's map, // as on Map (default: false). Load/validation errors still show. quiet?: boolean;}Examples
Section titled “Examples”Build-time loading with debug mode
Section titled “Build-time loading with debug mode”---import { Scrollytelling, loadScrollytellingConfig } from '@maplibre-yaml/astro';const story = await loadScrollytellingConfig('./src/stories/development.yaml');---<Scrollytelling config={story} debug />Custom styling
Section titled “Custom styling”---import { Scrollytelling, loadScrollytellingConfig } from '@maplibre-yaml/astro';const story = await loadScrollytellingConfig('./src/stories/brand-story.yaml');---<Scrollytelling config={story} class="branded-scrollytelling"/>
<style> .branded-scrollytelling { font-family: 'Custom Font', sans-serif; }</style>Stories as a content collection
Section titled “Stories as a content collection”Keep several stories as YAML files in a content collection and build one page
per story. getScrollytellingSchema() validates each file with the same rules
as loadScrollytellingConfig, at build time and in astro dev:
import { defineCollection } from 'astro:content';import { glob } from 'astro/loaders';import { getScrollytellingSchema } from '@maplibre-yaml/astro/utils';
const stories = defineCollection({ loader: glob({ pattern: '*.yaml', base: './src/stories' }), schema: getScrollytellingSchema(),});
export const collections = { stories };---import { getCollection } from 'astro:content';import { Scrollytelling } from '@maplibre-yaml/astro';
export async function getStaticPaths() { const stories = await getCollection('stories'); return stories.map((story) => ({ params: { id: story.id }, props: { story } }));}
const { story } = Astro.props;---<Scrollytelling config={story.data} />Each file in src/stories/ is one standalone story document (type: scrollytelling at the top level, as in the quick start).
Chapter configuration
Section titled “Chapter configuration”Each chapter supports extensive configuration options:
Position & Camera:
id: Unique chapter identifier (required)center: [longitude, latitude] coordinates (required)zoom: Zoom level (required)pitch: Camera tilt (0-85 degrees, default 0)bearing: Camera rotation (-180 to 180 degrees, default 0)
Animation:
animation: “flyTo” (smooth arc), “easeTo” (direct), or “jumpTo” (instant); default “flyTo”speed: Animation speed (0-2, default 0.6)curve: Fly curve intensity (0-2, default 1)rotateAnimation: Keep rotating the camera while the chapter is activespinGlobe: Keep panning westward while the chapter is active
Content:
title: Chapter heading (required)description: HTML content (rendered as-is, not escaped). Markdown is not converted:**bold**and[link](url)show literally, so write<strong>bold</strong>and<a href="url">link</a>image: Image shown above the text: a fullhttps://URL, or a path to a file inpublic/(/images/fuji.jpgforpublic/images/fuji.jpg)video: Video with an inline player, given the same way asimagealignment: “left”, “right”, “center”, or “full”hidden: Hide content for map-only transitions
Interactivity:
layers.show: Array of layer IDs to make visiblelayers.hide: Array of layer IDs to hideonChapterEnter: Actions to execute when entering chapteronChapterExit: Actions to execute when leaving chaptercallback: A name passed in theml-scrollytelling:chapterevent, for your page script to dispatch on
The Story file reference lists every chapter key with its type and default.
Chapter actions
Section titled “Chapter actions”Execute MapLibre actions when users enter or exit chapters:
chapters: - id: filtered-view title: "Major Earthquakes Only" center: [-118.2, 34.0] zoom: 10 onChapterEnter: - action: setFilter layer: earthquake-circles filter: [">=", ["get", "magnitude"], 5.0] - action: setPaintProperty layer: earthquake-circles property: circle-color value: "#ff0000" onChapterExit: - action: setFilter layer: earthquake-circles filter: nullAvailable Actions:
| Action | Fields | Effect |
|---|---|---|
setFilter | layer, filter (null clears) | Filter features in a layer |
setPaintProperty | layer, property, value | Change a paint property |
setLayoutProperty | layer, property, value | Change a layout property |
flyTo / easeTo | options (center, zoom, bearing, pitch, duration, …) | Move the camera, overriding the chapter’s own camera |
fitBounds | bounds: [west, south, east, north] or [[west, south], [east, north]], options (padding, duration, …) | Frame an area |
custom | anything in options | Runs nothing itself. Dispatches ml-scrollytelling:action for your page |
A layer action naming a layer the map does not have logs a warning and is skipped. Enter actions run after the chapter’s camera move and layer toggles.
Events. The story element (<ml-scrollytelling>, which keeps the
scrollytelling-container class) dispatches bubbling events. On every
chapter change it dispatches ml-scrollytelling:chapter, with
detail: { chapterId, previousChapterId, index, callback, map }. callback
is the chapter’s callback: name. On each custom action it dispatches
ml-scrollytelling:action, with detail: { action, chapterId, phase, map }.
A story that cannot load or validate dispatches ml-scrollytelling:error,
with detail: { errors, warnings, source }. The element also carries
data-active-chapter. The example’s Tonga chapter
uses a custom action to show a banner:
const banner = document.getElementById("story-banner")!;document.addEventListener("ml-scrollytelling:action", (event) => { const { action } = (event as CustomEvent).detail; banner.textContent = String(action.options?.banner ?? "");rotateAnimation: true slowly rotates the camera while a chapter is active,
and spinGlobe: true pans it westward. Both stop on the next chapter and are
skipped when the reader prefers reduced motion.
Chapter markers
Section titled “Chapter markers”When showMarkers: true, navigation dots appear at the right edge of the map:
- Click a dot to jump to its chapter (the chapters in between are skipped)
- Tab to the rail, then use the arrow keys, Home and End; Enter or Space activates a dot
- The active dot is highlighted (and marked
aria-currentfor screen readers) - Customizable color via
markerColor - On phones the cards leave a gutter for the rail, so it never sits on a card
Themes
Section titled “Themes”Light theme (default):
- Light chapter cards on map background
- Suitable for bright map styles
- Light footer background
Dark theme:
- Dark semi-transparent chapter cards
- Works well with satellite/dark base maps
- Dark footer background
Debug mode
Section titled “Debug mode”Enable debug prop to see:
- Red dashed outlines around inactive chapters
- Green outlines around the active chapter
- Helpful for fine-tuning scroll trigger points
- The story’s author hints (a chapter naming a layer that doesn’t exist, a
key nested under
config:) on the map’s notice even on a deployed site; withoutdebugthey show only inastro devand onlocalhost
<Scrollytelling config={story} debug />Scroll behavior
Section titled “Scroll behavior”- The active chapter is the one whose card is on screen and nearest the middle of it, recomputed as the page scrolls, so scrolling back and forth always lands on the chapter you are reading. On phones the map moves on once the next card is on screen, not before.
- The story starts reacting once its first chapter reaches the middle 20% of the screen (the trigger zone)
- Smooth map transitions between chapters
- Progressive chapter content fade in/out
The chapters scroll over a sticky full-viewport 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), so the mouse wheel and touch scroll the story
rather than moving the map, while the rail, legend, markers, popups and slots
on the map stay clickable. The camera is offset so a chapter’s center is not
under its card: beside a left or right card, above a centred or full-width
card, and above the card on a phone (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), the other chrome stays in its
corner under the cards, and a chapter photo is never taller than 40% of the
screen. A slot="legend" child fills the pinned legend’s panel.
The map opens on the story’s config camera. As soon as it has loaded, the
chapter sitting in the trigger zone is applied at once, without any scrolling.
On a page that starts with the story that is the first chapter: its camera,
layers and onChapterEnter actions all run on load. Give layers their starting state in config and the
layers themselves (for example layout: { visibility: none } on a layer a
later chapter reveals), and let the first chapter’s actions apply its own
look.
Accessibility
Section titled “Accessibility”- Semantic HTML with proper ARIA labels
- Keyboard navigation for chapter markers (Tab, arrow keys, Home, End, Enter)
- Screen reader friendly chapter titles
- Reduced motion: when the reader’s system asks for reduced motion
(
prefers-reduced-motion), chapter cards stop animating, every chapter camera move and camera action (flyTo,easeTo,fitBounds) is instant (an action whoseoptionssetessential: truestill animates), androtateAnimation/spinGlobeare skipped - High contrast mode support
Performance tips
Section titled “Performance tips”- Use build-time loading for better initial page load
- Optimize images: Compress chapter images and use appropriate sizes
- Limit chapters: 5-10 chapters for optimal scrolling experience
- Simplify transitions: Use
jumpTofor instant transitions on slower devices - Lazy load media: Images and videos load only when needed
Chapter component
Section titled “Chapter component”The Chapter component displays one narrative section of a scrollytelling story, for pages that lay out chapters themselves. Scrollytelling renders its chapters with the same core renderer (chapterHTML from @maplibre-yaml/core/scrollytelling) and stylesheet, so a Chapter looks exactly like a story’s own.
Chapter features
Section titled “Chapter features”- Flexible Content: Title, HTML description, images, and videos
- Multiple Alignments: left, right, center, or full-width layouts
- Theme Support: Light and dark themes with CSS custom properties
- Hidden Mode: Map-only chapters without visible content
- Active States: Visual feedback based on scroll position
- Accessibility: ARIA labels, semantic HTML, keyboard navigation
- Responsive Design: Mobile-optimized with multiple breakpoints
- Reduced Motion: Respects user’s motion preferences
Chapter component API
Section titled “Chapter component API”interface ChapterProps { id: string; // Unique chapter identifier (required) title: string; // Chapter title (required) description?: string; // HTML description content image?: string; // Image URL (absolute or relative) video?: string; // Video URL (absolute or relative) alignment?: 'left' | 'right' | 'center' | 'full'; // Content position (default: 'center') hidden?: boolean; // Hide content for map-only chapters isActive?: boolean; // Full opacity (default: true) base?: string | false; // Base for image/video paths (default: BASE_URL) theme?: 'light' | 'dark'; // Visual theme (default: 'light')}Chapter examples
Section titled “Chapter examples”Basic chapter
Section titled “Basic chapter”Chapter is exported from the @maplibre-yaml/astro/components subpath only.
The package root exports Map, FullPageMap and Scrollytelling, and the
ChapterProps type.
---import { Chapter } from '@maplibre-yaml/astro/components';---<Chapter id="intro" title="Introduction" description="<p>Welcome to our story about climate data.</p>" alignment="center"/>Chapter with image
Section titled “Chapter with image”<Chapter id="hero" title="The Journey Begins" description="<p>Exploring the landscape of our data.</p>" image="/images/hero-landscape.jpg" alignment="left" theme="dark"/>Chapter with video
Section titled “Chapter with video”<Chapter id="demo" title="How It Works" description="<p>Watch this short demonstration.</p>" video="/videos/tutorial.mp4" alignment="center"/>Hidden chapter (map-only)
Section titled “Hidden chapter (map-only)”<Chapter id="transition" title="Transition View" hidden/>Full-width chapter
Section titled “Full-width chapter”<Chapter id="finale" title="Conclusion" description="<p>Thank you for following this journey through the data.</p>" alignment="full" theme="dark"/>Alignment options
Section titled “Alignment options”- left: Content positioned on the left side (good for right-aligned maps)
- right: Content positioned on the right side (good for left-aligned maps)
- center: Content centered (default, works with most layouts)
- full: Full-width content, centered text (impactful for conclusions)
Chapter themes
Section titled “Chapter themes”Light theme (default):
- White/translucent background (rgba(255, 255, 255, 0.95))
- Dark text (#1f2937)
- Blue links (#3b82f6)
Dark theme:
- Dark gray/translucent background (rgba(31, 41, 55, 0.95))
- Light text (#f9fafb)
- Light blue links (#60a5fa)
Active states
Section titled “Active states”The isActive prop controls visual emphasis:
- Active chapters: Full opacity (1.0), normal scale
- Inactive chapters: Reduced opacity (0.4), slightly scaled down (0.98)
A standalone Chapter is active by default (full opacity): nothing on the page would activate it. Pass isActive={false} for the dimmed look. Inside a story, <ml-scrollytelling> sets is-active from the scroll position.
Chapter accessibility
Section titled “Chapter accessibility”- Semantic
<section>withrole="region" - Unique
aria-labelledbyfor each chapter - Lazy-loaded images with
alttext - Videos with controls and fallback text
- High contrast mode support
- Reduced motion support
Next steps
Section titled “Next steps”- Story file reference: every key of a story document, chapter actions, themes and the pinned legend
- Story maps in plain HTML: the same
story file with
<ml-scrollytelling>and no build step - Astro:
<Map>,<FullPageMap>, loaders and deploying to GitHub Pages - Astro content collections: maps from your collection entries’ frontmatter