Map markers in HTML, without a map service

A map can be one SVG drawing, and each marker a button placed on top of it in percentages. No map service, no API key, and it scales with the page.

To put markers on a map in HTML without a map service, draw the map as an inline SVG, wrap it in a box with position: relative, and add one <button> per marker with left and top in percent.

The percentages keep each pin on its spot at every screen width.

Try it. Click a pin, or press Tab to move between them and Enter to open one.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Map with markers</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  p.hint { margin: 0 0 8px; font-size: 13px; color: #5b6270; }
  .map { position: relative; max-width: 560px; }   /* markers are placed in % of this box */
  .map > svg { display: block; width: 100%; height: auto; border-radius: 10px; }
  .pin {
    position: absolute; width: 28px; height: 36px; padding: 0;
    border: 0; background: none; cursor: pointer;
    transform: translate(-50%, -100%);   /* the tip of the pin sits on the point */
  }
  .pin svg { display: block; width: 100%; height: 100%; fill: #d9480f; }
  .pin[aria-expanded="true"] svg { fill: #1d4ed8; }
  .pin:focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; border-radius: 6px; }
  .popup {
    position: absolute; z-index: 2; width: 190px; padding: 10px 12px;
    background: #fff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2);
    font-size: 13px; line-height: 1.4;
  }
  .popup b { display: block; font-size: 14px; margin-bottom: 2px; padding-right: 20px; }
  .popup .x { position: absolute; right: 6px; top: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<p class="hint">Click or tab to a pin. Esc closes the popup.</p>
<div class="map" id="map">
  <svg viewBox="0 0 800 500" role="img" aria-label="Campus map">
    <rect width="800" height="500" fill="#eef1ea"/>
    <path d="M0 400 C160 360 280 450 440 410 S690 350 800 380 V500 H0Z" fill="#cfe3f5"/>
    <rect x="470" y="50" width="270" height="170" rx="18" fill="#d4ebcf"/>
    <g stroke="#fff" stroke-width="18" stroke-linecap="round" fill="none">
      <path d="M20 260 H780"/><path d="M300 20 V370"/><path d="M620 260 V350"/>
    </g>
    <g fill="#d8dce2">
      <rect x="60" y="60" width="180" height="150" rx="8"/>
      <rect x="340" y="60" width="100" height="160" rx="8"/>
      <rect x="60" y="290" width="200" height="70" rx="8"/>
      <rect x="340" y="290" width="240" height="70" rx="8"/>
    </g>
    <g font-family="system-ui, sans-serif" font-size="20" fill="#5b6270">
      <text x="560" y="140">Park</text><text x="330" y="470">River</text>
    </g>
  </svg>
</div>

<template id="pin-svg">
  <svg viewBox="0 0 24 32" aria-hidden="true"><path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 20 12 20s12-11 12-20C24 5.4 18.6 0 12 0z"/><circle cx="12" cy="12" r="4.5" fill="#fff"/></svg>
</template>

<script>
  // x and y are in the SVG's own units (viewBox 0 0 800 500)
  const places = [
    { x: 150, y: 135, name: 'Main hall', text: 'Open 7:00-22:00. Visitor badges at the front desk.' },
    { x: 390, y: 140, name: 'Library', text: 'Quiet floors 2 and 3. Printers by the stairs.' },
    { x: 605, y: 110, name: 'Park gate', text: 'Picnic tables and a water fountain.' },
    { x: 460, y: 325, name: 'Cafe', text: 'Coffee from 8:00. Seats by the river.' },
  ];

  const map = document.getElementById('map');
  const popup = document.createElement('div');
  popup.className = 'popup';
  popup.id = 'popup';
  popup.hidden = true;
  popup.innerHTML = '<button class="x" aria-label="Close">&times;</button><b></b><span></span>';

  places.forEach((p) => {
    const pin = document.createElement('button');
    pin.className = 'pin';
    pin.style.left = (p.x / 800 * 100) + '%';   // SVG units -> percent of the map
    pin.style.top = (p.y / 500 * 100) + '%';
    pin.setAttribute('aria-label', p.name);
    pin.setAttribute('aria-expanded', 'false');
    pin.setAttribute('aria-controls', 'popup');
    pin.append(document.getElementById('pin-svg').content.cloneNode(true));
    pin.addEventListener('click', () => (pin.getAttribute('aria-expanded') === 'true' ? close() : open(pin, p)));
    map.append(pin);
  });

  let current = null;

  function open(pin, p) {
    close();
    current = pin;
    popup.querySelector('b').textContent = p.name;
    popup.querySelector('span').textContent = p.text;
    pin.after(popup);   // right after its pin, so Tab goes pin -> popup -> next pin
    popup.hidden = false;
    pin.setAttribute('aria-expanded', 'true');
    place();
  }

  function close() {
    if (!current) return;
    popup.hidden = true;
    current.setAttribute('aria-expanded', 'false');
    current = null;
  }

  // Above the pin if there is room, otherwise below; never past the map's sides
  function place() {
    const m = map.getBoundingClientRect(), r = current.getBoundingClientRect();
    const w = popup.offsetWidth, h = popup.offsetHeight;
    const x = Math.max(4, Math.min(r.left + r.width / 2 - m.left - w / 2, m.width - w - 4));
    let y = r.top - m.top - h - 6;
    if (y < 4) y = r.bottom - m.top + 6;
    popup.style.left = x + 'px';
    popup.style.top = y + 'px';
  }

  popup.querySelector('.x').addEventListener('click', () => { const pin = current; close(); pin.focus(); });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && current) { const pin = current; close(); pin.focus(); }
  });
  // a click on the map itself (not a pin or the popup) closes it
  map.addEventListener('click', (e) => { if (!e.target.closest('.pin, .popup')) close(); });
  window.addEventListener('resize', () => { if (current) place(); });
