Skip to content

Astro Integration

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.

  1. Install dependencies

    Terminal window
    pnpm add @maplibre-yaml/core maplibre-gl@^6

    maplibre-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.

  2. Create a map configuration file

    Create public/configs/hero-map.yaml:

    type: map
    id: hero-map
    config:
    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"
  3. 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>
  4. Use in a page

    src/pages/index.astro
    ---
    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:

  • Native Astro Components - Designed specifically for Astro’s architecture
  • Build-Time Loading - Load and validate YAML at build time for better performance
  • Runtime Loading - Or load from /public for dynamic configs
  • TypeScript Support - Full type safety with MapBlock types
  • Error Handling - Beautiful error messages with validation details
  • Content Collections - Schema helpers for Astro Content Collections
Terminal window
pnpm add @maplibre-yaml/astro @maplibre-yaml/core maplibre-gl@^6
AstroSupportedNotes
7Yes, tested in CINeeds Node 22.12 or newer (Astro’s own requirement).
6Yes, tested in CIBuild collection schemas with z from astro/zod (see below).
5Yes, tested in CI
4Yes, tested in CIContent-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:

  • Turn on experimental: { contentLayer: true } in astro.config.mjs.
  • Keep the config in 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.
  • Point 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.

PageShows
/<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
/volcanoesA Markdown collection (glob() + LocationPointSchema), one page per entry via buildMapConfigFromEntry
/galleryA 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:

examples/astro/site/src/content.config.ts
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:

src/pages/index.astro
---
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: map
id: my-map
config:
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.

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:

examples/astro/site/src/pages/index.astro
<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 &rarr;</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:

src/content.config.ts
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:

src/collections/maps/earthquake-map.yaml
type: map
id: earthquake-map
config:
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:

  • YAML Syntax Errors: Shows parsing error with details
  • Validation Errors: Lists all validation failures with paths (for example config.zoom: Expected number, got string)
  • Network Errors: Shows fetch failure messages (for example 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:

src/content.config.ts
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)
})
})
};
src/collections/maps/featured-map.yaml
author: "Jane Doe"
publishDate: 2024-01-15
tags: ["earthquakes", "geoscience"]
featured: true
type: map
id: featured-map
config:
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: map
id: neighborhood-map
config:
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: map
id: sugar-demo
config:
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.

  • Format v2 — the style/runtime split, key-by-key, with the v1 → v2 relocation table.
  • Interactions & Export to style.json — declarative interactions, the export guarantee, and how 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:

  • Astro !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.”
  • Malformed inline GeoJSON now warns. A v1 document with malformed inline GeoJSON data emits a warning (promoted to an error under mlym validate --strict / CI). Pre-check inline geometry.
  • v2 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.

  • Full Viewport: Fixed positioning that fills the entire viewport (100vw × 100vh)
  • Built-in Controls: Zoom in/out and reset view buttons, placed in the map’s top-right corner through <ml-map>’s slots
  • Optional Legend: the core legend, with configurable positioning
  • Same Loading Options: Supports both src (runtime) and config (build-time) loading
  • Responsive Design: Optimized layouts for mobile and desktop
---
import { FullPageMap } from '@maplibre-yaml/astro';
---
<FullPageMap src="/configs/world-map.yaml" />
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"
/>

Build-time loaded with complete configuration

Section titled “Build-time loaded with complete configuration”
---
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:

  • Zoom In (+): Increases zoom level by 1
  • Zoom Out (−): Decreases zoom level by 1
  • Reset View (↻): Returns to the document’s initial center, zoom, bearing, and pitch (re-captured on every load)

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.

  • 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
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"
/>
  • 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)

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)

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)

This is typically managed by the parent Scrollytelling component based on scroll position.

  • Semantic <section> with role="region"
  • Unique aria-labelledby for each chapter
  • Lazy-loaded images with alt text
  • Videos with controls and fallback text
  • High contrast mode support
  • Reduced motion support

