LeafletWordPressElementorJavaScriptCSSmapsCartoDB

How to Build a Custom Multi-Location Map in WordPress and Elementor with Leaflet (Free, No Monthly Fee)

A complete, working Leaflet.js map with custom pins, clustering, and branded popup cards for WordPress and Elementor. Real code from a six-location restaurant chain, including the iOS popup bug that took three attempts to actually fix.

Milan PavlákMilan Pavlák
20 min read
How to Build a Custom Multi-Location Map in WordPress and Elementor with Leaflet (Free, No Monthly Fee)

If you read the companion article about why I moved away from Google My Maps and paid map tools, this is the follow-up with the actual code. Six restaurant locations, custom brand-matched pins, clustering, and popup cards, built on Leaflet with zero recurring cost.

One thing worth saying upfront: I built this by describing what I wanted to Claude and iterating on what came back. That's not a disclaimer, it's the actual workflow, and I think it's worth being upfront about it. The value wasn't typing every line by hand. It was knowing what to ask for, catching what was wrong, and testing until it actually worked on a real phone. That process is documented honestly below, bugs included, because the debugging is where most of the useful detail lives.


What you're building

A single map embed showing multiple business locations, with:

  • Custom SVG pin markers in your own brand color, not the default red teardrop
  • Clustering, so nearby locations group into a numbered badge instead of overlapping
  • A styled popup card on tap, with photo, name, address, description, and phone
  • No API key, no usage limits, no monthly fee
  • Full control over every visual detail

The example throughout is the real one: Punjabi Dhaba, six restaurant locations, five in Bratislava plus one in Piešťany, gold (#DCA26B) and dark navy (#080D16) brand colors, frosted-glass popup cards.


The stack

  • Leaflet — the mapping library itself, open source, no key required
  • Leaflet.markercluster — the one plugin used, for grouping nearby pins
  • CartoDB Voyager tiles — the map background imagery, free tier, no key required for reasonable traffic
  • Plain HTML, CSS, and vanilla JavaScript — no build step, no framework

Everything loads from CDN (cdnjs for Leaflet and the cluster plugin), so there's nothing to install or host yourself.


Step 1: The container and library imports

Drop this into an Elementor HTML widget. Everything between the style tag and the closing script tag is self-contained.

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@400;600&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet">

<div id="pd-map"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js"></script>

⚠️ If your theme already loads Jost and Source Sans Pro (check your theme's typography settings), delete the Google Fonts <link> to avoid loading the same fonts twice. I left it in so the map still renders correctly even if reused on a page where the theme fonts aren't loaded.


Step 2: Surviving the Elementor/theme CSS cascade

This is the part that isn't documented anywhere and cost real time. Elementor and most WordPress themes apply fairly aggressive default styling to images, headings, and links — max-width, height: auto, font-family resets, and so on. If you write normal CSS, the theme wins almost every fight.

The fix is scoping everything under one container ID and using !important deliberately, not as a lazy habit but because it's specifically counteracting theme rules that are themselves !important or high-specificity.

#pd-map {
  width: 100%; height: 560px; overflow: hidden;
  border-radius: 0;
  font-family: "Source Sans Pro","Source Sans 3", sans-serif;
}
@media (max-width: 640px) {
  #pd-map { height: 600px; }
  #pd-map .pd-card__img { height: 150px !important; }
}
@media (max-width: 400px) {
  #pd-map .pd-card__img { height: 150px !important; }
}

⚠️ Every selector below is scoped as #pd-map .something. If you copy this for your own project, keep that pattern — dropping the #pd-map prefix means the theme's own CSS will likely override you unpredictably depending on load order.

Pin sizing

#pd-map .pd-pin,
#pd-map svg.pd-pin {
  width: 43px !important; height: 58px !important; max-width: none !important;
  display: block !important; filter: drop-shadow(0 4px 6px rgba(0,0,0,.4));
}

The max-width: none !important matters specifically — most themes set img, svg { max-width: 100% } globally, which will squash your pin down to the width of its container without this override.

Cluster badge

#pd-map .pd-cluster {
  width: 48px !important; height: 48px !important; border-radius: 50% !important;
  background: #DCA26B !important; border: 3px solid #fff !important;
  box-shadow: 0 3px 7px rgba(0,0,0,.4) !important;
  display: flex !important; align-items: center !important; justify-content: center !important;
}
#pd-map .pd-cluster span {
  font-family: "Jost", sans-serif !important; font-weight: 600 !important;
  font-size: 17px !important; color: #080D16 !important;
}

