Open demo

CSS code

HTML
<div><span class="lbl">City guide with custom pins and popups</span>
<section class="lf" aria-labelledby="lf-guide-title">
  <h2 id="lf-guide-title">An afternoon in central Lisbon</h2>
  <p class="lf-sub">Five stops within a 40-minute walk. Select a pin for details.</p>
  <div class="lf-map" data-leaflet role="region" aria-label="Map of central Lisbon with five places"></div>
  <script type="application/json">[{"name":"Castelo de São Jorge","note":"Moorish castle walls and the city’s highest lookout.","lat":38.7139,"lng":-9.1335,"type":"sight"},
  {"name":"Miradouro de Santa Luzia","note":"Tiled terrace over the rooftops of Alfama.","lat":38.7118,"lng":-9.1302,"type":"view"},
  {"name":"Praça do Comércio","note":"Riverside square at the foot of Rua Augusta.","lat":38.7075,"lng":-9.1364,"type":"sight"},
  {"name":"Mercado da Ribeira","note":"Covered market and food hall by Cais do Sodré.","lat":38.7068,"lng":-9.1458,"type":"food"},
  {"name":"Jardim do Príncipe Real","note":"Shady garden with a giant cedar and kiosk café.","lat":38.7166,"lng":-9.1484,"type":"view"}]</script>
  <ul class="lf-legend"><li><span class="lf-swatch" style="background:#7c3aed" aria-hidden="true"></span>Sights</li><li><span class="lf-swatch" style="background:#be123c" aria-hidden="true"></span>Viewpoints</li><li><span class="lf-swatch" style="background:#0f766e" aria-hidden="true"></span>Food</li></ul>
  <p class="lf-keys">Keyboard: Tab to the map, then use the arrow keys to pan and + or − to zoom. Tab again to reach each pin; Enter opens its card.</p>
</section></div>
<div><span class="lbl">Map with a list that shows each place</span>
<section class="lf" data-leaflet-box aria-labelledby="lf-week-title">
  <h2 id="lf-week-title">Lisbon Design Week venues</h2>
  <p class="lf-sub">Four venues, 14–17 May. Choose one to centre it on the map.</p>
  <div class="lf-split">
    <ul class="lf-list">
      <li><button class="lf-item" type="button" data-place="0" aria-pressed="false"><strong>Riverside Pavilion</strong><small>Thu 14 May · Talks and the opening night. Belém, by the river.</small></button></li>
      <li><button class="lf-item" type="button" data-place="1" aria-pressed="false"><strong>Factory Courtyard</strong><small>Fri 15 May · Studio open days in a former textile mill.</small></button></li>
      <li><button class="lf-item" type="button" data-place="2" aria-pressed="false"><strong>Design Museum, Baixa</strong><small>Sat 16 May · Exhibition: forty years of Portuguese chairs.</small></button></li>
      <li><button class="lf-item" type="button" data-place="3" aria-pressed="false"><strong>Garden Auditorium</strong><small>Sun 17 May · Closing panel and awards in the park.</small></button></li>
    </ul>
    <div>
      <div class="lf-map" data-leaflet role="region" aria-label="Map of the four Design Week venues"></div>
      <script type="application/json">[{"name":"Riverside Pavilion","note":"Talks and the opening night. Belém, by the river.","lat":38.6957,"lng":-9.1923,"type":"sight","when":"Thu 14 May"},
  {"name":"Factory Courtyard","note":"Studio open days in a former textile mill.","lat":38.7034,"lng":-9.1785,"type":"food","when":"Fri 15 May"},
  {"name":"Design Museum, Baixa","note":"Exhibition: forty years of Portuguese chairs.","lat":38.7095,"lng":-9.1373,"type":"view","when":"Sat 16 May"},
  {"name":"Garden Auditorium","note":"Closing panel and awards in the park.","lat":38.7372,"lng":-9.1546,"type":"sight","when":"Sun 17 May"}]</script>
    </div>
  </div>