The 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.

  • 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: Visual navigation dots for quick chapter access
  • Build-time Loading: Load and validate the story with loadScrollytellingConfig
  • Debug Mode: Visual indicators to help during development
  • Accessible: Full keyboard navigation and screen reader support
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 - 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:

examples/astro/site/src/pages/story-runtime.astro
<Scrollytelling src="/stories/cascadia.yaml" />

A complete scrollytelling story includes base map configuration, layers, and chapters:

type: scrollytelling
id: earthquake-story
theme: dark # 'light' or 'dark'
showMarkers: true # Show chapter navigation dots
markerColor: "#3FB1CE" # Custom marker color
# Base map configuration
config:
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 chapters
chapters:
- 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>&copy; 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 active
  • spinGlobe: Keep panning westward while the chapter is active

Content:

  • 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 transitions

Interactivity:

  • layers.show: Array of layer IDs to make visible
  • layers.hide: Array of layer IDs to hide
  • onChapterEnter: Actions to execute when entering chapter
  • onChapterExit: Actions to execute when leaving chapter
  • callback: A name passed in the ml-scrollytelling:chapter event, for your page script to dispatch on

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: null

Available Actions:

ActionFieldsEffect
setFilterlayer, filter (null clears)Filter features in a layer
setPaintPropertylayer, property, valueChange a paint property
setLayoutPropertylayer, property, valueChange a layout property
flyTo / easeTooptions (center, zoom, bearing, pitch, duration, …)Move the camera, overriding the chapter’s own camera
fitBoundsbounds: [west, south, east, north], options (padding, duration, …)Frame an area
customanything in optionsRuns 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:

examples/astro/site/src/pages/story.astro
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:

  • Click markers to jump to specific chapters
  • Active marker is highlighted
  • Customizable color via markerColor
  • Accessible with keyboard navigation
  • Automatically positioned and scaled

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

Enable debug prop to see:

  • Red dashed outlines around inactive chapters
  • Green outlines around the active chapter
  • Helpful for fine-tuning scroll trigger points
<Scrollytelling config={story} debug />

The component uses IntersectionObserver with these settings:

  • Trigger zone: Middle 20% of viewport (40% margin top/bottom)
  • Activates when chapter enters the trigger zone
  • Smooth map transitions between chapters
  • Progressive chapter content fade in/out

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).

  • Semantic HTML with proper ARIA labels
  • Keyboard navigation for chapter markers
  • Screen reader friendly chapter titles
  • Chapter cards respect reduced-motion preferences (the map’s camera moves do not; use animation: jumpTo where that matters)
  • High contrast mode support
  1. Use build-time loading for better initial page load
  2. Optimize images: Compress chapter images and use appropriate sizes
  3. Limit chapters: 5-10 chapters for optimal scrolling experience
  4. Simplify transitions: Use jumpTo for instant transitions on slower devices
  5. Lazy load media: Images and videos load only when needed
src/pages/earthquake-story.astro
---
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.

  1. Create a global config YAML file

    src/config/maps.yaml
    title: "My Map Application"
    defaultMapStyle: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
    defaultCenter: [-73.985, 40.674]
    defaultZoom: 14
    theme: light
  2. Create a shared loader module

    src/lib/map-config.ts
    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.

  3. 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).

FieldTypeDescription
titlestring?Application title
descriptionstring?Application description
defaultMapStylestring (URL)?Default basemap style for all maps
defaultCenter[lng, lat]?Default map center
defaultZoomnumber (0-24)?Default zoom level
theme"light" | "dark"Default theme (default: "light")

Adding Geographic Data to Content Collections

Section titled “Adding Geographic Data to Content Collections”

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.

src/content.config.ts
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"
---

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.

src/pages/projects/[slug].astro
---
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:

  1. feature_ref — external GeoJSON file reference
  2. region — inline polygon
  3. route — inline line
  4. locations — inline multi-point
  5. location — inline single point
  6. options.fallback — supplied default