⚠️ Replace #DCA26B and #080D16 with your own brand colors.

#pd-map .leaflet-popup.pd-wrap .leaflet-popup-content-wrapper {
  padding: 0 !important;
  border-radius: 16px !important;
  overflow: hidden !important;
  background-color: rgba(255, 255, 255, 0.66) !important;
  -webkit-backdrop-filter: blur(10px) !important;
  backdrop-filter: blur(10px) !important;
  box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 16px !important;
}
#pd-map .leaflet-popup.pd-wrap .leaflet-popup-content {
  margin: 0 !important; width: 280px !important;
}
#pd-map .leaflet-popup.pd-wrap .leaflet-popup-tip {
  background: rgba(255, 255, 255, 0.66) !important;
  box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 16px !important;
}

The frosted glass effect is just backdrop-filter: blur() on a semi-transparent white background. It's a two-line effect that looks considerably more expensive than it is.

Card content typography

#pd-map .pd-card__img {
  width: 100% !important; height: 170px !important; max-width: none !important;
  object-fit: cover !important; display: block !important;
  margin: 0 !important; border-radius: 0 !important;
}
#pd-map .pd-card__body { padding: 24px !important; }
#pd-map .pd-card__title {
  margin: 0 0 8px !important; padding: 0 !important;
  font-family: "Jost", sans-serif !important; font-weight: 400 !important;
  text-transform: none !important; letter-spacing: normal !important;
  font-size: 20px !important; line-height: 1.25 !important; color: #DCA26B !important;
}
#pd-map .pd-card__addr {
  display: inline-block !important; margin: 0 0 12px !important;
  font-family: "Jost", sans-serif !important; font-weight: 400 !important;
  font-size: 12px !important; color: #000 !important; text-decoration: none !important;
}
#pd-map .pd-card__desc {
  margin: 0 0 14px !important; padding: 0 !important;
  font-family: "Source Sans Pro","Source Sans 3", sans-serif !important;
  font-weight: 400 !important; font-size: 14px !important; line-height: 1.5 !important; color: #000 !important;
}
#pd-map .pd-card__phone {
  font-family: "Jost", sans-serif !important; font-weight: 400 !important;
  font-size: 14px !important; color: #000 !important; text-decoration: none !important;
}
#pd-map .pd-card__addr:hover, #pd-map .pd-card__phone:hover { color: #DCA26B !important; }

⚠️ text-transform: none !important is there specifically because a lot of themes force headings to uppercase. Check whether your theme does this before assuming you don't need this line.

The mobile fit safety net

#pd-map .leaflet-popup.pd-wrap .leaflet-popup-content {
  max-height: 78vh !important; overflow-y: auto !important;
}

This one caused a real bug, explained in full further down. Keep it — it's the fallback that guarantees a card can never be taller than the viewport, even on a short landscape phone.


Step 3: Your location data

var IMG = {
  prievozska: "https://yourdomain.com/wp-content/uploads/2026/07/1.webp",
  cubicon: "https://yourdomain.com/wp-content/uploads/2026/07/3.webp",
  skypark: "https://yourdomain.com/wp-content/uploads/2026/07/5.webp",
  nivy: "https://yourdomain.com/wp-content/uploads/2026/07/7.webp",
  sancova: "https://yourdomain.com/wp-content/uploads/2026/07/9.webp",
  piestany: "https://yourdomain.com/wp-content/uploads/2026/07/11.webp",
};

var venues = [
  {
    name: "Your Location Name",
    address: "Street 18, 821 09 City",
    mapUrl: "https://maps.app.goo.gl/your-google-maps-share-link",
    phone: "0900 000 000",
    description: "A short description of what makes this specific location worth visiting.",
    image: IMG.prievozska,
    coords: [48.148296, 17.1480764]
  },
  // repeat for each location
];

⚠️ This is the section to replace entirely with your own data. Everything else in the code is reusable as-is.

On getting coordinates right

This is worth flagging because it caused a real, live bug. My first pass at the coordinates for this project used approximate guesses rather than exact values. One of them, Prievozská, was off by roughly 550 meters. It looked fine on the map at a glance, zoomed out, but was pointing at the wrong building.

