Skip to content
Sync a List with the Map

Keep a List and the Map in Sync in Maptoolkit Maps JS

A map beside a list of results is the shape of most search interfaces: property listings, store finders, hotel results. The part that takes the work is keeping the two in step in both directions, and filtering the list to whatever is currently on screen. This example does all three.

const API_KEY = 'YOUR_API_KEY';

    const DATA = {
        type: 'FeatureCollection',
        features: [
            ['Stephansdom', 'Cathedral', 16.3731, 48.2085], ['Hofburg', 'Palace', 16.3657, 48.2065],
            ['Belvedere', 'Museum', 16.3806, 48.1915], ['Schönbrunn', 'Palace', 16.3122, 48.1845],
            ['Prater', 'Park', 16.3960, 48.2167], ['Naschmarkt', 'Market', 16.3631, 48.1985],
            ['Rathaus', 'Civic', 16.3573, 48.2108], ['Karlskirche', 'Church', 16.3722, 48.1984],
            ['Augarten', 'Park', 16.3760, 48.2265], ['Hundertwasserhaus', 'Landmark', 16.3940, 48.2075]
        ].map(([name, kind, lng, lat], i) => ({
            type: 'Feature',
            id: i,
            properties: { id: i, name, kind },
            geometry: { type: 'Point', coordinates: [lng, lat] }
        }))
    };

    let selectedId = null;
    let hoveredId = null;

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [16.3722, 48.2082],
        zoom: 12,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    function setHover(id) {
        if (hoveredId === id) return;
        if (hoveredId !== null) map.setFeatureState({ source: 'places', id: hoveredId }, { hover: false });
        hoveredId = id;
        if (hoveredId !== null) map.setFeatureState({ source: 'places', id: hoveredId }, { hover: true });

        for (const el of document.querySelectorAll('#items .item')) {
            el.classList.toggle('hover', Number(el.dataset.id) === id);
        }
    }

    // One path for both directions, so a click on the map and a click on the row do the
    // same thing. Centring without changing zoom keeps the reader's frame of reference.
    function setSelected(id) {
        if (selectedId !== null) map.setFeatureState({ source: 'places', id: selectedId }, { selected: false });
        selectedId = id;
        if (selectedId !== null) map.setFeatureState({ source: 'places', id: selectedId }, { selected: true });

        for (const el of document.querySelectorAll('#items .item')) {
            const match = Number(el.dataset.id) === id;
            el.classList.toggle('selected', match);
            if (match) el.scrollIntoView({ block: 'nearest' });
        }

        const feature = DATA.features.find(f => f.properties.id === id);
        if (feature) map.easeTo({ center: feature.geometry.coordinates, duration: 400 });
    }

    function renderList() {
        // What is actually drawn right now, which is what "in view" should mean.
        const visible = map.queryRenderedFeatures({ layers: ['places'] });
        const seen = new Set();
        const rows = [];

        for (const f of visible) {
            if (seen.has(f.properties.id)) continue;
            seen.add(f.properties.id);
            rows.push(f.properties);
        }
        rows.sort((a, b) => a.name.localeCompare(b.name));

        document.getElementById('items').innerHTML = rows.length
            ? rows.map(p => `<div class="item${p.id === selectedId ? ' selected' : ''}" data-id="${p.id}">
                               <b>${p.name}</b><span>${p.kind}</span></div>`).join('')
            : '<div class="item"><span>Nothing in view. Zoom out.</span></div>';
    }

    map.on('load', () => {
        map.addSource('places', { type: 'geojson', data: DATA });

        map.addLayer({
            id: 'places',
            type: 'circle',
            source: 'places',
            paint: {
                'circle-radius': ['case', ['boolean', ['feature-state', 'selected'], false], 11,
                                  ['boolean', ['feature-state', 'hover'], false], 9, 7],
                'circle-color': ['case', ['boolean', ['feature-state', 'selected'], false], '#0b3d70',
                                 ['boolean', ['feature-state', 'hover'], false], '#2171b5', '#6baed6'],
                'circle-stroke-color': '#fff',
                'circle-stroke-width': 2
            }
        });

        // Map to list.
        map.on('mousemove', 'places', (e) => {
            map.getCanvas().style.cursor = 'pointer';
            setHover(e.features[0].properties.id);
        });
        map.on('mouseleave', 'places', () => {
            map.getCanvas().style.cursor = '';
            setHover(null);
        });
        map.on('click', 'places', (e) => setSelected(e.features[0].properties.id));

        // List to map. One delegated listener, so re-rendering the list keeps working.
        const items = document.getElementById('items');
        items.addEventListener('mouseover', (e) => {
            const row = e.target.closest('.item');
            if (row) setHover(Number(row.dataset.id));
        });
        items.addEventListener('mouseleave', () => setHover(null));
        items.addEventListener('click', (e) => {
            const row = e.target.closest('.item');
            if (!row) return;
            setSelected(Number(row.dataset.id));
        });

        map.on('moveend', renderList);
        map.once('idle', renderList);
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Sync a List with the Map - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Two-way sync between a sidebar list and the map." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; font: 13px/1.5 system-ui, sans-serif; }
        #app { display: flex; height: 100%; }
        #list { width: 240px; overflow-y: auto; border-right: 1px solid #e3e3e3; flex: none; }
        #list h3 { margin: 0; padding: 10px 12px; font-size: 12px; text-transform: uppercase;
                   letter-spacing: .05em; color: #777; border-bottom: 1px solid #eee; position: sticky; top: 0; background: #fff; }
        #list .item { padding: 9px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
        #list .item:hover, #list .item.hover { background: #eef4fb; }
        #list .item.selected { background: #d9e8f8; box-shadow: inset 3px 0 0 #2171b5; }
        #list .item b { display: block; }
        #list .item span { color: #777; font-size: 12px; }
        #map { flex: 1; }
    </style>
</head>
<body>
<div id="app">
    <div id="list"><h3>In view</h3><div id="items"></div></div>
    <div id="map"></div>
</div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    const DATA = {
        type: 'FeatureCollection',
        features: [
            ['Stephansdom', 'Cathedral', 16.3731, 48.2085], ['Hofburg', 'Palace', 16.3657, 48.2065],
            ['Belvedere', 'Museum', 16.3806, 48.1915], ['Schönbrunn', 'Palace', 16.3122, 48.1845],
            ['Prater', 'Park', 16.3960, 48.2167], ['Naschmarkt', 'Market', 16.3631, 48.1985],
            ['Rathaus', 'Civic', 16.3573, 48.2108], ['Karlskirche', 'Church', 16.3722, 48.1984],
            ['Augarten', 'Park', 16.3760, 48.2265], ['Hundertwasserhaus', 'Landmark', 16.3940, 48.2075]
        ].map(([name, kind, lng, lat], i) => ({
            type: 'Feature',
            id: i,
            properties: { id: i, name, kind },
            geometry: { type: 'Point', coordinates: [lng, lat] }
        }))
    };

    let selectedId = null;
    let hoveredId = null;

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [16.3722, 48.2082],
        zoom: 12,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    function setHover(id) {
        if (hoveredId === id) return;
        if (hoveredId !== null) map.setFeatureState({ source: 'places', id: hoveredId }, { hover: false });
        hoveredId = id;
        if (hoveredId !== null) map.setFeatureState({ source: 'places', id: hoveredId }, { hover: true });

        for (const el of document.querySelectorAll('#items .item')) {
            el.classList.toggle('hover', Number(el.dataset.id) === id);
        }
    }

    // One path for both directions, so a click on the map and a click on the row do the
    // same thing. Centring without changing zoom keeps the reader's frame of reference.
    function setSelected(id) {
        if (selectedId !== null) map.setFeatureState({ source: 'places', id: selectedId }, { selected: false });
        selectedId = id;
        if (selectedId !== null) map.setFeatureState({ source: 'places', id: selectedId }, { selected: true });

        for (const el of document.querySelectorAll('#items .item')) {
            const match = Number(el.dataset.id) === id;
            el.classList.toggle('selected', match);
            if (match) el.scrollIntoView({ block: 'nearest' });
        }

        const feature = DATA.features.find(f => f.properties.id === id);
        if (feature) map.easeTo({ center: feature.geometry.coordinates, duration: 400 });
    }

    function renderList() {
        // What is actually drawn right now, which is what "in view" should mean.
        const visible = map.queryRenderedFeatures({ layers: ['places'] });
        const seen = new Set();
        const rows = [];

        for (const f of visible) {
            if (seen.has(f.properties.id)) continue;
            seen.add(f.properties.id);
            rows.push(f.properties);
        }
        rows.sort((a, b) => a.name.localeCompare(b.name));

        document.getElementById('items').innerHTML = rows.length
            ? rows.map(p => `<div class="item${p.id === selectedId ? ' selected' : ''}" data-id="${p.id}">
                               <b>${p.name}</b><span>${p.kind}</span></div>`).join('')
            : '<div class="item"><span>Nothing in view. Zoom out.</span></div>';
    }

    map.on('load', () => {
        map.addSource('places', { type: 'geojson', data: DATA });

        map.addLayer({
            id: 'places',
            type: 'circle',
            source: 'places',
            paint: {
                'circle-radius': ['case', ['boolean', ['feature-state', 'selected'], false], 11,
                                  ['boolean', ['feature-state', 'hover'], false], 9, 7],
                'circle-color': ['case', ['boolean', ['feature-state', 'selected'], false], '#0b3d70',
                                 ['boolean', ['feature-state', 'hover'], false], '#2171b5', '#6baed6'],
                'circle-stroke-color': '#fff',
                'circle-stroke-width': 2
            }
        });

        // Map to list.
        map.on('mousemove', 'places', (e) => {
            map.getCanvas().style.cursor = 'pointer';
            setHover(e.features[0].properties.id);
        });
        map.on('mouseleave', 'places', () => {
            map.getCanvas().style.cursor = '';
            setHover(null);
        });
        map.on('click', 'places', (e) => setSelected(e.features[0].properties.id));

        // List to map. One delegated listener, so re-rendering the list keeps working.
        const items = document.getElementById('items');
        items.addEventListener('mouseover', (e) => {
            const row = e.target.closest('.item');
            if (row) setHover(Number(row.dataset.id));
        });
        items.addEventListener('mouseleave', () => setHover(null));
        items.addEventListener('click', (e) => {
            const row = e.target.closest('.item');
            if (!row) return;
            setSelected(Number(row.dataset.id));
        });

        map.on('moveend', renderList);
        map.once('idle', renderList);
    });
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map with a sidebar list of locations. Hovering a list row highlights the matching point and vice versa, clicking either one selects it, and the list shows only what is currently in view.

How it works

Highlighting runs on feature state, not on rewriting the data. setFeatureState flips a flag the paint expressions read, so hovering a row repaints one circle instead of re-uploading the source. With a few hundred features the difference between the two approaches is the difference between instant and visibly laggy.

Feature state needs a stable feature id, and it has to be at the top level of the feature, not inside properties. This data sets both: id for MapLibre and properties.id so event handlers and the list can read it back, since e.features[0].id is not always carried through. Omitting the top-level id makes setFeatureState fail silently, which is the usual reason a hover effect does nothing.

['boolean', ['feature-state', 'hover'], false] wraps the lookup with a default. An unvisited feature has no state at all, and the bare ['feature-state', 'hover'] evaluates to null, which is not a boolean and makes case throw.

The list is built from queryRenderedFeatures, so “in view” means what is actually drawn rather than what is within the bounding box. Two consequences follow. It returns features once per tile they appear in, so the seen set is required or a point on a tile boundary is listed twice. And it only ever sees the current viewport, so this pattern suits a find-what-is-near-me list, not a complete result set.

The list uses one delegated listener on the container rather than a listener per row. The rows are replaced on every moveend, and per-row listeners would be lost with them.

map.once('idle', renderList) seeds the first render. At load the tiles are not yet drawn, so queryRenderedFeatures returns nothing and the list starts empty.

Both directions run through the same setSelected. An earlier version zoomed in when a row was clicked and did nothing when the matching point was clicked, which reads as two different features rather than one. Whatever the interaction does, it should not depend on which half of the interface the click landed in.

It centres without changing zoom. Zooming on selection fights the reader: they set a zoom deliberately, and a click that changes it throws away the context they were using to compare things. Centring is enough to bring an off-screen row into view, and scrollIntoView does the same for the list when the selection came from the map.

Next steps

The version people actually build filters the list by more than the viewport. Combining this with a category filter means applying the same filter expression to the layer and to the list, so the two can never disagree.

Once a row is selected, showing its detail is a popup on the map or a panel beside the list, and the Routing API turns the selection into directions from wherever the reader is.