</section></div>
CSS
/* Leaflet 1.9.4 + OpenStreetMap tiles. Markers are L.divIcon pins in the style of set 05. */
.lf-map{height:420px;border-radius:16px;z-index:0;font:inherit;-webkit-text-size-adjust:100%;text-size-adjust:100%}
.lf-map:focus-visible{outline:3px solid #0f172a;outline-offset:3px}
.lf-marker{background:none;border:0}
.lf-marker svg{display:block;width:34px;height:42px;filter:drop-shadow(0 3px 4px rgba(15,23,42,.35))}
.lf-marker:focus-visible{outline:3px solid #0f172a;outline-offset:2px;border-radius:8px}
.lf-map .leaflet-control-zoom a:focus-visible,.lf-map .leaflet-control-attribution a:focus-visible,.lf-map .leaflet-popup-close-button:focus-visible{outline:3px solid #0f172a;outline-offset:1px}
.lf-map .leaflet-control-attribution{background:rgba(255,255,255,.92);color:#334155;font-size:.7rem}
.lf-map .leaflet-control-attribution a{color:#1d4ed8}
.lf-map .leaflet-popup-content-wrapper{border-radius:14px;box-shadow:0 16px 36px -14px rgba(15,23,42,.45)}
.lf-map .leaflet-popup-content{margin:14px 18px 14px 16px;font-size:.85rem;line-height:1.45;color:#334155}
.lf-map .leaflet-popup-content strong{display:block;font-size:1rem;color:#0f172a}
.lf-map .leaflet-popup-content a{display:inline-block;margin-top:6px;font-weight:700;color:#be123c;text-decoration:underline}
.lf-map .leaflet-popup-close-button{width:28px;height:28px;font-size:20px;color:#334155}
.lf-swatch{display:inline-block;width:12px;height:12px;border-radius:50%}
@media (max-width:640px){.lf-map{height:340px}}
@media (prefers-reduced-motion:reduce){.lf-map .leaflet-fade-anim .leaflet-popup,.lf-map .leaflet-zoom-anim .leaflet-zoom-animated{transition:none}}
.lf{--lf-ink:#0f172a;--lf-muted:#475569;background:#fff;border:1px solid #e2e8f0;border-radius:22px;padding:24px;color:var(--lf-ink)}
.lf h2{font-size:1.3rem;letter-spacing:-.02em}
.lf-sub{margin:4px 0 16px;font-size:.92rem;color:var(--lf-muted)}
.lf-legend{display:flex;flex-wrap:wrap;gap:6px 18px;margin-top:12px;list-style:none;font-size:.82rem;color:var(--lf-muted)}
.lf-legend li{display:flex;align-items:center;gap:7px}
.lf-keys{margin-top:8px;font-size:.8rem;color:var(--lf-muted)}
.lf-split{display:grid;gap:18px}
@media (min-width:860px){.lf-split{grid-template-columns:280px minmax(0,1fr)}}
.lf-list{list-style:none;display:grid;gap:8px;align-content:start}
.lf-item{display:grid;width:100%;gap:2px;padding:12px 14px;border:1px solid #e2e8f0;border-radius:14px;background:#fff;font:inherit;color:inherit;text-align:left;cursor:pointer;transition:border-color .15s,background-color .15s}
.lf-item small{font-size:.8rem;color:var(--lf-muted)}
.lf-item:hover{border-color:#94a3b8;background:#f8fafc}
.lf-item[aria-pressed="true"]{border-color:#0f172a;box-shadow:inset 0 0 0 1px #0f172a}
.lf-item:focus-visible{outline:3px solid #0f172a;outline-offset:2px}
@media (max-width:640px){.lf{padding:16px}}
@media (prefers-reduced-motion:reduce){.lf-item{transition:none}}
JavaScript
// Leaflet maps from data: each [data-leaflet] map reads its places from the JSON script that follows it.
(function () {
  if (!window.L) return;
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var colours = { sight: '#7c3aed', view: '#be123c', food: '#0f766e' };
  function icon(type) {
    return L.divIcon({ className: 'lf-marker', iconSize: [34, 42], iconAnchor: [17, 41], popupAnchor: [0, -36],
      html: '<svg viewBox="0 0 34 42" aria-hidden="true" focusable="false"><path d="M17 41C17 41 32 25.5 32 16A15 15 0 0 0 2 16C2 25.5 17 41 17 41Z" fill="' + (colours[type] || '#be123c') + '" stroke="#fff" stroke-width="2.5"/><circle cx="17" cy="16" r="5.5" fill="#fff"/></svg>' });
  }
  function popup(p) {
    var box = document.createElement('div'), t = document.createElement('strong'), n = document.createElement('span'), a = document.createElement('a');
    t.textContent = p.name; n.textContent = (p.when ? p.when + '. ' : '') + p.note;
    a.href = 'https://www.openstreetmap.org/directions?route=%3B' + p.lat + '%2C' + p.lng; a.textContent = 'Directions';
    a.target = '_blank'; a.rel = 'noopener'; a.setAttribute('aria-label', 'Directions to ' + p.name + ' (opens in a new tab)');
    box.append(t, n, document.createElement('br'), a); return box;
  }
  document.querySelectorAll('[data-leaflet]').forEach(function (el) {
    var places = JSON.parse(el.nextElementSibling.textContent);
    var map = L.map(el, { scrollWheelZoom: false, zoomAnimation: !reduce, fadeAnimation: !reduce, markerZoomAnimation: !reduce });
    L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19,
      attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map);
    var markers = places.map(function (p) {
      return L.marker([p.lat, p.lng], { icon: icon(p.type), title: p.name, alt: p.name, riseOnHover: true }).bindPopup(popup(p)).addTo(map);
    });
    map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [40, 40] });
    // optional list of buttons that show a place on the map
    var box = el.closest('[data-leaflet-box]');
    if (box) box.querySelectorAll('[data-place]').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var m = markers[+btn.dataset.place];
        box.querySelectorAll('[data-place]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
        map.setView(m.getLatLng(), 15, { animate: !reduce });
        m.openPopup();
      });
    });
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">City guide with custom pins and popups</span>
<section class="lf card rounded-4 p-3 p-sm-4" aria-labelledby="lf-guide-title">
  <h2 id="lf-guide-title" class="h4 fw-bold mb-1">An afternoon in central Lisbon</h2>
  <p class="lf-sub text-body-secondary mb-3">Five stops within a 40-minute walk. Select a pin for details.</p>
  <div class="lf-map" data-leaflet role="region" aria-label="Map of central Lisbon with five places"></div>
  <script type="application/json">[{"name":"Castelo de São Jorge","note":"Moorish castle walls and the city’s highest lookout.","lat":38.7139,"lng":-9.1335,"type":"sight"},
  {"name":"Miradouro de Santa Luzia","note":"Tiled terrace over the rooftops of Alfama.","lat":38.7118,"lng":-9.1302,"type":"view"},
  {"name":"Praça do Comércio","note":"Riverside square at the foot of Rua Augusta.","lat":38.7075,"lng":-9.1364,"type":"sight"},
  {"name":"Mercado da Ribeira","note":"Covered market and food hall by Cais do Sodré.","lat":38.7068,"lng":-9.1458,"type":"food"},
  {"name":"Jardim do Príncipe Real","note":"Shady garden with a giant cedar and kiosk café.","lat":38.7166,"lng":-9.1484,"type":"view"}]</script>
  <ul class="list-inline small text-body-secondary mt-3 mb-0"><li class="list-inline-item d-inline-flex align-items-center gap-2 me-3"><span class="lf-swatch" style="background:#7c3aed" aria-hidden="true"></span>Sights</li><li class="list-inline-item d-inline-flex align-items-center gap-2 me-3"><span class="lf-swatch" style="background:#be123c" aria-hidden="true"></span>Viewpoints</li><li class="list-inline-item d-inline-flex align-items-center gap-2 me-3"><span class="lf-swatch" style="background:#0f766e" aria-hidden="true"></span>Food</li></ul>
  <p class="small text-body-secondary mt-2 mb-0">Keyboard: Tab to the map, then use the arrow keys to pan and + or − to zoom. Tab again to reach each pin; Enter opens its card.</p>
</section></div>
<div><span class="lbl">Map with a list that shows each place</span>
<section class="lf card rounded-4 p-3 p-sm-4" data-leaflet-box aria-labelledby="lf-week-title">
  <h2 id="lf-week-title" class="h4 fw-bold mb-1">Lisbon Design Week venues</h2>
  <p class="lf-sub text-body-secondary mb-3">Four venues, 14–17 May. Choose one to centre it on the map.</p>
  <div class="row g-3">
    <div class="col-12 lf-side">
      <ul class="list-unstyled d-grid gap-2 mb-0">
        <li><button class="lf-item list-group-item list-group-item-action d-grid gap-1 px-3 py-2" type="button" data-place="0" aria-pressed="false"><strong>Riverside Pavilion</strong><small class="text-body-secondary">Thu 14 May · Talks and the opening night. Belém, by the river.</small></button></li>
        <li><button class="lf-item list-group-item list-group-item-action d-grid gap-1 px-3 py-2" type="button" data-place="1" aria-pressed="false"><strong>Factory Courtyard</strong><small class="text-body-secondary">Fri 15 May · Studio open days in a former textile mill.</small></button></li>
        <li><button class="lf-item list-group-item list-group-item-action d-grid gap-1 px-3 py-2" type="button" data-place="2" aria-pressed="false"><strong>Design Museum, Baixa</strong><small class="text-body-secondary">Sat 16 May · Exhibition: forty years of Portuguese chairs.</small></button></li>
        <li><button class="lf-item list-group-item list-group-item-action d-grid gap-1 px-3 py-2" type="button" data-place="3" aria-pressed="false"><strong>Garden Auditorium</strong><small class="text-body-secondary">Sun 17 May · Closing panel and awards in the park.</small></button></li>
      </ul>
    </div>
    <div class="col-12 lf-main">
      <div class="lf-map" data-leaflet role="region" aria-label="Map of the four Design Week venues"></div>
      <script type="application/json">[{"name":"Riverside Pavilion","note":"Talks and the opening night. Belém, by the river.","lat":38.6957,"lng":-9.1923,"type":"sight","when":"Thu 14 May"},
  {"name":"Factory Courtyard","note":"Studio open days in a former textile mill.","lat":38.7034,"lng":-9.1785,"type":"food","when":"Fri 15 May"},
  {"name":"Design Museum, Baixa","note":"Exhibition: forty years of Portuguese chairs.","lat":38.7095,"lng":-9.1373,"type":"view","when":"Sat 16 May"},
  {"name":"Garden Auditorium","note":"Closing panel and awards in the park.","lat":38.7372,"lng":-9.1546,"type":"sight","when":"Sun 17 May"}]</script>
    </div>
  </div>
</section></div>
CSS
/* Leaflet 1.9.4 + OpenStreetMap tiles. Markers are L.divIcon pins in the style of set 05. */
.lf-map{height:420px;border-radius:16px;z-index:0;font:inherit;-webkit-text-size-adjust:100%;text-size-adjust:100%}
.lf-map:focus-visible{outline:3px solid #0f172a;outline-offset:3px}
.lf-marker{background:none;border:0}
.lf-marker svg{display:block;width:34px;height:42px;filter:drop-shadow(0 3px 4px rgba(15,23,42,.35))}
.lf-marker:focus-visible{outline:3px solid #0f172a;outline-offset:2px;border-radius:8px}
.lf-map .leaflet-control-zoom a:focus-visible,.lf-map .leaflet-control-attribution a:focus-visible,.lf-map .leaflet-popup-close-button:focus-visible{outline:3px solid #0f172a;outline-offset:1px}
.lf-map .leaflet-control-attribution{background:rgba(255,255,255,.92);color:#334155;font-size:.7rem}
.lf-map .leaflet-control-attribution a{color:#1d4ed8}
.lf-map .leaflet-popup-content-wrapper{border-radius:14px;box-shadow:0 16px 36px -14px rgba(15,23,42,.45)}
.lf-map .leaflet-popup-content{margin:14px 18px 14px 16px;font-size:.85rem;line-height:1.45;color:#334155}
.lf-map .leaflet-popup-content strong{display:block;font-size:1rem;color:#0f172a}
.lf-map .leaflet-popup-content a{display:inline-block;margin-top:6px;font-weight:700;color:#be123c;text-decoration:underline}
.lf-map .leaflet-popup-close-button{width:28px;height:28px;font-size:20px;color:#334155}
.lf-swatch{display:inline-block;width:12px;height:12px;border-radius:50%}
@media (max-width:640px){.lf-map{height:340px}}
@media (prefers-reduced-motion:reduce){.lf-map .leaflet-fade-anim .leaflet-popup,.lf-map .leaflet-zoom-anim .leaflet-zoom-animated{transition:none}}
.lf{--bs-card-border-color:#e2e8f0;color:#0f172a}
.lf-sub{font-size:.92rem}
.lf-item{--bs-list-group-action-hover-bg:#f8fafc;border-radius:14px!important;border:1px solid #e2e8f0!important}
.lf-item[aria-pressed="true"]{border-color:#0f172a!important;box-shadow:inset 0 0 0 1px #0f172a;background:#fff}
.lf-item:focus-visible{outline:3px solid #0f172a;outline-offset:2px;box-shadow:none;z-index:2}
@media (min-width:860px){.lf-side{flex:0 0 298px;width:auto}.lf-main{flex:1 1 0;width:auto;min-width:0}}
JavaScript
// Leaflet maps from data: each [data-leaflet] map reads its places from the JSON script that follows it.
(function () {
  if (!window.L) return;
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var colours = { sight: '#7c3aed', view: '#be123c', food: '#0f766e' };
  function icon(type) {
    return L.divIcon({ className: 'lf-marker', iconSize: [34, 42], iconAnchor: [17, 41], popupAnchor: [0, -36],
      html: '<svg viewBox="0 0 34 42" aria-hidden="true" focusable="false"><path d="M17 41C17 41 32 25.5 32 16A15 15 0 0 0 2 16C2 25.5 17 41 17 41Z" fill="' + (colours[type] || '#be123c') + '" stroke="#fff" stroke-width="2.5"/><circle cx="17" cy="16" r="5.5" fill="#fff"/></svg>' });
  }
  function popup(p) {
    var box = document.createElement('div'), t = document.createElement('strong'), n = document.createElement('span'), a = document.createElement('a');
    t.textContent = p.name; n.textContent = (p.when ? p.when + '. ' : '') + p.note;
    a.href = 'https://www.openstreetmap.org/directions?route=%3B' + p.lat + '%2C' + p.lng; a.textContent = 'Directions';
    a.target = '_blank'; a.rel = 'noopener'; a.setAttribute('aria-label', 'Directions to ' + p.name + ' (opens in a new tab)');
    box.append(t, n, document.createElement('br'), a); return box;
  }
  document.querySelectorAll('[data-leaflet]').forEach(function (el) {
    var places = JSON.parse(el.nextElementSibling.textContent);
    var map = L.map(el, { scrollWheelZoom: false, zoomAnimation: !reduce, fadeAnimation: !reduce, markerZoomAnimation: !reduce });
    L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19,
      attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map);
    var markers = places.map(function (p) {
      return L.marker([p.lat, p.lng], { icon: icon(p.type), title: p.name, alt: p.name, riseOnHover: true }).bindPopup(popup(p)).addTo(map);
    });
    map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [40, 40] });
    // optional list of buttons that show a place on the map
    var box = el.closest('[data-leaflet-box]');
    if (box) box.querySelectorAll('[data-place]').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var m = markers[+btn.dataset.place];
        box.querySelectorAll('[data-place]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
        map.setView(m.getLatLng(), 15, { animate: !reduce });
        m.openPopup();
      });
    });
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#475569] uppercase">City guide with custom pins and popups</span>
<section class="rounded-[22px] border border-[#e2e8f0] bg-white p-4 text-[#0f172a] sm:p-6" aria-labelledby="lf-guide-title">
  <h2 id="lf-guide-title" class="text-[1.3rem] font-bold tracking-[-.02em]">An afternoon in central Lisbon</h2>
  <p class="mt-1 mb-4 text-[.92rem] text-[#475569]">Five stops within a 40-minute walk. Select a pin for details.</p>
  <div class="lf-map" data-leaflet role="region" aria-label="Map of central Lisbon with five places"></div>
  <script type="application/json">[{"name":"Castelo de São Jorge","note":"Moorish castle walls and the city’s highest lookout.","lat":38.7139,"lng":-9.1335,"type":"sight"},
  {"name":"Miradouro de Santa Luzia","note":"Tiled terrace over the rooftops of Alfama.","lat":38.7118,"lng":-9.1302,"type":"view"},
  {"name":"Praça do Comércio","note":"Riverside square at the foot of Rua Augusta.","lat":38.7075,"lng":-9.1364,"type":"sight"},
  {"name":"Mercado da Ribeira","note":"Covered market and food hall by Cais do Sodré.","lat":38.7068,"lng":-9.1458,"type":"food"},
  {"name":"Jardim do Príncipe Real","note":"Shady garden with a giant cedar and kiosk café.","lat":38.7166,"lng":-9.1484,"type":"view"}]</script>
  <ul class="mt-3 flex flex-wrap gap-x-[18px] gap-y-1.5 text-[.82rem] text-[#475569]"><li class="flex items-center gap-[7px]"><span class="lf-swatch" style="background:#7c3aed" aria-hidden="true"></span>Sights</li><li class="flex items-center gap-[7px]"><span class="lf-swatch" style="background:#be123c" aria-hidden="true"></span>Viewpoints</li><li class="flex items-center gap-[7px]"><span class="lf-swatch" style="background:#0f766e" aria-hidden="true"></span>Food</li></ul>
  <p class="mt-2 text-[.8rem] text-[#475569]">Keyboard: Tab to the map, then use the arrow keys to pan and + or − to zoom. Tab again to reach each pin; Enter opens its card.</p>
</section></div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#475569] uppercase">Map with a list that shows each place</span>
<section class="rounded-[22px] border border-[#e2e8f0] bg-white p-4 text-[#0f172a] sm:p-6" data-leaflet-box aria-labelledby="lf-week-title">
  <h2 id="lf-week-title" class="text-[1.3rem] font-bold tracking-[-.02em]">Lisbon Design Week venues</h2>
  <p class="mt-1 mb-4 text-[.92rem] text-[#475569]">Four venues, 14–17 May. Choose one to centre it on the map.</p>
  <div class="grid gap-[18px] min-[860px]:grid-cols-[280px_minmax(0,1fr)]">
    <ul class="grid content-start gap-2">
      <li><button class="grid w-full cursor-pointer gap-0.5 rounded-[14px] border border-[#e2e8f0] bg-white px-3.5 py-3 text-left transition-colors hover:border-[#94a3b8] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#0f172a] aria-pressed:border-[#0f172a] aria-pressed:shadow-[inset_0_0_0_1px_#0f172a] motion-reduce:transition-none" type="button" data-place="0" aria-pressed="false"><strong>Riverside Pavilion</strong><small class="text-[.8rem] text-[#475569]">Thu 14 May · Talks and the opening night. Belém, by the river.</small></button></li>
      <li><button class="grid w-full cursor-pointer gap-0.5 rounded-[14px] border border-[#e2e8f0] bg-white px-3.5 py-3 text-left transition-colors hover:border-[#94a3b8] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#0f172a] aria-pressed:border-[#0f172a] aria-pressed:shadow-[inset_0_0_0_1px_#0f172a] motion-reduce:transition-none" type="button" data-place="1" aria-pressed="false"><strong>Factory Courtyard</strong><small class="text-[.8rem] text-[#475569]">Fri 15 May · Studio open days in a former textile mill.</small></button></li>
      <li><button class="grid w-full cursor-pointer gap-0.5 rounded-[14px] border border-[#e2e8f0] bg-white px-3.5 py-3 text-left transition-colors hover:border-[#94a3b8] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#0f172a] aria-pressed:border-[#0f172a] aria-pressed:shadow-[inset_0_0_0_1px_#0f172a] motion-reduce:transition-none" type="button" data-place="2" aria-pressed="false"><strong>Design Museum, Baixa</strong><small class="text-[.8rem] text-[#475569]">Sat 16 May · Exhibition: forty years of Portuguese chairs.</small></button></li>
      <li><button class="grid w-full cursor-pointer gap-0.5 rounded-[14px] border border-[#e2e8f0] bg-white px-3.5 py-3 text-left transition-colors hover:border-[#94a3b8] hover:bg-[#f8fafc] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#0f172a] aria-pressed:border-[#0f172a] aria-pressed:shadow-[inset_0_0_0_1px_#0f172a] motion-reduce:transition-none" type="button" data-place="3" aria-pressed="false"><strong>Garden Auditorium</strong><small class="text-[.8rem] text-[#475569]">Sun 17 May · Closing panel and awards in the park.</small></button></li>
    </ul>
    <div>
      <div class="lf-map" data-leaflet role="region" aria-label="Map of the four Design Week venues"></div>
      <script type="application/json">[{"name":"Riverside Pavilion","note":"Talks and the opening night. Belém, by the river.","lat":38.6957,"lng":-9.1923,"type":"sight","when":"Thu 14 May"},
  {"name":"Factory Courtyard","note":"Studio open days in a former textile mill.","lat":38.7034,"lng":-9.1785,"type":"food","when":"Fri 15 May"},
  {"name":"Design Museum, Baixa","note":"Exhibition: forty years of Portuguese chairs.","lat":38.7095,"lng":-9.1373,"type":"view","when":"Sat 16 May"},
  {"name":"Garden Auditorium","note":"Closing panel and awards in the park.","lat":38.7372,"lng":-9.1546,"type":"sight","when":"Sun 17 May"}]</script>
    </div>
  </div>
</section></div>
CSS
/* Leaflet 1.9.4 + OpenStreetMap tiles. Markers are L.divIcon pins in the style of set 05. */
.lf-map{height:420px;border-radius:16px;z-index:0;font:inherit;-webkit-text-size-adjust:100%;text-size-adjust:100%}
.lf-map:focus-visible{outline:3px solid #0f172a;outline-offset:3px}
.lf-marker{background:none;border:0}
.lf-marker svg{display:block;width:34px;height:42px;filter:drop-shadow(0 3px 4px rgba(15,23,42,.35))}
.lf-marker:focus-visible{outline:3px solid #0f172a;outline-offset:2px;border-radius:8px}
.lf-map .leaflet-control-zoom a:focus-visible,.lf-map .leaflet-control-attribution a:focus-visible,.lf-map .leaflet-popup-close-button:focus-visible{outline:3px solid #0f172a;outline-offset:1px}
.lf-map .leaflet-control-attribution{background:rgba(255,255,255,.92);color:#334155;font-size:.7rem}
.lf-map .leaflet-control-attribution a{color:#1d4ed8}
.lf-map .leaflet-popup-content-wrapper{border-radius:14px;box-shadow:0 16px 36px -14px rgba(15,23,42,.45)}
.lf-map .leaflet-popup-content{margin:14px 18px 14px 16px;font-size:.85rem;line-height:1.45;color:#334155}
.lf-map .leaflet-popup-content strong{display:block;font-size:1rem;color:#0f172a}
.lf-map .leaflet-popup-content a{display:inline-block;margin-top:6px;font-weight:700;color:#be123c;text-decoration:underline}
.lf-map .leaflet-popup-close-button{width:28px;height:28px;font-size:20px;color:#334155}
.lf-swatch{display:inline-block;width:12px;height:12px;border-radius:50%}
@media (max-width:640px){.lf-map{height:340px}}
@media (prefers-reduced-motion:reduce){.lf-map .leaflet-fade-anim .leaflet-popup,.lf-map .leaflet-zoom-anim .leaflet-zoom-animated{transition:none}}
JavaScript
// Leaflet maps from data: each [data-leaflet] map reads its places from the JSON script that follows it.
(function () {
  if (!window.L) return;
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var colours = { sight: '#7c3aed', view: '#be123c', food: '#0f766e' };
  function icon(type) {
    return L.divIcon({ className: 'lf-marker', iconSize: [34, 42], iconAnchor: [17, 41], popupAnchor: [0, -36],
      html: '<svg viewBox="0 0 34 42" aria-hidden="true" focusable="false"><path d="M17 41C17 41 32 25.5 32 16A15 15 0 0 0 2 16C2 25.5 17 41 17 41Z" fill="' + (colours[type] || '#be123c') + '" stroke="#fff" stroke-width="2.5"/><circle cx="17" cy="16" r="5.5" fill="#fff"/></svg>' });
  }
  function popup(p) {
    var box = document.createElement('div'), t = document.createElement('strong'), n = document.createElement('span'), a = document.createElement('a');
    t.textContent = p.name; n.textContent = (p.when ? p.when + '. ' : '') + p.note;
    a.href = 'https://www.openstreetmap.org/directions?route=%3B' + p.lat + '%2C' + p.lng; a.textContent = 'Directions';
    a.target = '_blank'; a.rel = 'noopener'; a.setAttribute('aria-label', 'Directions to ' + p.name + ' (opens in a new tab)');
    box.append(t, n, document.createElement('br'), a); return box;
  }
  document.querySelectorAll('[data-leaflet]').forEach(function (el) {
    var places = JSON.parse(el.nextElementSibling.textContent);
    var map = L.map(el, { scrollWheelZoom: false, zoomAnimation: !reduce, fadeAnimation: !reduce, markerZoomAnimation: !reduce });
    L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19,
      attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map);
    var markers = places.map(function (p) {
      return L.marker([p.lat, p.lng], { icon: icon(p.type), title: p.name, alt: p.name, riseOnHover: true }).bindPopup(popup(p)).addTo(map);
    });
    map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [40, 40] });
    // optional list of buttons that show a place on the map
    var box = el.closest('[data-leaflet-box]');
    if (box) box.querySelectorAll('[data-place]').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var m = markers[+btn.dataset.place];
        box.querySelectorAll('[data-place]').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
        map.setView(m.getLatLng(), 15, { animate: !reduce });
        m.openPopup();
      });
    });
  });
})();

About this set

The one set in this pack that uses a map library: Leaflet 1.9.4, loaded from jsDelivr with pinned versions and subresource integrity, with OpenStreetMap tiles and the required attribution. No API key is needed. The first map is a city guide of central Lisbon with five places; each uses a custom L.divIcon pin in the style of set 05, coloured by type with a legend, and opens a popup with a short note and a directions link. The second map sits beside a list of four event venues; choosing a venue in the list centres the map on it and opens its popup, which gives keyboard and screen reader users a simple way through the places. Place data lives in a small JSON script next to each map, so you edit names and coordinates without touching the script. Scroll-wheel zoom is off so the page scrolls normally, the map fits itself to its markers, and zoom and fade animations are turned off for visitors who prefer reduced motion. Below the first map, a short note explains the keyboard controls. Check the OpenStreetMap tile usage policy before heavy production use, or point the tile URL at your own provider.

What’s included

  • Leaflet 1.9.4 pinned with subresource integrity
  • OpenStreetMap tiles with attribution and no API key
  • Custom divIcon pins that match the marker set
  • Places read from an editable JSON script
  • List of venues that centres the map and opens popups

Accessibility

Each map container is a labelled region that Leaflet makes keyboard-operable: arrow keys pan and plus and minus zoom. Every pin is focusable, named by its title, and opens its popup with Enter. Directions links in popups name their place and say they open a new tab. Focus outlines are added for the map, pins and controls, the list buttons use aria-pressed, and Leaflet's zoom and fade animations are disabled under prefers-reduced-motion.

Customise it

Edit the colours object in the script for pin colours and the .lf-map rules for height, radius and popup styling. Change the JSON places, or the tile URL and attribution if you use another tile provider.