The reliable way to get exact coordinates: open the location in Google Maps, use the Share function to get a shortened maps.app.goo.gl link, then open that link and read the resolved coordinates from the address bar or page URL after redirect. Don't estimate by eye on the map itself. Get the coordinates from the same link you're already using for the "get directions" click-through, so they're guaranteed to match.


Step 4: The map initialization, pins, and clustering

var map = L.map("pd-map", { scrollWheelZoom: false });

L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
  attribution: "&copy; OpenStreetMap &copy; CARTO", maxZoom: 19
}).addTo(map);

var pinSvg =
  '<svg class="pd-pin" width="43" height="58" viewBox="0 0 34 46" xmlns="http://www.w3.org/2000/svg">' +
    '<path d="M17 0C7.6 0 0 7.6 0 17c0 12 17 29 17 29s17-17 17-29C34 7.6 26.4 0 17 0z" fill="#DCA26B"/>' +
    '<circle cx="17" cy="17" r="6" fill="#080D16"/>' +
  '</svg>';
var pdIcon = L.divIcon({
  html: pinSvg, className: "", iconSize: [43, 58], iconAnchor: [22, 58], popupAnchor: [0, -54]
});

⚠️ Replace #DCA26B and #080D16 in the SVG path with your own brand colors — matching whatever you set in the CSS cluster badge above.

scrollWheelZoom: false is deliberate. Without it, a visitor scrolling down the page with their mouse over the map gets their scroll captured by the map's zoom instead of continuing to scroll the page. It's re-enabled on click, further down.

Building the popup HTML

function esc(s){ return String(s).replace(/[&<>"]/g, function(c){
  return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c];
}); }

function popupHtml(v){
  var img  = v.image ? '<img class="pd-card__img" src="'+v.image+'" alt="'+esc(v.name)+'">' : '';
  var addr = v.mapUrl
    ? '<a class="pd-card__addr" href="'+esc(v.mapUrl)+'" target="_blank" rel="noopener noreferrer">'+esc(v.address)+'</a>'
    : '<span class="pd-card__addr">'+esc(v.address)+'</span>';
  var desc = v.description ? '<p class="pd-card__desc">'+esc(v.description)+'</p>' : '';
  var tel  = v.phone.replace(/\s+/g, "");
  return img +
    '<div class="pd-card__body">' +
      '<h3 class="pd-card__title">'+esc(v.name)+'</h3>' +
      addr + desc +
      '<a class="pd-card__phone" href="tel:'+esc(tel)+'">'+esc(v.phone)+'</a>' +
    '</div>';
}

The esc() function matters if any of your location names, addresses, or descriptions could ever contain characters like & or quotes — without escaping, that breaks the HTML structure of the popup. Cheap insurance, worth keeping even if your current data is clean.

The cluster group

var cluster = L.markerClusterGroup({
  showCoverageOnHover: false, maxClusterRadius: 60,
  removeOutsideVisibleBounds: false,
  iconCreateFunction: function (c) {
    return L.divIcon({
      html: '<div class="pd-cluster"><span>' + c.getChildCount() + '</span></div>',
      className: "", iconSize: [48, 48]
    });
  }
});

venues.forEach(function (v) {
  cluster.addLayer(
    L.marker(v.coords, { icon: pdIcon })
      .bindPopup(popupHtml(v), { className: "pd-wrap", maxWidth: 300, closeButton: false, closeOnClick: false, autoClose: true, autoPanPadding: [16, 16] })
  );
});
map.addLayer(cluster);
map.fitBounds(cluster.getBounds(), { padding: [45, 45] });

maxClusterRadius: 60 controls how close pins need to be, in pixels, before they merge into a cluster badge. Lower it if you want pins to stay separate more often; raise it if you want more aggressive grouping.

map.fitBounds(cluster.getBounds(), ...) is what automatically zooms and centers the map to fit every location on load, rather than you hardcoding a center point and zoom level that might not work once you add or remove a location.

removeOutsideVisibleBounds: false looks like a minor performance setting. It is not. It's the fix for the most serious bug in this entire build, explained fully below.


Step 5: The click and scroll behavior

var lastPopupOpen = 0;
map.on("popupopen", function () { lastPopupOpen = Date.now(); });

map.on("click", function () {
  map.scrollWheelZoom.enable();
  if (Date.now() - lastPopupOpen > 500) { map.closePopup(); }
});
map.on("mouseout", function () { map.scrollWheelZoom.disable(); });

This block exists entirely because of the iOS bug below. Read that section for why it's shaped this way.