</script>
</body>
</html>
Four pins on an SVG campus map. Each opens the same popup element, filled with that place's text.

This page is about maps you draw or already have as a picture: a campus, a venue, a trade show hall, an office floor plan.

For a real street map, embedding a Google map is the short route. For clickable areas on a photo, see HTML image maps.

The map: one SVG with a viewBox

The viewBox gives the drawing its own coordinate system. With a viewBox of 800 by 500, the point (240, 150) is always the same spot on the map, whatever size the SVG is drawn at.

<div class="map" style="position: relative">
  <svg viewBox="0 0 800 500" style="display: block; width: 100%; height: auto">
    <!-- rooms, roads, labels -->
  </svg>
  <!-- marker buttons go here -->
</div>

Two details matter. display: block removes the small gap under an inline SVG, so the wrapper is exactly as tall as the map.

In a 400 pixel wide test, the wrapper was 254 pixels tall without it and 250 with it, in Chromium, Firefox and WebKit.

Then height: auto keeps the 8 to 5 shape as the width changes. Scaling an SVG to fit covers the viewBox in more depth.

A bitmap works too. An <img> with width: 100% inside the same wrapper behaves the same way. Use the image's pixel size in place of the viewBox numbers.

Placing markers in percent

A marker's percent position is its map coordinate divided by the map size.

Read the point in SVG units, divide by the viewBox size, and the same percentages work at any width.
Read the point in SVG units, divide by the viewBox size, and the same percentages work at any width.
pin.style.left = (x / 800 * 100) + '%';
pin.style.top  = (y / 500 * 100) + '%';

Percent left and top on an absolutely positioned element are measured against the nearest positioned ancestor. That is the wrapper, which is why it needs position: relative. CSS position explains the rule.

Keep the places as data, one object per place, and create the buttons in a loop. Adding a place is then one more line in the array, not another block of HTML.

Put the tip of the pin on the point

left and top place the button's top-left corner. For a teardrop pin, that leaves the tip below and to the right of the place it marks.

