Skip to content
Labels from Browser Language

Set Map Labels from the Browser Language in Maptoolkit Maps JS

A map that labels Vienna as “Wien” inside an English interface looks unfinished. The vector tiles carry a name field per language, so matching the map to the visitor is a matter of reading the browser’s preference once at load and pointing every symbol layer at the right field. The manual switch stays, because the guess is sometimes wrong.

const API_KEY = 'YOUR_API_KEY';

    // Only the languages the tiles actually carry. Anything else falls back to local names.
    const AVAILABLE = ['de', 'en', 'es', 'fr', 'it', 'ru', 'zh', 'ja', 'ko', 'pl', 'cs', 'hu', 'ar', 'hi'];

    function detectLanguage() {
        for (const tag of navigator.languages || [navigator.language || 'en']) {
            // Tags are like "de-AT" or "pt-BR"; the tiles are keyed on the base language.
            const base = tag.toLowerCase().split('-')[0];
            if (AVAILABLE.includes(base)) return base;
        }
        return 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: [10.0, 50.0],
        zoom: 4,
        attributionControl: { compact: false }
    });

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

    function setLanguage(code) {
        // null means local names, which is what the style ships with.
        const field = code
            ? ['coalesce', ['get', `name_${code}`], ['get', 'name']]
            : ['get', 'name'];

        for (const layer of map.getStyle().layers) {
            if (layer.type !== 'symbol') continue;
            // Only touch layers that actually label something by name.
            const current = layer.layout && layer.layout['text-field'];
            if (current === undefined) continue;
            map.setLayoutProperty(layer.id, 'text-field', field);
        }
    }

    map.on('load', () => {
        const detected = detectLanguage();
        setLanguage(detected);

        const options = ['', ...AVAILABLE]
            .map(c => `<option value="${c}"${c === (detected || '') ? ' selected' : ''}>${c ? c.toUpperCase() : 'Local names'}</option>`)
            .join('');

        document.getElementById('panel').innerHTML =
            `<div class="detected">navigator.languages: ${(navigator.languages || []).join(', ') || 'unset'}</div>
             <select id="lang">${options}</select>`;

        document.getElementById('lang').addEventListener('change', (e) => {
            setLanguage(e.target.value || null);
        });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Labels from Browser Language - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Match map labels to the visitor's browser language." />
    <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; }
        #map { width: 100%; height: 100%; }
        #panel {
            position: absolute; top: 10px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.5 system-ui, sans-serif; padding: 10px 12px;
        }
        #panel .detected { color: #666; font-size: 12px; margin-bottom: 6px; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="panel"></div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    // Only the languages the tiles actually carry. Anything else falls back to local names.
    const AVAILABLE = ['de', 'en', 'es', 'fr', 'it', 'ru', 'zh', 'ja', 'ko', 'pl', 'cs', 'hu', 'ar', 'hi'];

    function detectLanguage() {
        for (const tag of navigator.languages || [navigator.language || 'en']) {
            // Tags are like "de-AT" or "pt-BR"; the tiles are keyed on the base language.
            const base = tag.toLowerCase().split('-')[0];
            if (AVAILABLE.includes(base)) return base;
        }
        return 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: [10.0, 50.0],
        zoom: 4,
        attributionControl: { compact: false }
    });

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

    function setLanguage(code) {
        // null means local names, which is what the style ships with.
        const field = code
            ? ['coalesce', ['get', `name_${code}`], ['get', 'name']]
            : ['get', 'name'];

        for (const layer of map.getStyle().layers) {
            if (layer.type !== 'symbol') continue;
            // Only touch layers that actually label something by name.
            const current = layer.layout && layer.layout['text-field'];
            if (current === undefined) continue;
            map.setLayoutProperty(layer.id, 'text-field', field);
        }
    }

    map.on('load', () => {
        const detected = detectLanguage();
        setLanguage(detected);

        const options = ['', ...AVAILABLE]
            .map(c => `<option value="${c}"${c === (detected || '') ? ' selected' : ''}>${c ? c.toUpperCase() : 'Local names'}</option>`)
            .join('');

        document.getElementById('panel').innerHTML =
            `<div class="detected">navigator.languages: ${(navigator.languages || []).join(', ') || 'unset'}</div>
             <select id="lang">${options}</select>`;

        document.getElementById('lang').addEventListener('change', (e) => {
            setLanguage(e.target.value || null);
        });
    });
</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 of Europe that reads navigator.languages, picks the matching name field for map labels if one is available, falls back to local names otherwise, and offers a manual language switch.

How it works

navigator.languages is an ordered list of preferences, not a single value, so the loop takes the first one the tiles can actually serve. Reading navigator.language alone throws away the fallbacks a visitor deliberately configured.

Language tags are not language codes. A browser reports de-AT, en-GB or pt-BR, and the tiles are keyed on the base language, so the region has to be stripped. Looking up name_de-AT returns nothing and the map silently falls back to local names, which looks like the detection failed.

AVAILABLE exists because the guess has to be checked against reality. Setting text-field to name_sv when the tiles carry no Swedish gives you empty labels rather than an error, and an unlabelled map is worse than one in the wrong language.

coalesce is the safety net inside the expression itself. Coverage is uneven: a capital city has a name in twenty languages and a hamlet has one. ['coalesce', ['get', 'name_en'], ['get', 'name']] uses the translation where it exists and the local name where it does not, per feature. Without it, whole zoom levels go blank in a sparsely translated language.

The loop skips layers with no text-field. A style contains symbol layers used only for icons, and forcing a text field onto them makes them start drawing names that were never meant to be there.

Labels are only part of localisation, and the cheapest half. Units, date formats, number separators and the direction of your own interface all have to follow, and a map showing kilometres inside an imperial interface is a more obvious mistake than an untranslated place name.

The manual switch stays visible rather than being a hidden preference. Someone browsing in English in Vienna may well want the local names, and a detected default is a guess, not a decision.

Next steps

Remembering the choice is the missing piece: the selection belongs in localStorage or, better, in the URL so a shared link carries the language with it.

Casing, spacing and halo width usually need attention at the same time, because a script the style was not designed around behaves differently. Non-Latin labels in particular need text-font checked against what the glyph server actually serves for that range.