Custom Markers in Leaflet: Colors, Icons and Labels
Leaflet’s default marker is a blue PNG, and its color can’t be changed with an option. This
example replaces it with an inline SVG pin built with L.divIcon, so each marker gets its
category’s color and a number, and switches between numbered pins and name labels. Each marker
carries its data, so a click on a pin or on the list highlights both, and the map fits itself to
whatever markers are shown.
const API_KEY = "YOUR_API_KEY";
const CATEGORIES = {
food: { label: "Food", color: "#e8590c" },
sight: { label: "Sights", color: "#7048e8" },
church: { label: "Churches", color: "#2f9e44" },
};
const PLACES = [
{ name: "Goldenes Dachl", category: "sight", lat: 47.26857, lng: 11.39328 },
{ name: "Hofburg", category: "sight", lat: 47.26886, lng: 11.39490 },
{ name: "Stadtturm", category: "sight", lat: 47.26823, lng: 11.39345 },
{ name: "Ferdinandeum", category: "sight", lat: 47.26721, lng: 11.39771 },
{ name: "Markthalle", category: "food", lat: 47.26719, lng: 11.38960 },
{ name: "Goldener Adler", category: "food", lat: 47.26829, lng: 11.39248 },
{ name: "Stiftskeller", category: "food", lat: 47.26836, lng: 11.39480 },
{ name: "Cathedral of St. James", category: "church", lat: 47.26936, lng: 11.39421 },
{ name: "Hofkirche", category: "church", lat: 47.26821, lng: 11.39526 },
];
const map = L.map("map", { zoomControl: false }).setView([47.269, 11.394], 16);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
// A pin as inline SVG: any color, any text, sharp at every size.
function pinIcon(color, text) {
return L.divIcon({
className: "pin",
html: `<svg width="30" height="40" viewBox="0 0 30 40">
<path d="M15 39C15 39 29 23.5 29 14.5A14 14 0 0 0 1 14.5C1 23.5 15 39 15 39Z" fill="${color}" stroke="#fff" stroke-width="2"/>
<text x="15" y="19.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="#fff">${text}</text>
</svg>`,
iconSize: [30, 40],
// The tip of the pin sits on the coordinate; popups open above it.
iconAnchor: [15, 39],
popupAnchor: [0, -36],
tooltipAnchor: [14, -22],
});
}
// All markers live in one group, so they can be cleared and fitted in one call.
const markers = L.featureGroup().addTo(map);
const panel = L.control({ position: "topleft" });
panel.onAdd = () => {
const div = L.DomUtil.create("div", "panel");
div.innerHTML = '<div class="filters">' + Object.entries(CATEGORIES).map(([key, c]) =>
`<label><input type="checkbox" value="${key}" checked> ${c.label}</label>`).join("") +
'</div><label><input type="checkbox" id="labels"> Show names on the map</label><ol></ol>';
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
panel.addTo(map);
const container = panel.getContainer();
function highlight(marker) {
markers.eachLayer((m) => {
const active = m === marker;
L.DomUtil[active ? "addClass" : "removeClass"](m.getElement(), "active");
m.place.item.classList.toggle("active", active);
});
}
function render() {
const visible = [...container.querySelectorAll(".filters input:checked")].map((input) => input.value);
const showNames = container.querySelector("#labels").checked;
// Remove every marker, then add the ones that pass the filter.
markers.clearLayers();
const list = container.querySelector("ol");
list.innerHTML = "";
PLACES.filter((place) => visible.includes(place.category)).forEach((place, i) => {
const { color } = CATEGORIES[place.category];
const marker = L.marker([place.lat, place.lng], { icon: pinIcon(color, i + 1), riseOnHover: true })
.bindPopup(`<strong>${place.name}</strong><br>${CATEGORIES[place.category].label}`);
if (showNames) marker.bindTooltip(place.name, { permanent: true, direction: "right", className: "name" });
// Keep the data on the marker, so any handler can read it back.
marker.place = place;
marker.on("click", () => highlight(marker));
markers.addLayer(marker);
const item = document.createElement("li");
item.innerHTML = `<span class="dot" style="background:${color}">${i + 1}</span>`;
item.append(place.name);
item.addEventListener("click", () => {
highlight(marker);
map.panTo(marker.getLatLng());
marker.openPopup();
});
list.append(item);
place.item = item;
});
// Fit the map to the visible markers, leaving room for the panel on the left.
if (markers.getLayers().length) {
map.fitBounds(markers.getBounds(), { paddingTopLeft: [260, 40], paddingBottomRight: [60, 40], maxZoom: 17 });
}
}
container.addEventListener("change", render);
render();<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
.panel {
width: 220px; padding: 10px 12px; background: #fff; border-radius: 10px;
box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18); font: 13px/1.4 system-ui, sans-serif; color: #1f2430;
}
.panel .filters { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-bottom: 6px; }
.panel label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.panel ol { max-height: 200px; margin: 6px 0 0; padding: 0; overflow-y: auto; list-style: none; }
.panel li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 6px; cursor: pointer; }
.panel li:hover, .panel li.active { background: #eef0f6; }
.panel .dot { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; color: #fff; font-size: 11px; font-weight: 700; }
/* The divIcon wrapper gets a white square by default; the pin draws its own shape. */
.pin { background: none; border: none; }
.pin svg { display: block; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); transition: transform 0.15s; transform-origin: 50% 100%; }
.pin.active svg { transform: scale(1.3); }
.leaflet-tooltip.name { padding: 1px 6px; border: none; border-radius: 4px; font: 600 12px system-ui, sans-serif; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = "YOUR_API_KEY";
const CATEGORIES = {
food: { label: "Food", color: "#e8590c" },
sight: { label: "Sights", color: "#7048e8" },
church: { label: "Churches", color: "#2f9e44" },
};
const PLACES = [
{ name: "Goldenes Dachl", category: "sight", lat: 47.26857, lng: 11.39328 },
{ name: "Hofburg", category: "sight", lat: 47.26886, lng: 11.39490 },
{ name: "Stadtturm", category: "sight", lat: 47.26823, lng: 11.39345 },
{ name: "Ferdinandeum", category: "sight", lat: 47.26721, lng: 11.39771 },
{ name: "Markthalle", category: "food", lat: 47.26719, lng: 11.38960 },
{ name: "Goldener Adler", category: "food", lat: 47.26829, lng: 11.39248 },
{ name: "Stiftskeller", category: "food", lat: 47.26836, lng: 11.39480 },
{ name: "Cathedral of St. James", category: "church", lat: 47.26936, lng: 11.39421 },
{ name: "Hofkirche", category: "church", lat: 47.26821, lng: 11.39526 },
];
const map = L.map("map", { zoomControl: false }).setView([47.269, 11.394], 16);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
// A pin as inline SVG: any color, any text, sharp at every size.
function pinIcon(color, text) {
return L.divIcon({
className: "pin",
html: `<svg width="30" height="40" viewBox="0 0 30 40">
<path d="M15 39C15 39 29 23.5 29 14.5A14 14 0 0 0 1 14.5C1 23.5 15 39 15 39Z" fill="${color}" stroke="#fff" stroke-width="2"/>
<text x="15" y="19.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="700" fill="#fff">${text}</text>
</svg>`,
iconSize: [30, 40],
// The tip of the pin sits on the coordinate; popups open above it.
iconAnchor: [15, 39],
popupAnchor: [0, -36],
tooltipAnchor: [14, -22],
});
}
// All markers live in one group, so they can be cleared and fitted in one call.
const markers = L.featureGroup().addTo(map);
const panel = L.control({ position: "topleft" });
panel.onAdd = () => {
const div = L.DomUtil.create("div", "panel");
div.innerHTML = '<div class="filters">' + Object.entries(CATEGORIES).map(([key, c]) =>
`<label><input type="checkbox" value="${key}" checked> ${c.label}</label>`).join("") +
'</div><label><input type="checkbox" id="labels"> Show names on the map</label><ol></ol>';
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
return div;
};
panel.addTo(map);
const container = panel.getContainer();
function highlight(marker) {
markers.eachLayer((m) => {
const active = m === marker;
L.DomUtil[active ? "addClass" : "removeClass"](m.getElement(), "active");
m.place.item.classList.toggle("active", active);
});
}
function render() {
const visible = [...container.querySelectorAll(".filters input:checked")].map((input) => input.value);
const showNames = container.querySelector("#labels").checked;
// Remove every marker, then add the ones that pass the filter.
markers.clearLayers();
const list = container.querySelector("ol");
list.innerHTML = "";
PLACES.filter((place) => visible.includes(place.category)).forEach((place, i) => {
const { color } = CATEGORIES[place.category];
const marker = L.marker([place.lat, place.lng], { icon: pinIcon(color, i + 1), riseOnHover: true })
.bindPopup(`<strong>${place.name}</strong><br>${CATEGORIES[place.category].label}`);
if (showNames) marker.bindTooltip(place.name, { permanent: true, direction: "right", className: "name" });
// Keep the data on the marker, so any handler can read it back.
marker.place = place;
marker.on("click", () => highlight(marker));
markers.addLayer(marker);
const item = document.createElement("li");
item.innerHTML = `<span class="dot" style="background:${color}">${i + 1}</span>`;
item.append(place.name);
item.addEventListener("click", () => {
highlight(marker);
map.panTo(marker.getLatLng());
marker.openPopup();
});
list.append(item);
place.item = item;
});
// Fit the map to the visible markers, leaving room for the panel on the left.
if (markers.getLayers().length) {
map.fitBounds(markers.getBounds(), { paddingTopLeft: [260, 40], paddingBottomRight: [60, 40], maxZoom: 17 });
}
}
container.addEventListener("change", render);
render();
</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
Color with L.divIcon, not L.icon. The default marker is an image, so changing its color
means another image for every color. L.divIcon puts any HTML into the marker instead, and an
inline SVG pin takes its fill color and number from the data. It stays sharp on high-density
screens and needs no image files. Leaflet gives a divIcon a white box by default; the pin class
removes it, since the SVG draws its own shape and shadow.
Anchors. iconAnchor is the pixel of the icon that sits on the coordinate: the tip of the
pin, at [15, 39] for a 30 by 40 pin. Without it, Leaflet centers the icon and the pin points
below its location. popupAnchor and tooltipAnchor are relative to that point.
Labels. bindTooltip(name, { permanent: true }) shows the name next to the marker all the
time instead of on hover. The name class restyles it as a compact label. Text inside the pin,
like the numbers here, belongs in the icon itself.
Data on the marker. A marker is an ordinary object, so marker.place = place keeps the
record on it. Any event handler can read it back, and the list and the map use it to highlight
each other.
One group for all markers. The markers are in an L.featureGroup. clearLayers() removes
them all before the filter adds the matching ones again, which avoids keeping track of individual
markers to delete. getBounds() on the group gives the box around every marker in it, and
fitBounds() zooms to it. paddingTopLeft keeps the markers clear of the panel, and maxZoom
stops one remaining marker from zooming the map all the way in.
riseOnHover brings the marker under the cursor to the front where pins overlap.
Next steps
For hundreds of markers, group nearby ones with Leaflet.markercluster, which works with custom icons too. For markers that move, see Animate a Vehicle Along a Route.
In Maptoolkit Maps JS, where many points are better drawn as a symbol layer than as DOM markers, Choose an Icon per Feature gives each category its own icon.