Skip to content

Fly to a Location Based on Scroll Position ​

Trigger map camera animations based on scroll position — useful for narrative maps, story maps, and scroll-driven tours.

New York

The city that never sleeps. Located at the mouth of the Hudson River.

Paris

The City of Light, home to the Eiffel Tower and world-class cuisine.

Tokyo

Japan's bustling capital, blending ultramodern and traditional culture.

Rio de Janeiro

Famous for Carnival, Christ the Redeemer, and stunning beaches.

Pattern: IntersectionObserver + flyTo ​

The most robust approach uses IntersectionObserver to detect when a story section enters the viewport:

javascript
const steps = document.querySelectorAll('.step');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const { lng, lat, zoom } = entry.target.dataset;
      map.flyTo({
        center: [parseFloat(lng), parseFloat(lat)],
        zoom: parseFloat(zoom),
        speed: 0.8,
      });
    }
  });
}, { threshold: 0.5 }); // fire when 50% of the element is visible

steps.forEach(step => observer.observe(step));

Each story section holds its target location in data-* attributes:

html
<div class="step"
  data-lng="-74.0"
  data-lat="40.7"
  data-zoom="12">
  <h2>New York City</h2>
  <p>Description...</p>
</div>

Pattern: Scroll Event + Progress ​

For finer control using raw scroll position:

javascript
const waypoints = [
  { center: [-74.0, 40.7], zoom: 10 },
  { center: [2.35, 48.85], zoom: 10 },
  { center: [139.7, 35.7], zoom: 10 },
];

window.addEventListener('scroll', () => {
  const scrollFraction = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  const index = Math.min(
    Math.floor(scrollFraction * waypoints.length),
    waypoints.length - 1
  );
  const wp = waypoints[index];
  map.easeTo({ center: wp.center, zoom: wp.zoom, duration: 800 });
});

Disable Map Interaction for Story Maps ​

javascript
const map = new mapmetricsgl.Map({
  // ...
  interactive: false, // disable all user interaction
});

// Or selectively:
map.scrollZoom.disable();
map.dragPan.disable();

Complete Example ​

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <link href="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.css" rel="stylesheet" />
    <script src="https://cdn.mapmetrics-atlas.net/versions/latest/mapmetrics-gl.js"></script>
    <style>
      body { margin: 0; }
      #container { display: flex; }
      #map { position: sticky; top: 0; width: 50%; height: 100vh; }
      #story { width: 50%; padding: 20px; }
      .step { min-height: 80vh; padding: 20px; }
    </style>
  </head>
  <body>
    <div id="container">
      <div id="map"></div>
      <div id="story">
        <div class="step" data-lng="-74" data-lat="40.7" data-zoom="11">
          <h2>New York</h2><p>The Big Apple.</p>
        </div>
        <div class="step" data-lng="2.35" data-lat="48.85" data-zoom="11">
          <h2>Paris</h2><p>City of Light.</p>
        </div>
        <div class="step" data-lng="139.7" data-lat="35.7" data-zoom="11">
          <h2>Tokyo</h2><p>Neon city.</p>
        </div>
      </div>
    </div>
    <script>
      const map = new mapmetricsgl.Map({
        container: 'map',
        style: '<StyleFile_URL_with_Token>',
        center: [0, 20],
        zoom: 1.5,
        interactive: false
      });

      map.on('load', () => {
        const observer = new IntersectionObserver(entries => {
          entries.forEach(e => {
            if (e.isIntersecting) {
              map.flyTo({
                center: [+e.target.dataset.lng, +e.target.dataset.lat],
                zoom: +e.target.dataset.zoom,
                speed: 0.8
              });
            }
          });
        }, { threshold: 0.5 });

        document.querySelectorAll('.step').forEach(s => observer.observe(s));
      });
    </script>
  </body>
</html>
jsx
import React, { useEffect, useRef } from 'react';
import mapmetricsgl from '@mapmetrics/mapmetrics-gl';
import '@mapmetrics/mapmetrics-gl/dist/mapmetrics-gl.css';

const waypoints = [
  { lng: -74.0, lat: 40.7, zoom: 11, title: 'New York', desc: 'The Big Apple.' },
  { lng: 2.35, lat: 48.85, zoom: 11, title: 'Paris', desc: 'City of Light.' },
  { lng: 139.7, lat: 35.7, zoom: 11, title: 'Tokyo', desc: 'Neon city.' },
];

const FlyToOnScroll = () => {
  const mapContainer = useRef(null);
  const map = useRef(null);
  const stepRefs = useRef([]);

  useEffect(() => {
    if (map.current) return;
    map.current = new mapmetricsgl.Map({
      container: mapContainer.current,
      style: '<StyleFile_URL_with_Token>',
      center: [0, 20],
      zoom: 1.5,
      interactive: false
    });

    map.current.on('load', () => {
      const observer = new IntersectionObserver(entries => {
        entries.forEach(e => {
          if (e.isIntersecting) {
            const i = parseInt(e.target.dataset.index);
            const wp = waypoints[i];
            map.current.flyTo({ center: [wp.lng, wp.lat], zoom: wp.zoom, speed: 0.8 });
          }
        });
      }, { threshold: 0.5 });

      stepRefs.current.forEach(s => s && observer.observe(s));
      return () => observer.disconnect();
    });

    return () => { map.current?.remove(); map.current = null; };
  }, []);

  return (
    <div style={{ display: 'flex', gap: 16 }}>
      <div ref={mapContainer} style={{ position: 'sticky', top: 0, width: '50%', height: '100vh' }} />
      <div style={{ flex: 1 }}>
        {waypoints.map((wp, i) => (
          <div
            key={i}
            ref={el => stepRefs.current[i] = el}
            data-index={i}
            style={{ minHeight: '80vh', padding: 20 }}
          >
            <h2>{wp.title}</h2>
            <p>{wp.desc}</p>
          </div>
        ))}
      </div>
    </div>
  );
};

export default FlyToOnScroll;

For more information, visit the MapMetrics GitHub repository.