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
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).
<!-- 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:
- Explicit
lat/lngattributes — highest priority src→<address data-lat data-lng>— reference an external address element by IDsrc→<script type="application/ld+json">— reference a JSON-LD script by ID- Slotted
<address data-lat data-lng>— address inside the component placeattribute — scans all JSON-LD scripts for a matchingname<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.
<!-- 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 anaria-labeldescribing 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
<icon-wc>— Icon component (similar Shadow DOM pattern)- Contact patterns — Contact form layouts with map integration