Leaflet Custom HTML Markers and Popups — Free HTML CSS JS Snippet

Leaflet Custom HTML Markers and Popups · Misc · Plain HTML, CSS & JS · Live preview

What's included

Features

HTML markers, not static images
divIcon renders an avatar, ring, and status dot as one marker.
Interactive popup content
A real, clickable button lives inside the popup, not just text.
Correctly anchored custom shape
iconAnchor/popupAnchor keep the pin and popup properly aligned.
Popup-open-scoped event binding
Listeners attach when popup content actually exists in the DOM.
One status field, two views
Marker ring and popup badge always agree — read from the same data.
Free OpenStreetMap tiles
No API key or paid map provider required.

About this UI Snippet

Leaflet Custom HTML Markers and Popups — Beyond the Default Pin

Screenshot of the Leaflet Custom HTML Markers and Popups snippet rendered live

Leaflet's default marker is a single static image — fine for a plain location pin, useless for showing a person's avatar, an online/offline status, or anything that needs more than one visual layer. L.divIcon replaces the marker's image entirely with arbitrary HTML, and bindPopup accepts arbitrary HTML too — together they turn a Leaflet marker from a pin into a small interactive component.

divIcon renders real HTML as the marker, not the popup

The distinction matters: an L.Icon can only ever be one image URL. An L.divIcon renders whatever HTML string you give it — here, an avatar <img>, a colored ring driven by a status class, and a small dot badge layered with absolute positioning — directly as the marker on the map, before any popup is even opened.

iconAnchor and popupAnchor have to agree with the new shape

A default marker's classic teardrop shape has its "pointing" tip at a specific known offset, which Leaflet accounts for automatically. A custom divIcon is just a box, so iconAnchor (which point of the box sits on the actual coordinate) and popupAnchor (where the popup opens relative to that) both have to be set explicitly — get them wrong and the marker visually floats away from where it's actually pinned, or the popup opens overlapping the marker instead of above it.

The popup's button needs a listener bound on every open, not once

This is the detail that's easy to get wrong: Leaflet doesn't keep popup DOM permanently in the page — it constructs the popup's content fresh (or reuses a detached element) each time openPopup runs. A click listener attached once at page load, before the popup content exists in the DOM, would silently never fire. The fix is binding it inside a popupopen event handler, which runs exactly when the actual button element exists on the page.

Status drives both the marker ring and the popup badge from one field

Each person's status field is read twice — once to pick a CSS class for the marker's ring and dot color, once to pick the popup card's status label and badge color — so the two views can never show conflicting states for the same person.

Reusing it

This is the base pattern for any "live map of things with state" — delivery drivers, field technicians, IoT devices, online users — swap the avatar and status scheme for whatever your data actually represents; the divIcon/popup mechanics stay identical.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to discover the popup-timing issue the hard way. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why a click listener attached once at page load would silently fail to fire on a Leaflet popup's button, and why binding it inside a popupopen event handler fixes it. The same assistant can help optimize it — ask whether rebuilding each divIcon's full HTML string on every status change is efficient enough for a map with hundreds of live-updating markers, or whether toggling a CSS class on an existing element would be cheaper. It's also useful for extending the effect: ask it to add marker clustering for a larger dataset, a live-updating position (say, from a WebSocket) that smoothly animates the marker to its new location, or a filter that shows only markers matching a selected status. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a Leaflet map with custom HTML markers showing an avatar photo and a live status indicator, plus rich interactive popups, using Leaflet.js (load Leaflet's CSS and JS from a CDN, no other library), in plain HTML, CSS, and JavaScript.

Requirements:
- Maintain an array of person objects, each with a name, a role, a status (e.g. online, busy, offline), an avatar image URL, and latitude/longitude coordinates.
- Render each person as a custom HTML-based marker (not the library's default image-based marker) showing their avatar photo inside a colored ring plus a small status dot badge, where the ring and dot color both depend on that person's status field.
- Set the custom marker's anchor points correctly so it visually sits on the correct coordinate and its popup opens in the correct position relative to it, since a custom HTML marker does not have the same built-in anchor point as the library's default pin shape.
- Bind each marker to a popup showing a richer HTML card: the person's name, role, a status badge matching the marker's status color and a human-readable label, and a real clickable button.
- Make the popup's button actually interactive — clicking it should change its own text to a confirmation and disable it — and ensure this works reliably by binding the click listener at the correct point in the popup's lifecycle (when its content is actually present in the DOM), not once at page load before the popup has ever been opened.
- Use free OpenStreetMap tile layers so the demo requires no API key.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Source Code

<div class="hm-map" id="hmMap"></div>

Step by step

How to Use

  1. 1
    Add the Leaflet CDNLoad leaflet.css and leaflet.js before the snippet's JS runs.
  2. 2
    Paste HTML, CSS, and JSFour avatar markers render with colored status rings.
  3. 3
    Click a markerA rich popup card opens with name, role, and status.
  4. 4
    Click "Message" in the popupThe button updates to a sent confirmation and disables.
  5. 5
    Compare the status colorsGreen online, amber busy, gray offline on both marker and card.
  6. 6
    Zoom or panCustom markers behave exactly like normal Leaflet markers.

Real-world uses

Common Use Cases

Fleet and field-team tracking
Driver or technician avatars with live status on a map.
Real-time ride or delivery apps
Rich, actionable popups beyond plain text info.
IoT device dashboards
Custom icons per device type and health state.
Team "who is online" maps
Presence indicators tied to real map positions.
Property and listing maps
Photo markers instead of generic pins.
Learning Leaflet customization
A clear reference for divIcon and popup event timing.
Related: Leaflet Store Locator with Search
See the Leaflet Store Locator snippet elsewhere in this collection for a searchable-list companion pattern.

Got questions?

Frequently Asked Questions

Leaflet's default marker uses L.Icon, which can only display a single static image at a fixed size. L.divIcon instead accepts an arbitrary HTML string as the marker's content, which is what makes layering an avatar photo, a colored status ring, and a small badge dot possible — none of that is achievable with a plain image-based icon.

Leaflet's built-in teardrop-shaped marker has a known, fixed point where it "touches down" on the map, which the library accounts for automatically. A custom divIcon is just a rectangular HTML box with no inherent anchor point, so you have to specify iconAnchor (which pixel of that box sits on the real coordinate) and popupAnchor (where the popup should open relative to that) yourself, or the marker will appear offset from its real position.

Leaflet doesn't keep every popup's DOM permanently rendered on the page — it builds or reuses the popup's content specifically when openPopup runs, which for a marker means when the user clicks it. A listener attached before that moment is binding to an element that doesn't exist in the document yet. Binding the listener inside a popupopen event handler instead runs the binding code at the exact moment the real button element is in the DOM.

Read the status value from the same data object in both places — the pinFor function reads person.status to pick the marker's CSS class, and cardFor reads the same person.status to pick the popup's badge color and label. Because both functions read from one shared object rather than two separately maintained values, they can never disagree about a given person's current status.

Build the divIcon's HTML string (or use a library-specific Leaflet wrapper's marker-content API) from your framework's own templating, keep status and other live data in component state, and re-create or update the icon when that state changes. Bind popup interactivity the same way — inside a popupopen handler — since the underlying DOM timing issue exists regardless of framework.