The bug that took three real attempts to fix

I want to walk through this in full because the eventual fix isn't the obvious one, and if you build something similar you may hit the same thing.

The symptom

On iPhone specifically, tapping a pin would open the popup card and then immediately close it. Desktop worked fine. Android worked fine. Only iOS Safari showed this.

Attempt one: assume it's the standard iOS ghost-click issue

There's a known quirk where a single tap on a mobile browser can sometimes fire both the intended click and a secondary "ghost" click shortly after, and if your close-on-click logic isn't guarded, that second click closes whatever the first one just opened.

The fix for that specific problem is straightforward: set closeOnClick: false on the popup, and instead track when a popup opened, then ignore any map click that happens within a short window afterward (500ms), while still allowing a genuine tap-away to close it after that window passes.

That's exactly what the code in Step 5 does. It's a real fix for a real, common problem. It did not fix this bug.

Attempt two: assume it's a viewport sizing issue

When the ghost-click fix didn't resolve it, the next reasonable hypothesis was that resizing the map container for mobile and reducing the photo height would give the popup more room to render without triggering whatever was cutting it off.

Also didn't fix it. At this point, guessing was no longer productive — it needed to actually be reproduced and measured.

Attempt three: reproduce it and measure

Using a browser resized to real iPhone viewport width, the actual failure became visible and measurable. At mobile width, the map container was 460px tall. The tallest popup card, for the Sky Park location, rendered at 473px tall on its own, already taller than the map. Add the 58px pin height on top and the required space was 531px. It could not physically fit inside a 460px container.

Here's the actual chain of events that was happening:

  1. The popup opens and Leaflet tries to auto-pan the map so the oversized card is fully visible
  2. To do that, it has to shift the map far enough that the pin itself gets pushed outside the visible viewport
  3. The markercluster plugin, by default, actively removes any marker that scrolls outside the visible bounds, a performance optimization for maps with hundreds of markers, deleting off-screen ones from the DOM
  4. Deleting a marker also destroys its popup
  5. The popup that had just opened a moment earlier vanishes, because the marker it belonged to no longer exists

That's the real mechanism. It had nothing to do with ghost clicks. The ghost-click fix from attempt one was still correct and worth keeping, but it was fixing a different, smaller problem that happened to look similar.

The actual fix, three parts

Stop the plugin from deleting off-screen markers. With only six locations total, there's no meaningful performance cost to keeping all of them mounted regardless of scroll position:

var cluster = L.markerClusterGroup({
  removeOutsideVisibleBounds: false,
  // ...
});

This is the root-cause fix. Without this, nothing else here matters.

Make the card actually fit, so the auto-pan doesn't need to shove the pin as far off-screen in the first place:

@media (max-width: 640px) {
  #pd-map { height: 600px; }
  #pd-map .pd-card__img { height: 150px !important; }
}

Verified by measurement after the change: the tallest card at the new mobile size came to 453px, plus the 58px pin, totaling 511px, comfortably inside the new 600px container.

A safety net for edge cases, like a short landscape phone where even the fixed height might not be enough:

#pd-map .leaflet-popup.pd-wrap .leaflet-popup-content {
  max-height: 78vh !important; overflow-y: auto !important;
}

If a card is ever still too tall for a specific screen, it scrolls inside itself rather than trying to force the map to accommodate it and triggering the same failure again.

The honest limitation

All three fixes were verified by actually reproducing the problem in a resized browser and measuring real pixel heights before and after. What couldn't be verified in that environment was the real iOS touch event sequence itself. A desktop browser resized to phone width renders at the right dimensions but doesn't fire the same touch events a physical iPhone does. The dimensional fix is proven. The final confirmation that the touch behavior itself is resolved requires testing on an actual device.

If you're adapting this for your own project with more than six or seven locations, test on a real phone before assuming this is fully solved for your case, particularly if any of your popup content is longer than what's here.


Before going live: three things to check

Image domain. If you build and test this on a staging URL, remember to repoint the IMG object to your production media URLs before launch. It's an easy thing to forget because staging images will keep working even after the site goes live, right up until staging goes down.

Font loading. If your theme already enqueues the fonts you're using, delete the Google Fonts <link> in Step 1 to avoid a duplicate load.

External dependencies. This setup pulls Leaflet and the cluster plugin from cdnjs, and map tiles from CartoDB, at runtime. That's normal and reliable, but if you'd rather not depend on a third-party CDN at all, both Leaflet files can be self-hosted in your theme instead.


