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.
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.