Use src Attribute (Recommended)
Place YAML files in /public/configs/ and use <ml-map src="/configs/map.yaml">. Simplest and most maintainable approach.
Astro is a natural fit for maplibre-yaml - both embrace configuration-driven approaches. This guide covers integrating maps into Astro sites, from simple page embeds to reusable components.
Get a map in your Astro site in 5 minutes.
Install dependencies
pnpm add @maplibre-yaml/core maplibre-gl@^6maplibre-gl 4, 5 and 6 (>= 6.4.1) are supported; v6 is recommended.
The components set maplibre-gl 6’s worker URL themselves, so no
setWorkerUrl() call is needed in an Astro project.
Create a map configuration file
Create public/configs/hero-map.yaml:
type: mapid: hero-mapconfig: center: [-74.006, 40.7128] zoom: 12 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: marker type: circle source: type: geojson data: type: FeatureCollection features: - type: Feature geometry: type: Point coordinates: [-74.006, 40.7128] properties: name: "New York City" paint: circle-radius: 12 circle-color: "#3b82f6" circle-stroke-width: 2 circle-stroke-color: "#ffffff"Create a Map component
Create src/components/Map.astro:
---interface Props { src: string; height?: string; class?: string;}
const { src, height = '400px', class: className } = Astro.props;---
<ml-map src={src} style={`height: ${height}; display: block;`} class={className}/>
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css';</script>
<style> ml-map { width: 100%; border-radius: 8px; overflow: hidden; }</style>Use in a page
---import Layout from '../layouts/Layout.astro';import Map from '../components/Map.astro';---
<Layout title="Home"> <h1>Welcome</h1> <Map src="/configs/hero-map.yaml" height="500px" /></Layout>For Astro projects, we recommend using the dedicated @maplibre-yaml/astro package instead of the core web components directly. This package provides:
/public for dynamic configspnpm add @maplibre-yaml/astro @maplibre-yaml/core maplibre-gl@^6| Astro | Supported | Notes |
|---|---|---|
| 7 | Yes, tested in CI | Needs Node 22.12 or newer (Astro’s own requirement). |
| 6 | Yes, tested in CI | Build collection schemas with z from astro/zod (see below). |
| 5 | Yes, tested in CI | |
| 4 | Yes, tested in CI | Content-layer collections need three changes, listed below. |
This needs @maplibre-yaml/astro 0.5 or later. Earlier versions declare
astro: ^4 || ^5, so npm install refuses them on Astro 6 and 7. Every major
is tested the same way. CI builds the example site against
the published tarballs with astro check, astro build and astro preview, then
drives it in a browser. It also runs a smoke test under astro dev. The
maplibre-gl peer range is ^4 || ^5 || ^6.4.1; the example site runs on v6.
Collections and zod. Astro 4 and 5 ship zod 3. Astro 6 and 7 ship zod 4.
The two cannot nest inside each other. Import z from astro/zod, which
works on all four majors. The /utils schema helpers are built from the same
module, so LocationPointSchema and the others nest in your z.object() on
every major. (z from astro:content is the same object, but Astro 6
deprecated that export.)
Astro 4 only. The glob() collection loaders on this page need three
changes:
experimental: { contentLayer: true } in astro.config.mjs.src/content/config.ts. Astro 4 does not read
src/content.config.ts. A one-line export { collections } from "../content.config"; there serves both: Astro 5+ prefer src/content.config.ts.base outside src/content/. Astro 4’s glob() skips files there,
and the folder becomes a legacy collection whose ids keep the extension
(park.md).The example site does all three.
examples/astro/site
is a small site, Ring of Fire, that uses every pattern on this page. CI
builds it and drives it in a browser on Astro 4, 5, 6 and 7. The snippets
below marked with a file name are copied from it, and a test fails if they
drift.
| Page | Shows |
|---|---|
/ | <Map config> from loadMapConfig, <Map src>, corner slots |
/explore | <FullPageMap> with zoom/reset controls, showLegend, markers, a parameters panel, hover popups |
/story | <Scrollytelling config>: chapters move the camera, toggle layers, run every action type |
/story-runtime | <Scrollytelling src>, loaded in the browser |
/volcanoes | A Markdown collection (glob() + LocationPointSchema), one page per entry via buildMapConfigFromEntry |
/gallery | A YAML collection of whole map documents (extendSchema(getMapSchema(), …)) |
Run it from a clone with pnpm install && pnpm build, then
pnpm --filter @maplibre-yaml/example-astro-site dev. To run the CI check for
one major, use node scripts/astro-matrix.mjs --majors 7.
Its content config works unchanged on all four majors:
import { defineCollection } from "astro:content";import { glob } from "astro/loaders";// `astro/zod` is the zod your Astro version ships: zod 3 on Astro 4/5,// zod 4 on Astro 6/7. The /utils helpers are built from the same module,// so they nest inside these objects on every major.import { z } from "astro/zod";import { LocationPointSchema, getMapSchema, extendSchema,} from "@maplibre-yaml/astro/utils";
/** `fuji.md` -> `fuji` on every Astro major (Astro 4 keeps the extension). */const stripExtension = ({ entry }: { entry: string }) => entry.replace(/\.[^.]+$/, "");
/** Markdown entries with a point location in their frontmatter. */const volcanoes = defineCollection({ loader: glob({ pattern: "*.md", base: "./src/collections/volcanoes", generateId: stripExtension }), schema: z.object({ title: z.string(), country: z.string(), elevation: z.number(), lastEruption: z.number(), location: LocationPointSchema, }),});
/** Whole map documents as YAML data entries, plus two fields of our own. */const maps = defineCollection({ loader: glob({ pattern: "*.yaml", base: "./src/collections/maps", generateId: stripExtension }), schema: extendSchema(getMapSchema(), { title: z.string(), blurb: z.string(), }),});
export const collections = { volcanoes, maps };The Map component is the simplest way to add a map to your Astro site:
Load YAML from /public directory:
---import { Map } from '@maplibre-yaml/astro';---
<Map src="/configs/map.yaml" height="500px" class="my-map"/>Place your YAML file in /public/configs/map.yaml:
type: mapid: my-mapconfig: center: [-120, 37] zoom: 4 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: points type: circle source: type: geojson url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson" paint: circle-radius: 6 circle-color: "#007cbf"<Map src> hands the URL to <ml-map src>, which fetches, parses and
validates the file in the browser and shows its own error card if any of that
fails.
Load and validate YAML at build time:
---import { Map, loadMapConfig } from '@maplibre-yaml/astro';
// Loaded at build time - validates YAML and catches errors earlyconst config = await loadMapConfig('./src/configs/map.yaml');---
<Map config={config} height="600px" style="border: 2px solid #e2e8f0; border-radius: 8px;"/>Benefits of build-time loading:
interface MapProps { // Data source (one required) src?: string; // Path to YAML file in /public config?: MapBlock; // Pre-loaded configuration
// Styling height?: string; // Container height (default: "400px") class?: string; // CSS class name style?: string; // Inline styles}If you pass both, config wins and the component warns at build time.
Children with slot="top-left", "top-right", "bottom-left",
"bottom-right" or "legend" are forwarded to <ml-map>’s corner system,
which places them in that corner of the map and stacks them with the built-in
legend and parameters panel. A legend slot replaces the built-in legend.
Map, FullPageMap and Scrollytelling all forward them:
<Map config={ringOfFire} height="480px"> <p slot="top-left" class="caption" data-testid="hero-caption"> <strong>16 volcanoes, one plate boundary</strong> Hover a dot for its name and height. </p> <a slot="bottom-right" class="caption" href="/explore">Explore full screen →</a></Map>Corners with no content render nothing. FullPageMap’s zoom and reset buttons
already sit in top-right, and your top-right content stacks below them.
Examples (each line is a separate usage, in a page that imports Map):
<!-- Minimal --><Map src="/maps/simple.yaml" />
<!-- With custom height --><Map src="/maps/dashboard.yaml" height="100vh" />
<!-- With styling --><Map src="/maps/styled.yaml" height="500px" class="shadow-lg rounded-lg" style="border: 1px solid #ccc;"/>Build-time loaded:
---import { Map, loadMapConfig } from '@maplibre-yaml/astro';const earthquakes = await loadMapConfig('./src/maps/earthquakes.yaml');---<Map config={earthquakes} height="600px" />The package provides utilities for loading YAML configurations:
Load and validate a map configuration from a file:
---import { loadMapConfig, Map } from '@maplibre-yaml/astro';
// Paths resolve from the project root (where `astro build` runs).// A missing or invalid file throws YAMLLoadError, which fails the build.const config = await loadMapConfig('./src/configs/my-map.yaml');// config is fully typed as MapBlock---
<Map config={config} />To report errors before failing, see Error Handling below.
Load multiple configurations at once:
---import { loadFromGlob, Map } from '@maplibre-yaml/astro';import { YAMLParser } from '@maplibre-yaml/core';
// A leading "/" is the project root. A "./" pattern resolves against THIS// page file, so './src/configs/*.yaml' from src/pages/ matches nothing.const configFiles = import.meta.glob<string>('/src/configs/*.yaml', { query: '?raw', import: 'default',});const maps = await loadFromGlob( configFiles, (yaml) => YAMLParser.safeParseMapBlock(yaml));---
<div class="map-gallery"> {maps.map(({ path, config }) => ( <div> <h2>{config.id} <small>{path}</small></h2> <Map config={config} height="400px" /> </div> ))}</div>If any file fails validation, loadFromGlob throws a single YAMLLoadError
listing every failing file, which fails the build. A pattern that matches
nothing returns an empty array without an error.
Use Astro Content Collections for managing map configurations:
1. Define collection schema:
import { defineCollection } from 'astro:content';import { glob } from 'astro/loaders';import { getMapSchema } from '@maplibre-yaml/astro/utils';
export const collections = { maps: defineCollection({ loader: glob({ pattern: '**/*.yaml', base: './src/collections/maps' }), schema: getMapSchema() })};2. Add YAML files:
type: mapid: earthquake-mapconfig: center: [-120, 35] zoom: 6 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: earthquakes type: circle source: type: geojson url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson" paint: circle-radius: 8 circle-color: "#ff4444"3. Query and use in pages:
---import { getEntry } from 'astro:content';import { Map } from '@maplibre-yaml/astro';
const earthquakeMap = await getEntry('maps', 'earthquake-map');if (!earthquakeMap) throw new Error('maps/earthquake-map.yaml not found');---
<Map config={earthquakeMap.data} height="600px" />With src, problems surface in the browser: <ml-map> replaces the map with
an error card and dispatches ml-map:error:
<!-- Invalid YAML or network error will show error message --><Map src="/configs/broken.yaml" />Error display includes:
config.zoom: Expected number, got string)Failed to fetch /configs/missing.yaml: 404 Not Found)With config, the same problems surface at build time instead. Handle them
programmatically like this:
---import { Map, loadMapConfig, YAMLLoadError } from '@maplibre-yaml/astro';
let config;try { config = await loadMapConfig('./src/configs/map.yaml');} catch (error) { if (error instanceof YAMLLoadError) { // `errors` is set for validation failures; a missing file or a YAML // syntax error carries only `message` (and the original as `cause`). console.error(`${error.filePath}: ${error.message}`); error.errors?.forEach(e => { console.error(` ${e.path}: ${e.message}`); }); } throw error; // Fail build on invalid config}---
<Map config={config} />Add custom metadata to your map configurations:
import { defineCollection } from 'astro:content';import { glob } from 'astro/loaders';import { z } from 'astro/zod';import { extendSchema, getMapSchema } from '@maplibre-yaml/astro/utils';
export const collections = { maps: defineCollection({ loader: glob({ pattern: '**/*.yaml', base: './src/collections/maps' }), schema: extendSchema(getMapSchema(), { author: z.string(), publishDate: z.date(), tags: z.array(z.string()), featured: z.boolean().default(false) }) })};author: "Jane Doe"publishDate: 2024-01-15tags: ["earthquakes", "geoscience"]featured: true
type: mapid: featured-mapconfig: center: [0, 0] zoom: 2 mapStyle: "https://demotiles.maplibre.org/style.json"---import { getCollection } from 'astro:content';import { Map } from '@maplibre-yaml/astro';
const featuredMaps = await getCollection('maps', ({ data }) => data.featured);---
{featuredMaps.map(entry => { // Hand <Map> only the map document. The metadata fields would otherwise // reach <ml-map> as unknown keys and log a warning each. const { author, publishDate, tags, featured, ...mapConfig } = entry.data; return ( <div> <h2>{mapConfig.id}</h2> <p>By {author} on {publishDate.toLocaleDateString()}</p> <Map config={mapConfig} /> </div> );})}Sources can be defined at the block level and referenced by name across multiple layers. This avoids duplicating source definitions and makes it easy to share data between layers (e.g., a fill and an outline from the same GeoJSON).
type: mapid: neighborhood-mapconfig: center: [-73.985, 40.674] zoom: 14 mapStyle: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
sources: boundary: type: geojson url: "https://example.com/data/boundary.geojson" parcels: type: vector url: "https://example.com/tiles.json"
layers: - id: boundary-fill type: fill source: boundary paint: fill-color: "#3388ff" fill-opacity: 0.2 - id: boundary-outline type: line source: boundary paint: line-color: "#3388ff" line-width: 2 - id: parcels-fill type: fill source: parcels source-layer: parcels paint: fill-color: "#ccc"Named sources are added to the map before layers are processed, so any layer can reference them by string ID. Inline sources on individual layers still work as before — the two approaches can be mixed freely.
For hand-authored data, a type: geojson source can carry a location,
locations, region, or route shorthand in place of a full data: object.
loadMapConfig parses with the core parser, which expands each sugar to
canonical GeoJSON before validation, so the config you hand to <Map> is
already normalized:
---import { Map, loadMapConfig } from '@maplibre-yaml/astro';const config = await loadMapConfig('./src/config/geojson-sugar.yaml');---<Map config={config} height="480px" />type: mapid: sugar-democonfig: center: [-73.98, 40.75] zoom: 12 mapStyle: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"sources: site: type: geojson location: coordinates: [-73.98, 40.75] name: "Site" path: type: geojson route: coordinates: [[-74.00, 40.73], [-73.98, 40.75], [-73.96, 40.77]] name: "Walking route"layers: - id: path-line type: line source: path paint: line-color: "#f59e0b" line-width: 4 - id: site-dot type: circle source: site paint: circle-radius: 9 circle-color: "#dc2626"See GeoJSON Sugar for the full reference and
all four shorthands. A runnable version lives in the minimal example at
examples/astro/minimal/src/pages/geojson-sugar.astro.
The same loadMapConfig / <Map> flow shown above works unchanged with
Format v2 documents (the additive style: / runtime: split): a v2
document parses to the same internal model as its v1 twin, so the config you
hand to <Map> is identical either way.
attachInteractions reattaches behavior (including
the trust-gated emit interaction).Map and FullPageMap hand the whole document to <ml-map>, through the
config attribute or the src URL. Map-level features therefore work with no
Astro-specific setup: markers:, images:, parameters: (the params panel),
legend:, and hover popups. One exception: a Scrollytelling story’s map is
built from its config: and layers: only. It has no sources:, markers:,
parameters: or legend, so give its layers inline sources.
Three behavior changes matter when you move an Astro project to 0.6:
!html loader fix (trust-gated). loadYAML / loadFromGlob now
parse with core’s canonical options, so an !html tag resolves to the same
{ $html } marker core produces (previously the Astro loader silently dropped
!html to a bare string). Rendering is still gated by the popup capability
policy: !html renders as raw markup only under a trusted policy, and is
escaped otherwise. It is trust-gated, not “renders HTML unconditionally.”data emits a warning (promoted to an error under mlym validate --strict / CI). Pre-check inline geometry.style.metadata flows through. In Format v2, style.metadata is
carried to the emitted style.json root metadata slot.The FullPageMap component creates a full-viewport map with built-in controls and an optional legend. It’s perfect for landing pages, dashboards, or dedicated map views.
<ml-map>’s slotssrc (runtime) and config (build-time) loading---import { FullPageMap } from '@maplibre-yaml/astro';---<FullPageMap src="/configs/world-map.yaml" />---import { FullPageMap, loadMapConfig } from '@maplibre-yaml/astro';const config = await loadMapConfig('./src/configs/world-map.yaml');---<FullPageMap config={config} />interface FullPageMapProps { // Map source (one required) src?: string; // Path to YAML file in /public config?: MapBlock; // Pre-loaded configuration object
// Control options showControls?: boolean; // Show zoom/reset controls (default: true) showLegend?: boolean; // Add a default legend: when the document has none (default: false) legendPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; // Legend position (default: 'top-right')
// Styling (optional - component is fixed viewport by default) class?: string; // Additional CSS classes style?: string; // Additional inline styles}---import { FullPageMap } from '@maplibre-yaml/astro';---<!DOCTYPE html><html> <head> <title>Interactive Map Dashboard</title> </head> <body style="margin: 0; padding: 0;"> <!-- dashboard-map.yaml declares its own `legend:` block and per-layer `legend:` entries; with `src`, the legend lives in the YAML. --> <FullPageMap src="/configs/dashboard-map.yaml" showControls /> </body></html><FullPageMap src="/configs/presentation-map.yaml" showControls={false}/><FullPageMap src="/configs/branded-map.yaml" style="z-index: 100;" class="dashboard-map"/>---import { FullPageMap, loadMapConfig } from '@maplibre-yaml/astro';
const mapConfig = await loadMapConfig('./src/configs/geospatial-data.yaml');---<FullPageMap config={mapConfig} showControls showLegend legendPosition="bottom-right"/>showLegend uses the core legend — the same one a document’s own legend:
block produces. When the document has no legend:, the component adds a
default one at legendPosition (v1: root legend:, v2: runtime.legend);
when the document declares its own legend:, that one wins untouched.
Legend entries come from the layers’ legend: fields (see
the layers reference) or the legend block’s own items:.
Nothing is generated from paint colors: a layer without legend: doesn’t
appear, and when no layer has one, no legend box is shown (the component
warns at build time, and <ml-map> warns in the console).
layers: - id: parks type: fill legend: { label: Parks, color: "#2e7d32", shape: square }showLegend needs the document at build time, so it applies to config.
With src, the document is fetched in the browser — declare legend: in the
YAML instead (the component warns at build time if you pass both src and
showLegend).
The built-in controls provide:
Controls are accessible with ARIA labels and keyboard navigation. They share the top-right corner with anything else placed there (a top-right legend, the params panel, a navigation control) and stack below it instead of overlapping.
The Chapter component displays individual narrative sections within scrollytelling experiences. It’s typically used internally by the Scrollytelling component but can be used independently for custom implementations.
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; // Active state (controlled by parent) theme?: 'light' | 'dark'; // Visual theme (default: 'light')}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 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 id="demo" title="How It Works" description="<p>Watch this short demonstration.</p>" video="/videos/tutorial.mp4" alignment="center"/><Chapter id="transition" title="Transition View" hidden/><Chapter id="finale" title="Conclusion" description="<p>Thank you for following this journey through the data.</p>" alignment="full" theme="dark"/>Light theme (default):
Dark theme:
The isActive prop controls visual emphasis:
This is typically managed by the parent Scrollytelling component based on scroll position.
<section> with role="region"aria-labelledby for each chapteralt textThe Scrollytelling component creates immersive narrative experiences where the map transitions between different views as users scroll through story chapters. Perfect for data journalism, interactive reports, and guided data tours.
loadScrollytellingConfiginterface 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 - shows chapter boundaries}Load the story at build time and pass it as config:
---import { Scrollytelling, loadScrollytellingConfig } from '@maplibre-yaml/astro';const story = await loadScrollytellingConfig('./src/stories/climate-change.yaml');---<Scrollytelling config={story} />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" />A complete scrollytelling story includes base map configuration, layers, and chapters:
type: scrollytellingid: earthquake-storytheme: dark # 'light' or 'dark'showMarkers: true # Show chapter navigation dotsmarkerColor: "#3FB1CE" # Custom marker color
# Base map configurationconfig: mapStyle: "https://demotiles.maplibre.org/style.json" center: [-118.2437, 34.0522] zoom: 9 pitch: 0 bearing: 0
# Map layers (sources are inline: a story has no `sources:` block)layers: - id: earthquake-circles type: circle source: type: geojson url: "/data/earthquakes.geojson" paint: circle-radius: 8 circle-color: "#ff0000"
# 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>" # Must be an absolute URL: a relative path such as /images/x.jpg fails # validation ("Invalid URL format"). image: "https://example.com/images/fault-lines.jpg" 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>"---import { Scrollytelling, loadScrollytellingConfig } from '@maplibre-yaml/astro';const story = await loadScrollytellingConfig('./src/stories/development.yaml');---<Scrollytelling config={story} debug />---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>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 activeContent:
title: Chapter heading (required)description: HTML content (rendered as-is, not escaped)image: Image URL for visual context (absolute URL)video: Video URL with inline player (absolute URL)alignment: “left”, “right”, “center”, or “full”hidden: Hide content for map-only transitionsInteractivity:
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 the ml-scrollytelling:chapter event, for your page script to dispatch onExecute 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], 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 container dispatches two 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 }.
The container 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.
When showMarkers: true, navigation dots appear on the right side of the viewport:
markerColorLight theme (default):
Dark theme:
Enable debug prop to see:
<Scrollytelling config={story} debug />The component uses IntersectionObserver with these settings:
The chapters scroll over a sticky full-viewport map, so the mouse wheel
scrolls the story rather than zooming the map. The map opens on the story’s
config camera with the first chapter marked active. That chapter’s layers
and onChapterEnter actions run only when a reader scrolls back to it, so
set its starting state in config and the layers themselves (for example
layout: { visibility: none } on a layer a later chapter reveals).
animation: jumpTo where that matters)jumpTo for instant transitions on slower devices---import { Scrollytelling, loadScrollytellingConfig } from '@maplibre-yaml/astro';
const story = await loadScrollytellingConfig('./src/stories/earthquakes.yaml');---<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Earthquake History Story</title> </head> <body> <Scrollytelling config={story} /> </body></html>
<style is:global> body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }</style>For sites with multiple maps, define site-wide defaults (basemap style, center, zoom) once and inherit them everywhere. This eliminates repetition and ensures consistency.
Create a global config YAML file
title: "My Map Application"defaultMapStyle: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"defaultCenter: [-73.985, 40.674]defaultZoom: 14theme: lightCreate a shared loader module
import { loadGlobalMapConfig } from "@maplibre-yaml/astro";
export const globalMapConfig = await loadGlobalMapConfig( "./src/config/maps.yaml");Top-level await is supported in Astro modules during the build phase.
Use in pages with map builders
---import { Map, buildPointMapConfig } from "@maplibre-yaml/astro";import { globalMapConfig } from "../lib/map-config";
const mapConfig = buildPointMapConfig( { location: { coordinates: [-73.983, 40.676], name: "Project Site", }, }, globalMapConfig, // mapStyle and zoom inherited as fallbacks);---<Map config={mapConfig} height="400px" />All map builders (buildPointMapConfig, buildMultiPointMapConfig, buildPolygonMapConfig, buildMultiPolygonMapConfig, buildRouteMapConfig, buildMultiLineStringMapConfig) accept globalConfig as their second argument. Values explicitly set in the builder options take priority; global defaults fill in anything omitted. The center always comes from the geometry, so defaultCenter matters only when you build a map without one (as the fallback below does).
| Field | Type | Description |
|---|---|---|
title | string? | Application title |
description | string? | Application description |
defaultMapStyle | string (URL)? | Default basemap style for all maps |
defaultCenter | [lng, lat]? | Default map center |
defaultZoom | number (0-24)? | Default zoom level |
theme | "light" | "dark" | Default theme (default: "light") |
You can add map support to any existing Astro content collection by importing the geographic schemas. This works for points, polygons, and lines — all optional, so items without geographic data still work. For a blog-style walkthrough with ready-made getCollectionItemWith*Schema helpers, see the Collection Integration guide.
import { defineCollection } from "astro:content";import { z } from "astro/zod";import { glob } from "astro/loaders";import { LocationPointSchema, RegionPolygonSchema, RouteLineSchema,} from "@maplibre-yaml/astro/utils"; // /utils subpath -- see "Use the /utils subpath" note above
const projects = defineCollection({ loader: glob({ pattern: "**/*.md", base: "./src/collections/projects" }), schema: z.object({ title: z.string(), status: z.string(), summary: z.string().optional(), // Geographic data — all optional location: LocationPointSchema.optional(), // point region: RegionPolygonSchema.optional(), // polygon route: RouteLineSchema.optional(), // line }),});
export const collections = { projects };---title: "New Library Branch"status: "In Progress"location: coordinates: [-73.983, 40.676] name: "487 4th Avenue" description: "Proposed 3-story library building" zoom: 16 markerColor: "#e74c3c"------title: "Park Renovation"status: "Planning"region: name: "Greene Park" description: "Park boundary" coordinates: - - [-73.9848, 40.6748] - [-73.9833, 40.6748] - [-73.9833, 40.6738] - [-73.9848, 40.6738] - [-73.9848, 40.6748] fillColor: "#2ecc71" fillOpacity: 0.35------title: "Canal Infrastructure"status: "Active"route: name: "Canal Path" description: "Stormwater infrastructure route" coordinates: - [-73.9890, 40.6790] - [-73.9870, 40.6760] - [-73.9855, 40.6730] - [-73.9840, 40.6700] color: "#3498db" width: 4---Use buildMapConfigFromEntry in your dynamic route page ([slug].astro). The helper inspects the entry data, dispatches to the appropriate builder by geometry type, and falls back to a supplied default if no geometry is set.
---import { getCollection } from "astro:content";import { Map, buildMapConfigFromEntry } from "@maplibre-yaml/astro";import { globalMapConfig } from "../../lib/map-config";
export async function getStaticPaths() { const entries = await getCollection("projects"); return entries.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));}
const { entry } = Astro.props;const mapConfig = await buildMapConfigFromEntry(entry.data, globalMapConfig, { label: entry.data.title, description: entry.data.summary, fallback: { coordinates: globalMapConfig.defaultCenter!, name: entry.data.title, },});---<Map config={mapConfig} height="300px" />The helper recognizes these fields in this precedence order:
feature_ref — external GeoJSON file referenceregion — inline polygonroute — inline linelocations — inline multi-pointlocation — inline single pointoptions.fallback — supplied defaultThe first set field wins; later fields are ignored. The helper is always async because feature_ref requires file I/O at build time.
If you need to handle each geometry type explicitly (e.g., custom popup or styling per type), call the builders directly:
---import { Map, buildPointMapConfig, buildPolygonMapConfig, buildRouteMapConfig,} from "@maplibre-yaml/astro";import type { MapBlock } from "@maplibre-yaml/core";import { globalMapConfig } from "../../lib/map-config";
// ...same getStaticPaths as above...
const { entry } = Astro.props;const data = entry.data;
let mapConfig: MapBlock;
if (data.region) { mapConfig = buildPolygonMapConfig({ region: data.region }, globalMapConfig);} else if (data.route) { mapConfig = buildRouteMapConfig({ route: data.route }, globalMapConfig);} else if (data.location) { mapConfig = buildPointMapConfig({ location: data.location }, globalMapConfig);} else { mapConfig = buildPointMapConfig( { location: { coordinates: globalMapConfig.defaultCenter! } }, globalMapConfig, );}---<Map config={mapConfig} height="300px" />When multiple collection items share geometry from a single source-of-truth GeoJSON file, copying coordinates into each frontmatter is fragile and verbose. The feature_ref field lets a collection item point at a feature in an external GeoJSON file (matched by featureId or by property equality). At build time, the loader reads the file, finds the matching feature, detects its geometry type, and dispatches to the existing point/polygon/route builders.
Add feature_ref to your collection schema
import { defineCollection } from "astro:content";import { z } from "astro/zod";import { glob } from "astro/loaders";import { FeatureRefSchema, LocationPointSchema, RegionPolygonSchema, RouteLineSchema,} from "@maplibre-yaml/astro/utils";
export const collections = { poas: defineCollection({ loader: glob({ pattern: "**/*.md", base: "./src/collections/poas" }), schema: z.object({ title: z.string(), gotf_id: z.number(), // Either feature_ref OR inline geometry -- not both feature_ref: FeatureRefSchema.optional(), location: LocationPointSchema.optional(), region: RegionPolygonSchema.optional(), route: RouteLineSchema.optional(), }), }),};For automatic mutual-exclusivity validation, use getCollectionItemWithFeatureRefSchema() instead. It rejects items declaring both feature_ref and inline geometry at content-collection-parse time.
Reference features from frontmatter
---title: "Gowanus Rezoning Area"gotf_id: 1.1feature_ref: source: "./src/data/gowanus.geojson" featureId: "poa-1.1"------title: "Gowanus Rezoning Area"gotf_id: 1.1feature_ref: source: "./src/data/gowanus.geojson" match: property: "gotf_id" equals: 1.1 fillColor: "#3388ff" fillOpacity: 0.25---Build the map in your dynamic page
The simplest path is buildMapConfigFromEntry — it inspects the entry data, dispatches by geometry type (including feature_ref), and produces a MapBlock:
---import { getCollection } from "astro:content";import { Map, buildMapConfigFromEntry } from "@maplibre-yaml/astro";import { globalMapConfig } from "../../lib/map-config";
export async function getStaticPaths() { const entries = await getCollection("poas"); return entries.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));}
const { entry } = Astro.props;const mapConfig = await buildMapConfigFromEntry(entry.data, globalMapConfig, { label: entry.data.title, fallback: { coordinates: globalMapConfig.defaultCenter! },});---<Map config={mapConfig} height="300px" />If you need custom per-type behavior, call the underlying builders directly (buildFeatureMapConfig, buildPolygonMapConfig, etc.) instead. See “Manual dispatch” above.
Two ways to identify which feature to use:
| Form | When to use |
|---|---|
featureId: <string | number> | When your GeoJSON features have stable top-level id fields (RFC 7946) |
match: { property, equals } | When IDs live in feature.properties (common — mirrors MapLibre’s promoteId) |
Exactly one match must be returned. Zero matches or multiple matches throw GeoJSONLoadError with details (file path, total feature count, sample IDs/values).
Frontmatter can override feature.properties.name and feature.properties.description, plus all style fields (markerColor, fillColor, strokeColor, fillOpacity, color, width, zoom). Overrides take priority; feature.properties are the fallback.
| Type | Builder dispatched |
|---|---|
Point | buildPointMapConfig |
MultiPoint | buildMultiPointMapConfig |
LineString | buildRouteMapConfig |
MultiLineString | buildMultiLineStringMapConfig (renders all segments) |
Polygon | buildPolygonMapConfig |
MultiPolygon | buildMultiPolygonMapConfig (renders all polygons) |
GeometryCollection (single member) | dispatches to the inner geometry type |
GeometryCollection (multiple members) | throws GeoJSONLoadError — split into separate features |
The source value is resolved against process.cwd() by default. Two settings on FeatureLoadOptions adjust this:
import { buildFeatureMapConfig, type FeatureLoadOptions,} from "@maplibre-yaml/astro";
const loadOptions: FeatureLoadOptions = { // Use a custom project root for monorepo builds where cwd != Astro project dir. // Canonicalized via realpath so macOS `/var` -> `/private/var` doesn't trip // the containment check. projectRoot: "/absolute/path/to/astro/project",
// Opt in to absolute `source` paths. Default: false. // Required for tests using tmpdir, monorepo data outside the project root, // or any controlled call site. Do NOT enable when frontmatter values can // come from untrusted content (CMS-supplied entries, user uploads). allowAbsolutePaths: true,};
await buildFeatureMapConfig({ ref, loadOptions }, globalConfig);The same loadOptions flows through buildMapConfigFromEntry and loadFeatureFile.
The builder throws one of two error classes:
import { buildFeatureMapConfig, GeoJSONLoadError, InvalidFeatureRefError,} from "@maplibre-yaml/astro";
try { await buildFeatureMapConfig({ ref }, globalConfig);} catch (err) { if (err instanceof InvalidFeatureRefError) { // Thrown when the ref has neither or both of `featureId` and `match`. // Discriminable via instanceof (not wrapped by buildFeatureMapConfig). } else if (err instanceof GeoJSONLoadError) { // Thrown for everything else: missing file/feature, malformed JSON, // unsupported geometry, path-traversal violation, absolute-path // rejection, file too large, degenerate LineString, heterogeneous // GeometryCollection. `.message` carries the actionable message; // `.filePath` carries the resolved path; ES2022 `.cause` carries the // underlying fs/parse error when applicable. }}buildFeatureMapConfig reads from the local filesystem via process.cwd() (or the configured projectRoot). It is build-time only — invoking it in a deployed SSR adapter context throws a GeoJSONLoadError whose ENOENT message includes deployment-context hints. Runtime resolution is planned for V2.
| File size | Behavior |
|---|---|
| < 10MB | Fully supported, no caveats |
| 10-50MB | Supported; one-time parse cost |
| 50-100MB | Build-time warning |
| > 100MB | Hard error directing toward PMTiles or splitting |
Files referenced multiple times across collection items are parsed once via an mtime-aware cache. Editing the GeoJSON in astro dev invalidates the cache on next page render — no server restart needed. For files with 200+ features, a per-property index is built lazily on the second access for that property.
FeatureRef accepts every style field (markerColor, fillColor, strokeColor, fillOpacity, color, width) because the geometry type is not known when the schema parses — it’s resolved when the file loads. When a field doesn’t apply to the resolved geometry (e.g., markerColor on a Polygon ref), the builder ignores it and emits a one-time console.warn during astro dev:
feature_ref: style override(s) [markerColor, width] do not apply to Polygon geometry from "./src/data/sample.geojson"; they will be ignored.Warnings are deduplicated per (source, geometryType, ignored-keys) so reloading the page won’t re-emit them. Restarting astro dev clears the dedupe cache.
For advanced cases (e.g., loading one file once and dispatching to many features manually):
import { loadFeatureFile, findFeature, GeoJSONLoadError, clearFeatureCache,} from "@maplibre-yaml/astro";
const fc = await loadFeatureFile("./src/data/gowanus.geojson");const feature = findFeature(fc, { source: "...", featureId: "poa-1.1" });// Then dispatch manually with the existing sync buildersclearFeatureCache() is exported for test isolation between files that share absolute paths.
These patterns wrap <ml-map> yourself and need only @maplibre-yaml/core.
The simplest pattern - let the web component handle everything. Place YAML files in the /public directory and reference them with the src attribute.
File structure:
public/├── configs/│ ├── hero-map.yaml│ ├── contact-map.yaml│ └── locations-map.yamlsrc/├── components/│ └── Map.astro└── pages/ └── index.astroMap.astro:
---interface Props { src: string; height?: string;}
const { src, height = '400px' } = Astro.props;---
<ml-map src={src} style={`height: ${height}; display: block;`} />
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css';</script>Usage:
<Map src="/configs/hero-map.yaml" /><Map src="/configs/contact-map.yaml" height="300px" />Benefits:
For projects that want build-time validation, you can parse YAML during the build:
File structure:
src/├── components/│ └── Map.astro├── configs/│ ├── hero-map.yaml│ ├── contact-map.yaml│ └── locations-map.yaml└── pages/ └── index.astroMap.astro:
---import { YAMLParser } from '@maplibre-yaml/core';
interface Props { configPath: string; height?: string;}
const { configPath, height = '400px' } = Astro.props;
// Import all YAML files at build timeconst yamlFiles = import.meta.glob<string>('/src/configs/**/*.yaml', { query: '?raw', import: 'default', eager: true});
const yamlContent = yamlFiles[configPath];
if (!yamlContent) { const available = Object.keys(yamlFiles).join(', '); throw new Error(`Config not found: ${configPath}. Available: ${available}`);}
const result = YAMLParser.safeParseMapBlock(yamlContent);
if (!result.success) { console.error('YAML parse errors:', result.errors); throw new Error(`Invalid config ${configPath}: ${result.errors[0].message}`);}
const configJson = JSON.stringify(result.data);---
<ml-map style={`height: ${height}; display: block;`} config={configJson} />
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css';</script>Usage:
<Map configPath="/src/configs/hero-map.yaml" /><Map configPath="/src/configs/contact-map.yaml" height="300px" />Benefits:
Tradeoffs:
For simple, one-off maps, you can inline configuration. However, this approach has significant drawbacks and is generally not recommended.
InlineMap.astro:
---import { YAMLParser } from '@maplibre-yaml/core';
interface Props { yaml: string; height?: string;}
const { yaml, height = '400px' } = Astro.props;
const result = YAMLParser.safeParseMapBlock(yaml);
if (!result.success) { console.error('Parse errors:', result.errors);}
const configJson = result.success ? JSON.stringify(result.data) : null;const error = result.success ? null : result.errors;---
{error ? ( <div class="map-error"> <strong>Configuration Error</strong> <pre>{JSON.stringify(error, null, 2)}</pre> </div>) : ( <ml-map style={`height: ${height}; display: block;`} config={configJson} />)}
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css';</script>
<style> .map-error { padding: 20px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; color: #dc2626; } .map-error pre { margin-top: 10px; font-size: 12px; overflow-x: auto; }</style>Usage in page:
---import InlineMap from '../components/InlineMap.astro';
// ⚠️ MUST start content immediately after backtickconst simpleMapYaml = `type: mapid: simple-mapconfig: center: [-74.006, 40.7128] zoom: 12 mapStyle: "https://demotiles.maplibre.org/style.json"layers: []`;---
<InlineMap yaml={simpleMapYaml} height="300px" />In MDX files (like documentation), use the src attribute pattern for the cleanest approach. MDX pages need the @astrojs/mdx integration (npx astro add mdx); Starlight already includes it.
guide.mdx:
---title: Map Guide---
import Map from "../../components/Map.astro";
## Live Earthquake Data
Here's a map showing recent earthquakes:
<Map src="/configs/earthquake-map.yaml" height="400px" />
The data loads from the USGS feed when the page opens.public/configs/earthquake-map.yaml:
type: mapid: earthquake-democonfig: center: [-120, 37] zoom: 4 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: quakes type: circle source: type: geojson url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson" paint: circle-radius: 6 circle-color: "#ef4444"A full-featured component with events and controls using the src pattern.
See Web Components for the full event and
method list. mapReady() is new in @maplibre-yaml/core 0.7. On 0.6, wait
for ml-map:load and call getMap().
InteractiveMap.astro:
---interface Props { src: string; height?: string; showControls?: boolean; onLayerClick?: string; // JavaScript function name to call}
const { src, height = '400px', showControls = false, onLayerClick} = Astro.props;
const mapId = `map-${Math.random().toString(36).slice(2, 9)}`;---
<div class="interactive-map" id={mapId}> {showControls && ( <div class="map-controls"> <button class="control-btn" data-action="zoom-in" title="Zoom In">+</button> <button class="control-btn" data-action="zoom-out" title="Zoom Out">−</button> <button class="control-btn" data-action="reset" title="Reset View">⌂</button> </div> )}
<ml-map src={src} style={`height: ${height}; display: block;`} data-click-handler={onLayerClick} />
<div class="map-status"></div></div>
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { MLMap } from '@maplibre-yaml/core/components';
// Initialize all interactive maps document.querySelectorAll<HTMLElement>('.interactive-map').forEach(container => { const mapEl = container.querySelector<MLMap>('ml-map'); const statusEl = container.querySelector<HTMLElement>('.map-status'); const controls = container.querySelector<HTMLElement>('.map-controls');
if (!mapEl || !statusEl) return;
let initialCenter: [number, number] | null = null; let initialZoom: number | null = null;
// Status updates. A layer's first `layer-data-loaded` can fire before // `ml-map:load`, so `load` must not overwrite a feature count. mapEl.addEventListener('ml-map:load', () => { const map = mapEl.getMap(); if (!map) return; initialCenter = map.getCenter().toArray() as [number, number]; initialZoom = map.getZoom(); if (!statusEl.textContent) statusEl.textContent = 'Map ready'; statusEl.className = 'map-status ready'; });
mapEl.addEventListener('ml-map:layer-data-loading', (e) => { const { layerId } = (e as CustomEvent<{ layerId: string }>).detail; statusEl.textContent = `Loading ${layerId}...`; statusEl.className = 'map-status loading'; });
mapEl.addEventListener('ml-map:layer-data-loaded', (e) => { const { featureCount } = (e as CustomEvent<{ featureCount: number }>).detail; statusEl.textContent = `${featureCount} features loaded`; statusEl.className = 'map-status ready'; });
mapEl.addEventListener('ml-map:error', (e) => { // `{ error }` for a load/runtime failure, `{ errors }` for a document // that failed to parse or validate. const detail = (e as CustomEvent<{ error?: Error; errors?: { message: string }[] }>).detail; const message = detail.error?.message ?? detail.errors?.map((err) => err.message).join('; '); statusEl.textContent = `Error: ${message}`; statusEl.className = 'map-status error'; });
// Custom click handler const clickHandler = mapEl.dataset.clickHandler; const handler = clickHandler ? (window as any)[clickHandler] : undefined; if (typeof handler === 'function') { mapEl.addEventListener('ml-map:layer-click', (e) => { handler((e as CustomEvent).detail); }); }
// Control buttons — mapReady() resolves with the live map (immediately // once loaded), so an early click waits instead of silently no-oping. if (controls) { controls.addEventListener('click', async (e) => { const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]'); if (!btn) return;
const map = await mapEl.mapReady();
const action = btn.dataset.action;
switch (action) { case 'zoom-in': map.zoomIn(); break; case 'zoom-out': map.zoomOut(); break; case 'reset': if (initialCenter && initialZoom !== null) { map.flyTo({ center: initialCenter, zoom: initialZoom }); } break; } }); } });</script>
<style> .interactive-map { position: relative; border-radius: 8px; overflow: hidden; border: 1px solid #e5e7eb; }
.map-controls { position: absolute; top: 10px; right: 10px; z-index: 10; display: flex; flex-direction: column; gap: 4px; }
.control-btn { width: 32px; height: 32px; border: none; background: white; border-radius: 4px; font-size: 18px; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; }
.control-btn:hover { background: #f3f4f6; }
.map-status { position: absolute; bottom: 10px; left: 10px; padding: 4px 8px; background: rgba(255,255,255,0.9); border-radius: 4px; font-size: 12px; z-index: 10; }
.map-status.loading { color: #d97706; } .map-status.ready { color: #059669; } .map-status.error { color: #dc2626; }</style>For sites with many maps that need build-time validation, you can use Astro Content Collections. Note that this is more complex than the recommended src attribute pattern:
src/content.config.ts:
import { defineCollection } from "astro:content";import { glob } from "astro/loaders";import { z } from "astro/zod";
const maps = defineCollection({ loader: glob({ pattern: "**/*.yaml", base: "./src/collections/maps" }), schema: z.object({ type: z.literal("map"), id: z.string(), config: z.object({ center: z.tuple([z.number(), z.number()]), zoom: z.number(), mapStyle: z.string(), pitch: z.number().optional(), bearing: z.number().optional(), }), layers: z.array(z.any()), }),});
export const collections = { maps };src/collections/maps/headquarters.yaml:
type: mapid: headquartersconfig: center: [-122.4194, 37.7749] zoom: 15 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: office type: circle source: type: geojson data: type: Feature geometry: type: Point coordinates: [-122.4194, 37.7749] properties: name: "Headquarters" paint: circle-radius: 12 circle-color: "#3b82f6"CollectionMap.astro:
---import { getEntry } from 'astro:content';
interface Props { slug: string; height?: string;}
const { slug, height = '400px' } = Astro.props;
const mapEntry = await getEntry('maps', slug);
if (!mapEntry) { throw new Error(`Map not found: ${slug}`);}
// Content collection already validates against schemaconst configJson = JSON.stringify(mapEntry.data);---
<ml-map style={`height: ${height}; display: block;`} config={configJson} />
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css';</script>Usage:
<CollectionMap slug="headquarters" height="500px" />ReferenceError: HTMLElement is not definedCause: Importing @maplibre-yaml/core/register in an Astro component’s frontmatter (--- block). The register module defines class MLMap extends HTMLElement, which crashes during Astro’s server-side rendering because HTMLElement is a browser-only API.
Wrong:
---import "@maplibre-yaml/core/register"; // ❌ Crashes during SSR---<ml-map src="/map.yaml" style="height: 400px; display: block;" />Correct:
<ml-map src="/map.yaml" style="height: 400px; display: block;" />
<script> import "@maplibre-yaml/core/register"; // ✅ Runs client-side only import "maplibre-gl/dist/maplibre-gl.css";</script>Astro processes non-is:inline <script> tags as client-side bundles — they never run during SSR. The @maplibre-yaml/astro components (Map, FullPageMap, Scrollytelling) handle this automatically.
client:only or client:load on Astro ComponentsSymptoms: astro build fails while bundling the package for the browser,
for example "readFile" is not exported by "__vite-browser-external" (from
@maplibre-yaml/astro/src/utils/loader.ts), or the component doesn’t render.
Cause: Using client directives (client:only="react", client:load, etc.) on components from @maplibre-yaml/astro. These are Astro components, not framework components (React/Vue/Svelte), so client directives are invalid.
Wrong:
<Map client:only="react" src="/map.yaml" height="400px" /> <!-- ❌ -->Correct:
<Map src="/map.yaml" height="400px" /> <!-- ✅ -->src Path for Public FilesSymptoms: 404 error or Failed to fetch in the console.
Cause: Using a filesystem path instead of a URL path for the src prop.
Wrong:
<Map src="public/configs/map.yaml" height="400px" /> <!-- ❌ -->Correct:
<Map src="/configs/map.yaml" height="400px" /> <!-- ✅ -->Files in Astro’s /public directory are served from the root URL, so public/configs/map.yaml is accessed as /configs/map.yaml.
Cause: YAML indentation was destroyed by template literal processing, or the file couldn’t be loaded.
Solutions:
Use the src attribute pattern (recommended)
<Map src="/configs/my-map.yaml" />Place your YAML file in public/configs/my-map.yaml
Check browser console for loading errors
The web component will log errors if it can’t fetch or parse the YAML file.
Verify file path is correct
Files in /public are served from the root, so /public/configs/map.yaml becomes src="/configs/map.yaml"
Symptoms: Nothing visible, or map is 0px tall.
Cause: CSS height not set properly.
Solution:
<ml-map src="/configs/map.yaml" style="height: 400px; display: block;" />Both height and display: block are required.
Symptoms: The map shows Failed to fetch /configs/map.yaml: 404 Not Found, or a network 404 appears in the console
Cause: Incorrect path or file not in public directory.
Solutions:
/public directory (not /src)src="/configs/map.yaml" for /public/configs/map.yamlSymptoms: Changes to YAML files in /public don’t appear immediately.
Cause: Browser caching or dev server needs refresh.
Workaround:
/public are served statically, so they update immediately without rebuildimport.meta.globSolution: Astro’s generated types already declare import.meta.glob. Tell
it what a raw import yields with the type argument:
const files = import.meta.glob<string>('/src/configs/*.yaml', { query: '?raw', import: 'default',});as: 'raw' still works but is deprecated: Vite warns on every build.
Symptoms: <ml-map> renders as unknown element.
Cause: Registration script didn’t run.
Solution: Ensure script is in component:
<script> import '@maplibre-yaml/core/register'; import 'maplibre-gl/dist/maplibre-gl.css';</script>The script must be inside the component, not in the frontmatter. On Astro 4,
also check that the page has a <head> (see the Astro 4 caution under
Component Patterns).
Use src Attribute (Recommended)
Place YAML files in /public/configs/ and use <ml-map src="/configs/map.yaml">. Simplest and most maintainable approach.
Set Explicit Dimensions
Always set height and display: block on <ml-map> elements.
Keep Config Separate
Avoid inline YAML in components. External files are easier to edit and maintain.
Use Build-Time Validation (Optional)
For critical applications, parse YAML at build time (Pattern 2) to catch errors early.
<ml-map>@maplibre-yaml/astro README - Every export of the package and its /utils subpath