Adapting this for your own project

The only section that needs real editing is Step 3: your locations, images, and coordinates. Steps 1, 2, 4, and 5 are structural and should work with genericized brand colors and your own container ID if you'd rather not reuse pd-map directly.

If you have significantly more than six or seven locations, two things change. removeOutsideVisibleBounds: false stops being free — at higher marker counts it becomes a real performance tradeoff, and you'd want to test whether the default clustering behavior is actually a problem for your card sizes before disabling it outright. And the popup card height math in the iOS section is worth re-verifying with your own content length, since a longer description will push the card taller than the Punjabi Dhaba cards were.

For six locations and similar, this exact code works.


The complete file

Everything above, combined into one block, ready to paste into an Elementor HTML widget after swapping in your own colors, images, and location data:

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/MarkerCluster.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@400;600&family=Source+Sans+3:wght@400;600&display=swap" rel="stylesheet">

<style>
  #pd-map {
    width: 100%; height: 560px; overflow: hidden;
    border-radius: 0;
    font-family: "Source Sans Pro","Source Sans 3", sans-serif;
  }
  @media (max-width: 640px) {
    #pd-map { height: 600px; }
    #pd-map .pd-card__img { height: 150px !important; }
  }

  #pd-map .pd-pin,
  #pd-map svg.pd-pin {
    width: 43px !important; height: 58px !important; max-width: none !important;
    display: block !important; filter: drop-shadow(0 4px 6px rgba(0,0,0,.4));
  }

  #pd-map .pd-cluster {
    width: 48px !important; height: 48px !important; border-radius: 50% !important;
    background: #DCA26B !important; border: 3px solid #fff !important;
    box-shadow: 0 3px 7px rgba(0,0,0,.4) !important;
    display: flex !important; align-items: center !important; justify-content: center !important;
  }
  #pd-map .pd-cluster span {
    font-family: "Jost", sans-serif !important; font-weight: 600 !important;
    font-size: 17px !important; color: #080D16 !important;
  }

  #pd-map .leaflet-popup.pd-wrap .leaflet-popup-content-wrapper {
    padding: 0 !important;
    border-radius: 16px !important;
    overflow: hidden !important;
    background-color: rgba(255, 255, 255, 0.66) !important;
    -webkit-backdrop-filter: blur(10px) !important;
    backdrop-filter: blur(10px) !important;
    box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 16px !important;
  }
  #pd-map .leaflet-popup.pd-wrap .leaflet-popup-content {
    margin: 0 !important; width: 280px !important;
  }
  #pd-map .leaflet-popup.pd-wrap .leaflet-popup-tip {
    background: rgba(255, 255, 255, 0.66) !important;
    box-shadow: rgba(0, 0, 0, 0.25) 0px 0px 16px !important;
  }

  #pd-map .pd-card__img {
    width: 100% !important; height: 170px !important; max-width: none !important;
    object-fit: cover !important; display: block !important;
    margin: 0 !important; border-radius: 0 !important;
  }

  #pd-map .pd-card__body { padding: 24px !important; }

  #pd-map .pd-card__title {
    margin: 0 0 8px !important; padding: 0 !important;
    font-family: "Jost", sans-serif !important; font-weight: 400 !important;
    text-transform: none !important; letter-spacing: normal !important;
    font-size: 20px !important; line-height: 1.25 !important; color: #DCA26B !important;
  }
  #pd-map .pd-card__addr {
    display: inline-block !important; margin: 0 0 12px !important;
    font-family: "Jost", sans-serif !important; font-weight: 400 !important;
    font-size: 12px !important; color: #000 !important; text-decoration: none !important;
  }
  #pd-map .pd-card__desc {
    margin: 0 0 14px !important; padding: 0 !important;
    font-family: "Source Sans Pro","Source Sans 3", sans-serif !important;
    font-weight: 400 !important; font-size: 14px !important; line-height: 1.5 !important; color: #000 !important;
  }
  #pd-map .pd-card__phone {
    font-family: "Jost", sans-serif !important; font-weight: 400 !important;
    font-size: 14px !important; color: #000 !important; text-decoration: none !important;
  }
  #pd-map .pd-card__addr:hover, #pd-map .pd-card__phone:hover { color: #DCA26B !important; }

  #pd-map .leaflet-popup.pd-wrap .leaflet-popup-content {
    max-height: 78vh !important; overflow-y: auto !important;
  }