The first set field wins; later fields are ignored. The helper is always async because feature_ref requires file I/O at build time.

Manual dispatch (when you need custom per-type logic)

Section titled “Manual dispatch (when you need custom per-type logic)”

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.

  1. Add feature_ref to your collection schema

    src/content.config.ts
    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.

  2. Reference features from frontmatter

    ---
    title: "Gowanus Rezoning Area"
    gotf_id: 1.1
    feature_ref:
    source: "./src/data/gowanus.geojson"
    featureId: "poa-1.1"
    ---
  3. 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:

    src/pages/poas/[slug].astro
    ---
    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:

FormWhen 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.

TypeBuilder dispatched
PointbuildPointMapConfig
MultiPointbuildMultiPointMapConfig
LineStringbuildRouteMapConfig
MultiLineStringbuildMultiLineStringMapConfig (renders all segments)
PolygonbuildPolygonMapConfig
MultiPolygonbuildMultiPolygonMapConfig (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 sizeBehavior
< 10MBFully supported, no caveats
10-50MBSupported; one-time parse cost
50-100MBBuild-time warning
> 100MBHard 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 builders

clearFeatureCache() is exported for test isolation between files that share absolute paths.

These patterns wrap <ml-map> yourself and need only @maplibre-yaml/core.

Section titled “Pattern 1: External YAML with src Attribute (Recommended)”

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.yaml
src/
├── components/
│ └── Map.astro
└── pages/
└── index.astro

Map.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:

  • ✅ Simplest possible implementation
  • ✅ No build-time parsing needed
  • ✅ YAML files are easy to edit
  • ✅ Clear separation of configuration and presentation
  • ✅ Works with any static file server
  • ✅ No template literal whitespace issues

Pattern 2: External YAML with Build-Time Parsing

Section titled “Pattern 2: External YAML with Build-Time Parsing”

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.astro

Map.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 time
const 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:

  • ✅ Build-time parsing catches errors early
  • ✅ YAML validation during development
  • ✅ Configuration bundled with the build

Tradeoffs:

  • ⚠️ More complex component code
  • ⚠️ Requires rebuild to see YAML changes
Section titled “Pattern 3: Inline Configuration (Not Recommended)”

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 backtick
const simpleMapYaml = `type: map
id: simple-map
config:
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: map
id: earthquake-demo
config:
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>

Content Collections Integration (Optional/Advanced)

Section titled “Content Collections Integration (Optional/Advanced)”

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: map
id: headquarters
config:
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 schema
const 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 defined

Section titled “ReferenceError: HTMLElement is not defined”

Cause: 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.

Using client:only or client:load on Astro Components

Section titled “Using client:only or client:load on Astro Components”

Symptoms: 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" /> <!-- ✅ -->

Symptoms: 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.

”Expected object, got null” or Similar Schema Errors

Section titled “”Expected object, got null” or Similar Schema Errors”

Cause: YAML indentation was destroyed by template literal processing, or the file couldn’t be loaded.

Solutions:

  1. Use the src attribute pattern (recommended)

    <Map src="/configs/my-map.yaml" />

    Place your YAML file in public/configs/my-map.yaml

  2. Check browser console for loading errors

    The web component will log errors if it can’t fetch or parse the YAML file.

  3. 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:

  1. Ensure file is in /public directory (not /src)
  2. Use root-relative paths: src="/configs/map.yaml" for /public/configs/map.yaml
  3. Check browser network tab for 404 errors
  4. Verify file name and extension are correct

Symptoms: Changes to YAML files in /public don’t appear immediately.

Cause: Browser caching or dev server needs refresh.

Workaround:

  • Hard refresh the browser (Cmd+Shift+R or Ctrl+Shift+R)
  • Or restart the dev server
  • Note: Files in /public are served statically, so they update immediately without rebuild

Solution: 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.