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:
- Scroll to place in list; map centers on the corresponding map marker
- Click on a map marker; list scrolls to the corresponding position in list
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>
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;
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>
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>
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>
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);
}
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);
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"
});
});
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)
}
}
});
});
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
});
});
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);
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!