Scrollytelling with Maps and Charts

Scrollytelling with Maps and Charts

How to build scroll-driven stories that update Leaflet maps and D3 charts as the reader scrolls through the narrative.

Updated 5 min read

Scroll-driven stories pin a visualisation on screen while the reader scrolls through prose. Each “step” in the story can trigger an update — flying a map to a new location, updating chart data, or changing the state of any registered visualisation.

This essay demonstrates two scrolly patterns: a Leaflet map that flies between cities, and a D3 bar chart that updates as the story progresses.


Standalone Leaflet map

Before the scrolly examples, here is a basic Leaflet map with the CartoDB light tile layer. No JavaScript is required in the post — just a data-leaflet element.

The data-markers attribute accepts a JSON array of {lat, lng, label} objects. Clicking a marker opens a popup.

Available tile presets: osm (default OpenStreetMap), carto (CartoDB Positron light), carto-dark, stadia.


Scrolly story: map flyTo

The pattern requires a .story-section containing a .story-sticky (the pinned graphic) and .story-steps (the scrollable prose). Give the viz element an id, then add data-update JSON to each step targeting that id.

London. The Thames cuts through 32 boroughs. At zoom level 11 the full extent of inner London sits comfortably in frame — from Heathrow in the west to the Isle of Dogs in the east.

Paris. Scroll down and the map flies south-east to the French capital. The périphérique ring road defines the boundary of the city proper. Zoom level 12 shows the arrondissement grid.

Berlin. Reunified in 1990, Berlin covers 892 km² — nine times the area of Paris. The Spree and Havel rivers wind through the city. Zoom 12 shows the inner Ringbahn.

Barcelona. Ildefons Cerdà's 1860 Eixample grid is visible at zoom 13 — perfect octagonal blocks, each 113 m per side, designed to equalise light and air for all residents.

Each step’s data-update value is a JSON object keyed by element id. When Scrollama fires story:step for a step, core.js reads the data-update, looks up each referenced element in its instance store, and calls the matching adapter’s update() function. For Leaflet that calls map.flyTo() with a 1.5-second animation.


Scrolly story: D3 bar chart

The same data-update pattern works with D3 charts. The data key replaces the chart’s dataset with a smooth transition.

2010 global electricity generation (%). Gas and coal together supply roughly a third of the world's electricity. Renewables are barely visible at this scale — solar is a rounding error.

2015. Solar and wind have both grown meaningfully. Coal is still rising in absolute terms as global demand grows faster than clean capacity is added. The energy transition is underway but not dominant.

2020. Solar and wind together now rival nuclear. Coal peaks in share terms. The cost curves for solar have crossed every optimistic projection made a decade earlier.

2024. Solar overtakes coal in new capacity additions for the first time. The transition is now an economic story, not just an environmental one — solar is the cheapest electricity source ever built.


How the wiring works

narrative.js
  │  Scrollama fires onStepEnter
  │  → sets data-active on step element
  │  → dispatches story:step(bubbles: true, detail: { element, index, step })
  ▼
core.js  (document-level listener)
  │  reads detail.element.dataset.update  → JSON
  │  for each { id: data } entry:
  │    instances.get(id) → { entry, el, instance }
  │    entry.update(el, data, instance)
  ▼
Adapter update functions
  Leaflet → map.flyTo([lat, lng], zoom, { duration: 1.5 })
  D3      → chart.update(data)  (built-in transition: 600ms)
  ECharts → instance.setOption(data, false)  (merge mode)

Adding update support to a custom D3 chart

When you register a custom chart type, return an update method from the factory:

registerD3Chart('timeline', (el, data, options) => {
  // … render initial state …

  return {
    update(newData) {
      // Apply newData with transitions
    }
  };
});

Then core.js will route data-update JSON to chart.update() automatically.

References