go back

Placeholder for post cover image
Cover image for post

Building a reactive scrolling map article in Svelte 🗺

December 15, 2019

Love it or hate it, Eater has a great interactive map listicle (mapicle? 🤔) format. For lists of places, it makes a complicated reading experience simple and fun. Here's an example if you've never read one.

I wanted to try implementing it in Svelte so I decided to make this tutorial!

Here's the demo in action. I used an article on coffee recommendations in Kyoto from Sprudge for the content, I hope they don't mind. 😇


The page can be broken down into two components: (1) listening to and overriding the scroll position of the text and (2) adjusting the center of the map. More directly, these two user interactions:

Setting things up 🏗

Some basic scaffolding to get things started.

index.html: to render the page

Just your basic HTML file, nothing crazy here.

<!DOCTYPE html>
<html>
<body>
  <script src="./main.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

main.js: to mount our Svelte app

Like most frontend frameworks, Svelte needs to know what DOM element to hook into.

import App from './components/App.svelte';

const app = new App({
  target: document.body
});

export default app;
Enter fullscreen mode Exit fullscreen mode

App.svelte, List.svelte, and Map.svelte: where the logic will go

Note: since this isn't a CSS tutorial, I left out the styles in each of these components to keep the article shorter. Same for some configuration (map setup, etc). You can see the full source on GitHub to fill in the blanks.

Creating the components 👷‍♀️

App.svelte

Sets up the left/right containers and renders components within them.

This is what Svelte components look like. Much like Vue, all of the code associated with a component is contained within one file which makes it simple to encapsulate logic.

<style>
  .container {
    height: 100vh;
    display: flex;
  }

  .pane {
    display: flex;
    flex-direction: column;
    width: 50%;
  }
</style>

<script>
  import List from './List.svelte';
  import Map from './Map.svelte';
</script>

<div class="container">
  <div class="pane left">
    <List />
  </div>
  <div class="pane right">
    <Map />
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

List.svelte 📝

The visual part is simple, just an #each iterator on the list. I included the index to be able to differentiate them when checking visibility. Note the @html tag to render the description, as I want to render the <a> tags properly:

<div id="list-items" bind:this={listRef}>
  {#each listItems as listItem, index}
    <div class="list-item" id="list-item-{index}">
      <img src="{listItem.image}" alt="{listItem.name}" />
      <a href="{listItem.website}"><h2>{listItem.name}</h2></a>
      {@html listItem.description}
    </div>
  {/each}
</div>
Enter fullscreen mode Exit fullscreen mode

Now for scroll listening/setting. We can only do this once the component is ready, so let's use the onMount lifecycle method Svelte provides. I'm also going to use in-view to check if DOM elements are in the viewport.

👀 Did you notice that bind:this above? That gives us a reference to the DOM element, so we can put a listener on it:

<script>
  import { onMount } from "svelte";

  // Define the ref
  let listRef;

  listRef.addEventListener('scroll', function(e) {
    // Active list item is top-most fully-visible item
    const visibleListItems = Array.from(document.getElementsByClassName('list-item')).map(inView.is);
    // Array.indexOf() will give us the first one in list, so the current active item
    const topMostVisible = visibleListItems.indexOf(true);
   });
</script>
Enter fullscreen mode Exit fullscreen mode

So now we know based on scrolling what the current active list item is, now what? Let's set it in a store (you'll see why later):

// Import above
import { activeListItem } from './stores.js';

if (topMostVisible !== $activeMapItem) {
  activeListItem.set(topMostVisible);
}
Enter fullscreen mode Exit fullscreen mode

Here's what stores.js looks like:

import { writable } from 'svelte/store'

// 0 is the default value, e.g. first item in list
export const activeListItem = writable(0);
Enter fullscreen mode Exit fullscreen mode

Map.svelte 🌎

I'm using Mapbox over Google Maps since it has the highest free tier (50k/daily requests), and has way better documentation.

The visual part of this component is simple, just a <div> with an id that Mapbox can hook into. Again, we need to use onMount to wait until the component is ready to perform operations:

onMount(async () => {
  // Create the map
  mapRef = new mapboxgl.Map({
    container: "map"
  });
});
Enter fullscreen mode Exit fullscreen mode

There are two things the map needs: (1) markers for each location, and (2) click handlers for each marker. To add the markers, we'll use the addLayer function on mapRef to add a FeatureCollection to the map once it's ready:

mapRef.on('load', function () {
  // Add markers to map
  mapRef.addLayer({
    id: 'places',
    type: 'symbol',
    source: {
      type: 'geojson',
      data: {
        type: 'FeatureCollection',
        features: listItems.map(generateFeature)
      }
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

I made a generateFeature helper to generate a marker from a list item, you can see it here. To to show the popup & center the map on click, we'll add another event listener - this time for the map's click event (with the id: places from above):

// When clicking on a map marker
mapRef.on('click', 'places', function ({
  coordinates
}) {
  // Show popup
  new mapboxgl.Popup()
    .setLngLat(coordinates)
    .setHTML(description)
    .addTo(mapRef);
  // Center the map on it
  mapRef.flyTo({
    center: coordinates
  });
});
Enter fullscreen mode Exit fullscreen mode

To tell the list that this is the new active item, we can reference the same store as the list, e.g.activeListItem.set.

Inter-component communication 🗣

All we need to do now is listen for changes in each component. This is why we used a store! It's as simple as calling store.subscribe, but we'll need the onDestroy lifecycle method to stop listening on unmount:

import { onDestroy } from "svelte";

// Update map center when active list item is updated via list
const unsubscribeActiveMapItem = activeMapItem.subscribe(newActiveMapItem => {
  if (mapRef) {
    mapRef.flyTo({
      center: listItems[newActiveMapItem].coordinates
    });
  }
});

// Remove listener on unmount
onDestroy(unsubscribeActiveMapItem);
Enter fullscreen mode Exit fullscreen mode

Then repeat this for the list, but replacing mapRef.flyTo with listRef.scrollTop = newActiveListItem.offsetTop. You could animate this like the Eater article for a nicer experience, but I didn't.

Minor gotcha ✋

Because the subscribe works both ways, the list will update its own scroll position (annoying when there's a visibility threshold and it will jump to the next article mid-scroll). This is easily remedied by keeping separate stores for what the map and list think is active, respectively.


And voilà! 🎉 A functional reactive map that listens to both sides of the page. You can play with it here, or clone the repo to run it locally. I hope this helped you understand the benefits of Svelte, and may you use it for tiny projects like this in the future!

go back