Without the translate, the corner marks the place. With it, the tip does.
Without the translate, the corner marks the place. With it, the tip does.
.pin {
  position: absolute;
  transform: translate(-50%, -100%);
}

Percentages in translate() refer to the element's own size. So the pin moves left by half its width and up by its full height, and the tip lands on the point. For a round dot marker, use translate(-50%, -50%) to centre it instead.

One popup, placed next to the pin

Each pin does not need its own popup. The examples keep a single popup element and fill it with the text of whichever pin was pressed. Then they measure both boxes and place the popup:

  1. Centre it horizontally over the pin, then clamp it so it never crosses the map's left or right edge.
  2. Put it above the pin. If there is no room above, put it below.
  3. Recalculate on resize, because the pin moves when the map changes width.
const m = map.getBoundingClientRect();
const r = pin.getBoundingClientRect();
let x = r.left + r.width / 2 - m.left - popup.offsetWidth / 2;
x = Math.max(4, Math.min(x, m.width - popup.offsetWidth - 4));
let y = r.top - m.top - popup.offsetHeight - 6;
if (y < 4) y = r.bottom - m.top + 6;

The popover attribute is another way to show the box, and CSS anchor positioning can tie it to the pin without the measuring code. The measuring version above is shown because it is short and does not depend on either feature.

Keyboard and screen readers

Markers are buttons, so Tab, Enter and Space work without extra code. Four more things make the map usable without a mouse:

  • A name on every pin. The pin is a picture, so add an aria-label with the place name.
  • State on the pin. aria-expanded="true" while its popup is open tells a screen reader the button opened something.
  • The popup right after its pin. Tab order follows the order of elements in the page, not their position on screen. Moving the popup with pin.after(popup) puts its close button next in line.
  • Escape closes, and focus goes back. Return focus to the pin so the user continues from where they were.

Order the places from top to bottom in the data. Then Tab walks across the map in reading order rather than in the order you happened to add them. The tabindex guide explains why reordering with positive tabindex values causes more trouble than it solves.

Grouping markers that overlap

Put thirty cafes on a small map and the ones downtown pile up into a blob that no one can tap. The usual fix is clustering: markers that would be drawn close together are replaced by one bubble with a count.

Markers within 40 screen pixels of each other become one bubble. Zooming in splits them apart.
Markers within 40 screen pixels of each other become one bubble. Zooming in splits them apart.