</style>

<div id="pd-map"></div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.5.3/leaflet.markercluster.js"></script>
<script>
(function () {

  var IMG = {
    prievozska: "https://yourdomain.com/wp-content/uploads/2026/07/1.webp",
    cubicon: "https://yourdomain.com/wp-content/uploads/2026/07/3.webp",
    skypark: "https://yourdomain.com/wp-content/uploads/2026/07/5.webp",
    nivy: "https://yourdomain.com/wp-content/uploads/2026/07/7.webp",
    sancova: "https://yourdomain.com/wp-content/uploads/2026/07/9.webp",
    piestany: "https://yourdomain.com/wp-content/uploads/2026/07/11.webp",
  };

  var venues = [
    {
      name: "Punjabi Dhaba Prievozská",
      address: "Prievozská 18, 821 09 Bratislava",
      mapUrl: "https://maps.app.goo.gl/zQrWBxRPnA4nnRDo6",
      phone: "0948 076 528",
      description: "Naša najväčšia reštaurácia s pravou indickou atmosférou a kapacitou až do 200 miest. Ideálna na večerné posedenia s rodinou, priateľmi, alebo pri organizovaní väčších akcií, ako osláv, či teambuildingov.",
      image: IMG.prievozska,
      coords: [48.148296, 17.1480764]
    },
    {
      name: "Punjabi Dhaba Cubicon",
      address: "Staré Grunty 24, 841 04 Bratislava",
      mapUrl: "https://maps.app.goo.gl/kq2JsfLVdiuKP9JD9",
      phone: "0911 768 951",
      description: "Útulná prevádzka s menšou kapacitou, ktorá sa teší vysokej obľúbenosti mladými rodinkami, alebo partiami priateľov, ktoré tu nájdu súkromie a pokojnú atmosféru.",
      image: IMG.cubicon,
      coords: [48.1578282, 17.0701555]
    },
    {
      name: "Punjabi Dhaba Šancová",
      address: "Šancová 92, 831 04 Bratislava",
      mapUrl: "https://maps.app.goo.gl/p8ZM4SYSqE7JcYK76",
      phone: "0910 115 591",
      description: "Úplne prvá a zároveň naša najmenšia prevádzka Punjabi Dhaba, ktorá bola otvorená na Slovensku už v roku 2015. Má autentický charakter a atmosféru, je ideálna pre ľudí, ktorí chcú spoznať prostredie pravej indickej pocestnej reštaurácie.",
      image: IMG.sancova,
      coords: [48.1577262, 17.121859]
    },
    {
      name: "Punjabi Dhaba Sky Park",
      address: "SKY PARK Offices, Bottova 2a, 811 09 Bratislava",
      mapUrl: "https://maps.app.goo.gl/wT4YktSufbDoNQoM9",
      phone: "0914 411 978",
      description: "Moderná prevádzka prispôsobená požiadavkám náročnejších klientov v lokalite Sky Park, vhodná pre obedy s kolegami, biznis stretnutia, ale aj menšie eventy, ako napríklad teambuildingy vo forme nie len večere, ale aj zážitkového varenia s výkladom.",
      image: IMG.skypark,
      coords: [48.1433829, 17.1256078]
    },
    {
      name: "Punjabi Dhaba Nivy",
      address: "OC Nivy, Mlynské nivy 5/A, 821 08 Bratislava",
      mapUrl: "https://maps.app.goo.gl/12nzhmw8AWQ8ZqD98",
      phone: "0914 411 978",
      description: "Prevádzka vo forme street-foodového kiosku v stravovacej zóne nákupného centra Nivy, ktorá ponúka nie len indické snacky, ale aj vybrané obľúbené jedlá našich zákazníkov.",
      image: IMG.nivy,
      coords: [48.1464901, 17.1308627]
    },
    {
      name: "Punjabi Dhaba Piešťany",
      address: "Nitrianska 1857/24, 921 01 Piešťany",
      mapUrl: "https://maps.app.goo.gl/Vys8CB72viuCANPM6",
      phone: "0904 372 438",
      description: "Najnovšia prevádzka, jediná, ktorá sa nenachádza v Bratislave. Prináša kvalitu a jedinečnosť chutí Punjabi Dhaba aj mimo hlavného mesta, aby bola opäť raz bližšie ku svojim zákazníkom v rámci Slovenska.",
      image: IMG.piestany,
      coords: [48.5869733, 17.8326497]
    }
  ];

  var map = L.map("pd-map", { scrollWheelZoom: false });

  L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
    attribution: "&copy; OpenStreetMap &copy; CARTO", maxZoom: 19
  }).addTo(map);

  var pinSvg =
    '<svg class="pd-pin" width="43" height="58" viewBox="0 0 34 46" xmlns="http://www.w3.org/2000/svg">' +
      '<path d="M17 0C7.6 0 0 7.6 0 17c0 12 17 29 17 29s17-17 17-29C34 7.6 26.4 0 17 0z" fill="#DCA26B"/>' +
      '<circle cx="17" cy="17" r="6" fill="#080D16"/>' +
    '</svg>';
  var pdIcon = L.divIcon({
    html: pinSvg, className: "", iconSize: [43, 58], iconAnchor: [22, 58], popupAnchor: [0, -54]
  });

  function esc(s){ return String(s).replace(/[&<>"]/g, function(c){
    return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c];
  }); }

  function popupHtml(v){
    var img  = v.image ? '<img class="pd-card__img" src="'+v.image+'" alt="'+esc(v.name)+'">' : '';
    var addr = v.mapUrl
      ? '<a class="pd-card__addr" href="'+esc(v.mapUrl)+'" target="_blank" rel="noopener noreferrer">'+esc(v.address)+'</a>'
      : '<span class="pd-card__addr">'+esc(v.address)+'</span>';
    var desc = v.description ? '<p class="pd-card__desc">'+esc(v.description)+'</p>' : '';
    var tel  = v.phone.replace(/\s+/g, "");
    return img +
      '<div class="pd-card__body">' +
        '<h3 class="pd-card__title">'+esc(v.name)+'</h3>' +
        addr + desc +
        '<a class="pd-card__phone" href="tel:'+esc(tel)+'">'+esc(v.phone)+'</a>' +
      '</div>';
  }

  var cluster = L.markerClusterGroup({
    showCoverageOnHover: false, maxClusterRadius: 60,
    removeOutsideVisibleBounds: false,
    iconCreateFunction: function (c) {
      return L.divIcon({
        html: '<div class="pd-cluster"><span>' + c.getChildCount() + '</span></div>',
        className: "", iconSize: [48, 48]
      });
    }
  });

  venues.forEach(function (v) {
    cluster.addLayer(
      L.marker(v.coords, { icon: pdIcon })
        .bindPopup(popupHtml(v), { className: "pd-wrap", maxWidth: 300, closeButton: false, closeOnClick: false, autoClose: true, autoPanPadding: [16, 16] })
    );
  });
  map.addLayer(cluster);
  map.fitBounds(cluster.getBounds(), { padding: [45, 45] });

  var lastPopupOpen = 0;
  map.on("popupopen", function () { lastPopupOpen = Date.now(); });

  map.on("click", function () {
    map.scrollWheelZoom.enable();
    if (Date.now() - lastPopupOpen > 500) { map.closePopup(); }
  });
  map.on("mouseout", function () { map.scrollWheelZoom.disable(); });
})();
</script>

