<geo-map>

A zero-dependency map component using OpenStreetMap tiles. Renders a static tile grid centered on given coordinates with an optional marker pin and address caption.

Overview

The <geo-map> component displays a static map using free OpenStreetMap tiles. It calculates tile coordinates from lat/lng attributes and renders a 3×3 tile grid clipped to the component dimensions.

Key features:

  • Zero dependencies — no API keys, no external libraries
  • Free tiles from OpenStreetMap or Carto
  • Progressive enhancement — slotted <address> visible before JS
  • SVG marker pin with customizable color
  • CSS custom properties for styling
  • Shadow DOM with exposed CSS parts
Empire State Building
20 W 34th St, New York, NY 10001
<geo-map lat="40.7484" lng="-73.9857" zoom="15"> <address> <strong>Empire State Building</strong><br/> 20 W 34th St, New York, NY 10001 </address> </geo-map>

Attributes

Attribute Type Default Description
lat Number Latitude of map center
lng Number Longitude of map center
zoom Number 15 Tile zoom level (1–19)
marker Boolean true Show pin at center. Set to "false" to hide.
marker-color String #e74c3c Pin fill color
provider String osm Tile source: osm, carto-light, carto-dark
src String ID of an external <address data-lat data-lng> or <script type="application/ld+json"> element to resolve coordinates from
place String Name to match against JSON-LD Place scripts on the page (matches data.name)

Tile Providers

Three free tile providers are supported. All require attribution (included automatically).

OSM (default)
Carto Light
Carto Dark
<!-- OpenStreetMap (default) --> <geo-map lat="51.5007" lng="-0.1246" zoom="15" provider="osm"></geo-map> <!-- Carto Light — minimal, clean style --> <geo-map lat="51.5007" lng="-0.1246" zoom="15" provider="carto-light"></geo-map> <!-- Carto Dark — great for dark themes --> <geo-map lat="51.5007" lng="-0.1246" zoom="15" provider="carto-dark"></geo-map>

Marker

The marker pin is an inline SVG positioned at the center of the map. Customize its color with the marker-color attribute or hide it with marker="false".

<!-- Default marker --> <geo-map lat="40.7484" lng="-73.9857" zoom="15"></geo-map> <!-- Custom color --> <geo-map lat="40.7484" lng="-73.9857" zoom="15" marker-color="#2563eb"></geo-map> <!-- No marker --> <geo-map lat="40.7484" lng="-73.9857" zoom="15" marker="false"></geo-map>

Caption

Slot an <address> (or any content) as a caption overlay at the bottom of the map. This content is also the no-JS fallback.

<!-- Slotted address as caption --> <geo-map lat="48.8584" lng="2.2945" zoom="16"> <address> <strong>Eiffel Tower</strong><br/> Champ de Mars, 5 Av. Anatole France<br/> 75007 Paris, France </address> </geo-map> <!-- No caption — just the map --> <geo-map lat="48.8584" lng="2.2945" zoom="16"></geo-map>

No-JS Fallback

Before the component upgrades, the slotted content is visible as a styled card. Include a link to OpenStreetMap so users can still find the location.

<!-- Before JS loads, the address is visible and clickable --> <geo-map lat="40.7484" lng="-73.9857" zoom="15"> <address> <a href="https://www.openstreetmap.org/?mlat=40.7484&mlon=-73.9857#map=15/40.7484/-73.9857"> Empire State Building<br/> 20 W 34th St, New York, NY 10001 </a> </address> </geo-map>

Data Binding

Coordinates can come from 6 sources, checked in priority order. The first match wins:

  1. Explicit lat/lng attributes — highest priority
  2. src<address data-lat data-lng> — reference an external address element by ID
  3. src<script type="application/ld+json"> — reference a JSON-LD script by ID
  4. Slotted <address data-lat data-lng> — address inside the component
  5. place attribute — scans all JSON-LD scripts for a matching name
  6. <meta name="geo.position"> — page-level fallback

src → address element

Point src to the ID of an <address> element with data-lat and data-lng attributes. The address stays visible as semantic content while the map reads its coordinates.

<!-- External address element with data-lat/data-lng --> <address id="office" data-lat="40.7484" data-lng="-73.9857"> <strong>Empire State Building</strong><br/> 20 W 34th St, New York, NY 10001 </address> <!-- Map resolves coordinates from the address --> <geo-map src="office" zoom="15"></geo-map>

src → JSON-LD script

Point src to the ID of a <script type="application/ld+json"> element. The component reads geo.latitude and geo.longitude from the parsed JSON.

<!-- JSON-LD with geo coordinates --> <script type="application/ld+json" id="place-data"> { "@context": "https://schema.org", "@type": "Place", "name": "Eiffel Tower", "geo": { "@type": "GeoCoordinates", "latitude": 48.8584, "longitude": 2.2945 } } </script> <!-- Map resolves coordinates from the JSON-LD script --> <geo-map src="place-data" zoom="16"></geo-map>

Slotted <address> with coordinates

Slot an <address> with data-lat and data-lng directly inside the component. The address appears as a caption and also provides the coordinates.

<!-- Slotted address with data-lat/data-lng --> <geo-map zoom="15"> <address data-lat="51.5007" data-lng="-0.1246"> <strong>Big Ben</strong><br/> Westminster, London SW1A 0AA </address> </geo-map>

place attribute

Set place to a name that matches the name field in a JSON-LD Place script anywhere on the page. Useful when structured data already exists for SEO.