The grouping works in screen pixels, not map units, because overlap depends on how big the map is drawn. The same thirty points may need more bubbles on a phone than on a laptop. The example below:

  • turns every place into a pixel position at the current size,
  • takes each ungrouped marker in turn and pulls in every other ungrouped marker within 40 pixels,
  • draws a group at the average position of its members,
  • regroups whenever the map changes width, using a ResizeObserver.
Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clustering nearby markers</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; font-size: 13px; }
  .bar button { font: inherit; padding: 4px 11px; border: 1px solid #c9cdd4; border-radius: 6px; background: #fff; cursor: pointer; }
  #status { color: #5b6270; }
  .view { max-width: 560px; overflow: hidden; border-radius: 10px; }   /* scrolls when zoomed in */
  .map { position: relative; width: 100%; }                          /* width grows with zoom */
  .map > svg { display: block; width: 100%; height: auto; }
  .m {
    position: absolute; transform: translate(-50%, -50%);
    border: 2px solid #fff; border-radius: 50%; cursor: pointer; padding: 0;
    color: #fff; font: 700 13px system-ui, sans-serif;
  }
  .m.one { width: 18px; height: 18px; background: #d9480f; }
  .m.group { width: 34px; height: 34px; background: #1d4ed8; box-shadow: 0 0 0 5px rgba(29, 78, 216, .25); }
  .m:focus-visible { outline: 3px solid #111; outline-offset: 2px; }
</style>
</head>
<body>
<div class="bar">
  <button id="out" aria-label="Zoom out">&minus;</button>
  <button id="in" aria-label="Zoom in">+</button>
  <label><input type="checkbox" id="on" checked> Group nearby markers</label>
  <span id="status" aria-live="polite"></span>
</div>
<div class="view" id="view" tabindex="0" aria-label="Map of cafes">
  <div class="map" id="map">
    <svg viewBox="0 0 800 500" aria-hidden="true">
      <rect width="800" height="500" fill="#eef1ea"/>
      <path d="M0 400 C160 360 280 450 440 410 S690 350 800 380 V500 H0Z" fill="#cfe3f5"/>
      <rect x="470" y="50" width="270" height="170" rx="18" fill="#d4ebcf"/>
      <g stroke="#fff" stroke-width="18" stroke-linecap="round" fill="none">
        <path d="M20 260 H780"/><path d="M300 20 V370"/><path d="M620 260 V350"/>
      </g>
      <g fill="#d8dce2">
        <rect x="60" y="60" width="180" height="150" rx="8"/><rect x="340" y="60" width="100" height="160" rx="8"/>
        <rect x="60" y="290" width="200" height="70" rx="8"/><rect x="340" y="290" width="240" height="70" rx="8"/>
      </g>
    </svg>
  </div>
</div>

<script>
  // 30 cafes in SVG units (viewBox 0 0 800 500). Many sit close together downtown.
  const cafes = [
    [310,240],[322,252],[334,238],[318,270],[340,262],[352,248],[296,262],[328,284],[360,272],[344,226],
    [150,120],[170,140],[160,100],[190,130],[640,100],[660,120],[700,160],[520,180],
    [420,320],[440,330],[460,318],[480,336],[500,322],[90,320],[120,340],[220,320],
    [720,300],[740,320],[600,300],[560,90],
  ];
  const RADIUS = 40;   // markers closer than this many screen pixels are grouped
  let zoom = 1;

  const view = document.getElementById('view');
  const map = document.getElementById('map');
  const status = document.getElementById('status');
  const on = document.getElementById('on');

  function render() {
    map.querySelectorAll('.m').forEach((el) => el.remove());
    const w = map.offsetWidth, h = map.offsetHeight;
    // screen position of every cafe at the current size
    const pts = cafes.map(([x, y]) => ({ x: x / 800 * w, y: y / 500 * h }));

    // Greedy grouping: take a marker, pull in every free marker within RADIUS
    const groups = [];
    const used = new Array(pts.length).fill(false);
    pts.forEach((p, i) => {
      if (used[i]) return;
      const g = [i];
      used[i] = true;
      if (on.checked) {
        pts.forEach((q, j) => {
          if (!used[j] && Math.hypot(p.x - q.x, p.y - q.y) < RADIUS) { g.push(j); used[j] = true; }
        });
      }
      groups.push(g);
    });

    groups.forEach((g) => {
      // place the group at the average of its members, in percent
      const cx = g.reduce((s, i) => s + cafes[i][0], 0) / g.length;
      const cy = g.reduce((s, i) => s + cafes[i][1], 0) / g.length;
      const b = document.createElement('button');
      b.className = 'm ' + (g.length > 1 ? 'group' : 'one');
      b.style.left = (cx / 800 * 100) + '%';
      b.style.top = (cy / 500 * 100) + '%';
      if (g.length > 1) {
        b.textContent = g.length;
        b.setAttribute('aria-label', g.length + ' cafes here, zoom in');
        b.addEventListener('click', () => { zoomTo(zoom * 2, cx, cy); view.focus(); });
      } else {
        b.setAttribute('aria-label', 'Cafe');
      }
      map.append(b);
    });
    status.textContent = `Zoom ${zoom}x: ${cafes.length} cafes, ${groups.length} markers`;
  }

  // Change zoom and keep the point (x, y in SVG units) in the middle of the view
  function zoomTo(z, x = 400, y = 250) {
    zoom = Math.min(Math.max(z, 1), 4);
    map.style.width = zoom * 100 + '%';
    view.style.overflow = zoom > 1 ? 'auto' : 'hidden';
    render();
    view.scrollLeft = x / 800 * map.offsetWidth - view.clientWidth / 2;
    view.scrollTop = y / 500 * map.offsetHeight - view.clientHeight / 2;
  }

  // At zoom 1 the view is as tall as the map; zoomed in, it keeps that height and scrolls
  function sizeView() { view.style.height = (view.offsetWidth * 500 / 800) + 'px'; }

  function centre() {   // the SVG point in the middle of the view right now
    return [(view.scrollLeft + view.clientWidth / 2) / map.offsetWidth * 800,
            (view.scrollTop + view.clientHeight / 2) / map.offsetHeight * 500];
  }
  document.getElementById('in').addEventListener('click', () => zoomTo(zoom * 2, ...centre()));
  document.getElementById('out').addEventListener('click', () => zoomTo(zoom / 2, ...centre()));
  on.addEventListener('change', render);
  // regroup whenever the map's width changes (window resize, phone rotation)
  new ResizeObserver(() => { sizeView(); render(); }).observe(view);
</script>
</body>
</html>
Thirty cafes. Click a blue bubble to zoom in on it, or untick the box to see every marker at once.

On the 680 pixel wide test frame, the thirty cafes showed as 13 markers at zoom 1. On a 366 pixel frame they showed as 9, because the points sit closer together on a smaller map.

This greedy grouping is simple, and it does not look for the best possible groups. Two bubbles can end up side by side. For a map like this one that is acceptable; map libraries offer clustering built for much larger sets of points.

A finished example: map, list and filters

A map on its own is hard to scan, especially with a screen reader. This version adds a list of the same places next to the map, and filters by type.

Picking a place in the list opens its popup on the map, and Escape sends focus back to the list.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Place finder: map, list and filters</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .filters { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 8px; font-size: 13px; }
  .wrap { display: grid; grid-template-columns: minmax(0, 1fr) 170px; gap: 10px; max-width: 680px; }
  @media (max-width: 520px) { .wrap { grid-template-columns: 1fr; } }
  .map { position: relative; align-self: start; }
  .map > svg { display: block; width: 100%; height: auto; border-radius: 10px; }
  .pin {
    position: absolute; width: 26px; height: 34px; padding: 0; border: 0; background: none; cursor: pointer;
    transform: translate(-50%, -100%);
  }
  .pin svg { display: block; width: 100%; height: 100%; fill: var(--c); }
  .pin[aria-expanded="true"] svg { fill: #111; }
  .pin:focus-visible, .list button:focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; border-radius: 6px; }
  .popup {
    position: absolute; z-index: 2; width: 180px; padding: 10px 12px; background: #fff;
    border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, .2); font-size: 13px; line-height: 1.4;
  }
  .popup b { display: block; font-size: 14px; padding-right: 20px; }
  .popup .x { position: absolute; right: 6px; top: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; }
  .list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; align-content: start; }
  @media (max-width: 520px) { .list { grid-template-columns: 1fr 1fr; } }
  .list button {
    width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 6px 8px;
    border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; cursor: pointer;
  }
  .list button[aria-pressed="true"] { border-color: #111; background: #eef1f5; }
  .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; background: var(--c); }
  .cafe { --c: #d9480f; } .study { --c: #1d4ed8; } .green { --c: #2f9e44; }
</style>
</head>
<body>
<div class="filters" id="filters">
  <label><input type="checkbox" value="cafe" checked> <span class="dot cafe"></span>Cafes</label>
  <label><input type="checkbox" value="study" checked> <span class="dot study"></span>Study spaces</label>
  <label><input type="checkbox" value="green" checked> <span class="dot green"></span>Green spaces</label>
</div>
<div class="wrap">
  <div class="map" id="map">
    <svg viewBox="0 0 800 500" role="img" aria-label="Campus map. The same places are listed next to it.">
      <rect width="800" height="500" fill="#eef1ea"/>
      <path d="M0 400 C160 360 280 450 440 410 S690 350 800 380 V500 H0Z" fill="#cfe3f5"/>
      <rect x="470" y="50" width="270" height="170" rx="18" fill="#d4ebcf"/>
      <g stroke="#fff" stroke-width="18" stroke-linecap="round" fill="none">
        <path d="M20 260 H780"/><path d="M300 20 V370"/><path d="M620 260 V350"/>
      </g>
      <g fill="#d8dce2">
        <rect x="60" y="60" width="180" height="150" rx="8"/><rect x="340" y="60" width="100" height="160" rx="8"/>
        <rect x="60" y="290" width="200" height="70" rx="8"/><rect x="340" y="290" width="240" height="70" rx="8"/>
      </g>
    </svg>
  </div>
  <ul class="list" id="list" aria-label="Places"></ul>
</div>

<script>
  // x, y in SVG units (viewBox 0 0 800 500). Sorted top to bottom so Tab reads the map in order.
  const places = [
    { x: 605, y: 105, type: 'green', name: 'Park gate', text: 'Picnic tables, water fountain.' },
    { x: 390, y: 120, type: 'study', name: 'Library', text: 'Quiet floors 2 and 3.' },
    { x: 150, y: 140, type: 'cafe', name: 'Hall Coffee', text: 'Ground floor, main hall. From 8:00.' },
    { x: 700, y: 180, type: 'green', name: 'Rose garden', text: 'Benches in the shade.' },
    { x: 200, y: 180, type: 'study', name: 'Reading room', text: 'Open late on weekdays.' },
    { x: 460, y: 325, type: 'cafe', name: 'River Cafe', text: 'Seats by the water.' },
    { x: 160, y: 330, type: 'study', name: 'Lab commons', text: 'Group tables, whiteboards.' },
    { x: 690, y: 330, type: 'cafe', name: 'Dock Kiosk', text: 'Takeaway only.' },
  ].sort((a, b) => a.y - b.y || a.x - b.x);

  const map = document.getElementById('map');
  const list = document.getElementById('list');
  const popup = document.createElement('div');
  popup.className = 'popup';
  popup.id = 'popup';
  popup.innerHTML = '<button class="x" aria-label="Close">&times;</button><b></b><span></span>';
  let current = null;   // the open place
  let opener = null;    // the pin or list button that opened it, for Esc

  const pinSvg = '<svg viewBox="0 0 24 32" aria-hidden="true"><path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 20 12 20s12-11 12-20C24 5.4 18.6 0 12 0z"/><circle cx="12" cy="12" r="4.5" fill="#fff"/></svg>';

  places.forEach((p) => {
    p.pin = document.createElement('button');
    p.pin.className = 'pin ' + p.type;
    p.pin.style.left = (p.x / 800 * 100) + '%';
    p.pin.style.top = (p.y / 500 * 100) + '%';
    p.pin.innerHTML = pinSvg;
    p.pin.setAttribute('aria-label', p.name);
    p.pin.setAttribute('aria-expanded', 'false');
    p.pin.addEventListener('click', () => (current === p ? close() : open(p)));
    map.append(p.pin);

    // the same place in the list: a second way in, easier to scan with a screen reader
    const li = document.createElement('li');
    p.item = document.createElement('button');
    p.item.innerHTML = `<span class="dot ${p.type}"></span>${p.name}`;
    p.item.setAttribute('aria-pressed', 'false');
    p.item.addEventListener('click', () => (current === p ? close() : open(p)));
    li.append(p.item);
    list.append(li);
  });

  function open(p) {
    close();
    current = p;
    opener = document.activeElement.closest('button') || p.pin;
    popup.querySelector('b').textContent = p.name;
    popup.querySelector('span').textContent = p.text;
    p.pin.after(popup);
    p.pin.setAttribute('aria-expanded', 'true');
    p.item.setAttribute('aria-pressed', 'true');
    place();
  }

  function close() {
    if (!current) return;
    popup.remove();
    current.pin.setAttribute('aria-expanded', 'false');
    current.item.setAttribute('aria-pressed', 'false');
    current = null;
  }

  function place() {
    const m = map.getBoundingClientRect(), r = current.pin.getBoundingClientRect();
    const w = popup.offsetWidth, h = popup.offsetHeight;
    const x = Math.max(4, Math.min(r.left + r.width / 2 - m.left - w / 2, m.width - w - 4));
    let y = r.top - m.top - h - 6;
    if (y < 4) y = r.bottom - m.top + 6;
    popup.style.left = x + 'px';
    popup.style.top = y + 'px';
  }

  // Filters hide both the pin and its list entry
  document.getElementById('filters').addEventListener('change', () => {
    const shown = [...document.querySelectorAll('#filters input:checked')].map((i) => i.value);
    places.forEach((p) => {
      const hide = !shown.includes(p.type);
      p.pin.hidden = hide;
      p.item.parentElement.hidden = hide;
      if (hide && current === p) close();
    });
  });

  // Closing from the keyboard puts focus back where the user was
  popup.querySelector('.x').addEventListener('click', () => { close(); opener.focus(); });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && current) { close(); opener.focus(); }
  });
  map.addEventListener('click', (e) => { if (!e.target.closest('.pin, .popup')) close(); });
  window.addEventListener('resize', () => { if (current) place(); });