Swap the IMG object and venues array for your own locations, replace #DCA26B and #080D16 throughout with your brand colors, and this is ready to paste into an Elementor HTML widget.

Frequently asked questions

Do I need a Google Maps API key for this?

No. Leaflet is open source and the CartoDB Voyager tiles it uses are free for reasonable traffic, so there is no API key and no usage billing anywhere in this setup.

Will this work with the free version of Elementor?

Yes. The whole map goes into a standard HTML widget, which exists in Elementor free. Nothing here depends on Elementor Pro.

How many locations can it handle?

Six sits comfortably, and it scales to a few dozen without much thought. Past that, the removeOutsideVisibleBounds: false setting that fixes the mobile popup bug becomes a real performance tradeoff, so at hundreds of markers you would re-test it.

Why not just use Google My Maps or a paid tool?

You can, and for two locations a Google embed is fine. This route is for when you want the map to match your brand exactly and carry no monthly fee, which paid tools cannot both give you. The companion article covers that trade-off.

Do I have to host Leaflet myself?

No. It loads from a CDN, so there is nothing to install. If you would rather not depend on a third-party CDN, you can self-host the two Leaflet files in your theme instead.

Let's Connect

Ready to discuss your project? Reach out through any of these channels.

Based in Bratislava, Slovakia. Available for projects worldwide.

Try me — I'm alive