<!-- JSON-LD somewhere on the page --> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "Place", "name": "The Hi-Dive", "geo": { "@type": "GeoCoordinates", "latitude": 39.7316, "longitude": -104.9878 } } </script> <!-- Map finds JSON-LD by matching the place name --> <geo-map place="The Hi-Dive" zoom="16"></geo-map>

<meta> fallback

As a last resort, the component checks for a <meta name="geo.position"> tag in the document. The content format is "lat;lng".

<!-- In the document <head> --> <meta name="geo.position" content="40.7484;-73.9857"/> <!-- Map with no explicit coordinates falls back to meta --> <geo-map zoom="15"></geo-map>

CSS Custom Properties

Property Default Description
--geo-map-height 300px Component height
--geo-map-border-radius 0.5rem Border radius
--geo-map-marker-color #e74c3c Marker pin fill (overridden by marker-color attr)
--geo-map-marker-size 32px Marker pin dimensions
--geo-map-caption-bg rgba(255,255,255,0.9) Caption background
--geo-map-caption-padding 0.5rem 0.75rem Caption padding
--geo-map-overlay-bg rgba(0,0,0,0.35) Overlay background on hover/focus
--geo-map-overlay-color #fff Overlay text color
--geo-map-attribution-font-size 0.625rem Attribution text size
/* Custom height */ geo-map { --geo-map-height: 400px; } /* Custom border radius */ geo-map { --geo-map-border-radius: 1rem; } /* Custom marker */ geo-map { --geo-map-marker-color: #2563eb; --geo-map-marker-size: 40px; } /* Custom caption */ geo-map { --geo-map-caption-bg: rgba(0, 0, 0, 0.7); --geo-map-caption-padding: 0.75rem 1rem; }

CSS Parts

Part Element Purpose
container Outer wrapper Overall sizing and border radius
tiles Tile grid area Map image area
marker SVG pin Marker styling
overlay Overlay wrapper Full-surface hover overlay containing the activate button
activate Button Click-to-interact trigger inside the overlay
caption Slot wrapper Address/label display
attribution OSM credit Required attribution link

Interactive Mode

By default, hovering over the map reveals a "Click to interact" button. Clicking activates pan and zoom — no external libraries loaded. The interactive layer is lazy-loaded on first activation.

Use interactive="eager" to activate immediately, interactive="none" to hide the button, or static-only to fully prevent interaction.

Click to activate (default)
Hover, then click to interact. Drag to pan, scroll to zoom, Escape to exit.
Static only
Tokyo — no interaction
<!-- Default: click to activate (hover to see button) --> <geo-map lat="40.7484" lng="-73.9857" zoom="15"> <address>Empire State Building, New York</address> </geo-map> <!-- Eager: activates immediately on load --> <geo-map lat="48.8584" lng="2.2945" zoom="14" interactive="eager"></geo-map> <!-- No interactive button --> <geo-map lat="51.5007" lng="-0.1246" zoom="15" interactive="none"></geo-map> <!-- Static only: prevents all interaction --> <geo-map lat="35.6762" lng="139.6503" zoom="13" static-only></geo-map>

Interactive Attributes

Attribute Type Default Description
interactive String click Activation mode: click (show button on hover), eager (activate immediately), none (no button)
static-only Boolean false Prevent interactive activation entirely. No activate button rendered.

Keyboard Navigation

When interactive mode is active, the following keyboard controls are available:

Key Action
Arrow keys Pan the map (up, down, left, right)
+ / = Zoom in
- Zoom out
Escape Exit interactive mode, return to static view

Events

Event Detail Description
geo-map:ready { lat, lng, zoom } Fired after tiles finish loading
geo-map:activate { lat, lng, zoom } Fired when interactive mode is activated
geo-map:move { lat, lng, zoom } Fired after pan or zoom in interactive mode
geo-map:error { message } Fired on tile load failure or missing coordinates
const map = document.querySelector('geo-map'); map.addEventListener('geo-map:ready', (e) => { console.log('Tiles loaded:', e.detail); // { lat: 40.7484, lng: -73.9857, zoom: 15 } }); map.addEventListener('geo-map:error', (e) => { console.error('Map error:', e.detail.message); }); const map = document.querySelector('geo-map'); map.addEventListener('geo-map:activate', (e) => { console.log('Interactive mode started:', e.detail); // { lat: 40.7484, lng: -73.9857, zoom: 15 } }); map.addEventListener('geo-map:move', (e) => { console.log('Map moved:', e.detail); // { lat: 40.7501, lng: -73.9823, zoom: 16 } });

Contact Page Example

A common use case: pair <geo-map> with a contact form in a sidebar layout.

<section> <layout-sidebar data-layout-gap="xl" data-layout-sidebar-width="wide"> <!-- Contact Form --> <form action="/contact" method="POST" data-layout="stack" data-layout-gap="l"> <h2>Send a message</h2> <form-field> <label for="name">Name</label> <input type="text" id="name" name="name" required/> </form-field> <form-field> <label for="email">Email</label> <input type="email" id="email" name="email" required/> </form-field> <form-field> <label for="message">Message</label> <textarea id="message" name="message" rows="4" required></textarea> </form-field> <button type="submit">Send message</button> </form> <!-- Map Sidebar --> <aside> <geo-map lat="37.7749" lng="-122.4194" zoom="14" style="--geo-map-height: 100%; min-height: 300px;"> <address> 123 Business Ave<br/> San Francisco, CA 94102 </address> </geo-map> </aside> </layout-sidebar> </section>

Accessibility

  • Tile grid has role="img" with an aria-label describing the coordinates
  • Marker SVG is aria-hidden="true" (decorative)
  • Attribution link opens in a new tab with rel="noopener"
  • Slotted <address> content is accessible before and after JS loads

Related Elements