</script>
</body>
</html>
Tick and untick the types, or pick a place from the list. On a narrow screen the list moves under the map.
Piece What it does Where in the code
Places array One object per place, sorted top to bottom places
Pin buttons Percent position, aria-label, aria-expanded p.pin
List buttons A second way to open each place p.item
Filters Hide pin and list entry together change listener
Popup One element, moved after the open pin open(), place()

If the map is a floor plan that already exists as a PDF or a drawing, export it to SVG first. Uploading a floor plan covers keeping its measurements readable.

When it does not work

What you see Cause Fix
Markers drift away from their places on a phone Positions in pixels Use percent for left and top
All markers sit in the top-left corner The wrapper is not positioned position: relative on the wrapper
Markers sit slightly too low A gap under the inline SVG makes the box taller than the map display: block on the SVG
The pin hangs below the point Only left and top are set Add translate(-50%, -100%)
The popup is cut off at the edge It is centred on the pin without a limit Clamp x between the map's edges
An open popup covers the next pin Popups sit above the pins Close on Escape or on a click on the map
Tab skips the popup The popup is at the end of the page Insert it right after the pin

A map is something people want to click, and a screenshot cannot open a popup. An .html file sent as an attachment may show up as code, or not open at all, on a phone. Opening an HTML file on a phone explains why.

To send the working map, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can open the pins and filter the list themselves. If you move a marker or add a place later, the same link shows the new version.

Questions people ask

Can I add markers to a map without Google Maps or another map service?

Yes, if you supply the map yourself. Draw it as an inline SVG (a campus, a venue, an office floor plan), wrap it in a box with position: relative, and place each marker with left and top in percent. Everything runs in the page with no key and no network request.

Why use percentages instead of pixels for marker positions?

A percentage of the wrapper box stays tied to the same spot on the map at every width. Pixel positions are only right at the one width you measured them at, so the markers drift as soon as the map gets narrower on a phone.

Should a map marker be a button or a div?

A button. It can be reached with Tab, it opens on Enter and Space, and screen readers announce it as something to press. Give each one an aria-label with the place name, because the pin itself has no text.

How do I turn latitude and longitude into percentages?

Note the longitude of the map's left and right edges and the latitude of its top and bottom edges. For a small area such as a campus or a town centre, a straight proportion between those edges is close enough. Over large areas the map projection matters, and a map library does that maths for you.

When should I use a map library instead?

When you need a real street map that pans and zooms across a city or a country, drawn from map tiles. Libraries such as Leaflet, OpenLayers and MapLibre GL JS are built for that. For one drawing with a handful of places, the approach on this page is shorter.

Keep reading