A drop-in map for any website. It uses free OpenStreetMap data, so you don't need Google, an API key, a billing account, or a build step. You can recolor every part of it.
Live demo and theme builder → Examples: basic · multiple pins
- One folder, one
<div>, one<script>. You don't write any JavaScript unless you want to. - Custom colors. Choose from 10 built-in themes, or set any of the 22 colors yourself.
- Good manners. The map loads only when it scrolls near the screen. It shows a plain address and an "Open in OpenStreetMap" link if it can't load, and it doesn't hijack page scrolling.
- Built on open tools: MapLibre GL JS for drawing, VersaTiles for the map tiles, and OpenStreetMap for the data.
Copy the openpin/ folder into your website, for example to /openpin/.
That's all there is to it. The folder holds everything the map needs: the script, its CSS, the map style, the icons, and MapLibre.
your-site/
├── index.html
└── openpin/ ← copy this whole folder
├── openpin.js
├── openpin.css
├── style.json
├── sprites/
└── maplibre/
Paste these two parts into your page wherever you want the map to appear:
<div
data-openpin
data-lat="30.79694"
data-lng="-86.68139"
data-zoom="14.7"
data-label="Baker Feed & Country Store"
data-home-label="Back to Baker"
style="height: 480px">
5791 Highway 4, Baker, Florida
</div>
<script type="module" src="/openpin/openpin.js"></script>- Change
data-latanddata-lngto your location (see Finding your coordinates). - Visitors who have JavaScript turned off see the text inside the
<div>. Put your address there. - Include the
<script>line once per page, even when the page has several maps. - Give the map a height. If you don't, it uses a minimum of 420px (320px on phones).
Browsers won't load this kind of script from a file you double-click (a file:// address). Run a small local web server from the folder that contains your page:
npx serve .python -m http.server 8000Then open the address it prints. On a real web host it works as is.
Theme builder: open index.html from this repo through a local server. You can pick a place and colors there, and it writes the code to paste for you.
<div data-openpin data-theme="midnight" ...></div>| Theme | Look |
|---|---|
country (default) |
Warm cream land, sage parks, golden highways, a barn-red pin |
coastal |
Light and airy, blue water, a teal pin |
mono |
Quiet grays that suit almost any brand |
midnight |
A dark map with an amber pin |
sputnik |
Deep navy land, golden highways, a red pin |
gmaps |
Light gray land, sky-blue water, and yellow highways, like the big street-map apps |
vivid |
Bright: cream land, electric-blue water, orange highways, a hot-pink pin |
evergreen |
Greens throughout: sage land, deep-green highways, teal water |
blueprint |
White linework on blueprint blue, a yellow pin |
vintage |
A sepia paper atlas with rust-red highways |
Add data-colors with only the colors you want to change. Everything else comes from the theme.
<div
data-openpin
data-lat="30.79694" data-lng="-86.68139"
data-theme="coastal"
data-colors='{"pin": "#c2410c", "highway": "#f0a95b", "water": "#7fb8d6"}'
style="height: 480px">
</div>Wrap
data-colorsin single quotes, because the JSON inside uses double quotes.
Every color you can set:
| Key | What it colors |
|---|---|
background |
Open land (the base color of the map) |
developed |
Towns, commercial, industrial, schools, parking |
green |
Parks, fields, farmland, grass |
forest |
Woods and forest |
bare |
Sand, rock, glaciers |
water |
Lakes, ocean, wide rivers |
waterway |
Streams, canals, and narrow rivers (lines) |
building |
Building fill |
buildingOutline |
Building edges |
road |
Regular streets and paths |
roadCasing |
The thin edge line along roads |
highway |
Highways and main roads |
rail |
Railways |
boundary |
State and country borders |
label |
Street names, place names, all map text |
labelHalo |
The glow behind map text that keeps it readable |
icon |
Small point-of-interest icons and shields |
pin |
Your location pin |
pinIcon |
The house or dot inside the pin |
surface |
Background of the buttons, pin label, and attribution |
text |
Text and icons on those buttons |
border |
Edges of those buttons |
Colors can be any CSS color: #c2410c, rgb(194 65 12), hsl(20 88% 40%), or tomato.
For a dark map, set surface to a dark color. The zoom buttons flip their icons automatically.
Tips for a good-looking map
- Keep
background,developed,green, andforestclose in brightness. They cover most of the map. - Make
labelcontrast strongly withbackground. SetlabelHaloto roughly your background color. - Give
pinandhighwaythe most saturated colors. They're what people look for first.
The buttons and pin label use CSS custom properties, so you can override them in your own stylesheet:
.openpin {
--openpin-font: "DM Sans", sans-serif; /* defaults to your page's font */
--openpin-radius: 12px; /* button and label corners */
--openpin-shadow: none;
}The text on the map itself (street names) uses Noto Sans from the tile server.
| Attribute | Example | Meaning |
|---|---|---|
data-openpin |
(no value) | Required. Marks the element as a map. |
data-lat, data-lng |
30.79694, -86.68139 |
Location. Required unless you use data-markers. |
data-zoom |
14.7 |
3 = country, 10 = city, 15 = streets, 18 = buildings. Default 14. |
data-label |
Baker Feed & Country Store |
Text above the pin. |
data-home-label |
Back to Baker |
Button that returns to the starting view. false hides it. |
data-address |
5791 Hwy 4, Baker, FL |
Shown while loading or if the map fails. |
data-link |
https://… |
Where clicking the pin goes. Defaults to OpenStreetMap. |
data-theme |
coastal |
country, coastal, mono, midnight, sputnik, gmaps, vivid, evergreen, blueprint, or vintage. |
data-colors |
'{"pin":"#c2410c"}' |
Color overrides (JSON). |
data-markers |
'[{"lat":30.4,"lng":-87.2,"label":"Pensacola"}]' |
Several pins (JSON). The map fits them all on screen. |
data-pin |
dot |
Pin icon: house (default), dot, or none. |
data-scroll-zoom |
true |
cooperative (default, Ctrl/⌘ + scroll to zoom), true, or false. |
data-lazy |
false |
Load immediately instead of when the map is near the screen. |
data-zoom-buttons |
false |
Hide the + / − buttons. |
data-show-labels |
false |
Hide the text above pins. |
data-attribution |
Map by <a href="…">You</a> |
Extra credit in the (i) attribution, shown after "OpenPin by SputnikFX". |
aria-label |
Map of our store |
Name for screen readers. Default: "Map showing {label}". |
For more control, import the module yourself:
<div id="locations" style="height: 520px"></div>
<script type="module">
import { createMap } from '/openpin/openpin.js';
const locations = createMap('#locations', {
theme: 'coastal',
colors: { pin: '#c2410c' },
homeLabel: 'Show all locations',
markers: [
{ lat: 30.4213, lng: -87.2169, label: 'Pensacola' },
{ lat: 30.3960, lng: -86.4958, label: 'Destin', pin: 'dot' },
{ lat: 30.7835, lng: -86.5547, label: 'Crestview', color: '#0f766e' },
],
});
// Later: recolor without reloading.
locations.setColors('midnight');
locations.setColors({ highway: '#ff9900' });
// Use the full MapLibre map once it's drawn.
const map = await locations.ready; // null if it couldn't load
map?.flyTo({ center: [-86.5547, 30.7835], zoom: 13 });
</script>createMap(target, options) accepts the same settings as the attributes, written in camelCase (homeLabel, scrollZoom, zoomButtons, showLabels), plus:
| Option | Meaning |
|---|---|
center |
[lng, lat] array or { lat, lng } object. |
markers |
Array of { lat, lng, label?, link?, color?, pin?, showLabel? }. Use link: false for a pin that isn't a link. |
marker: false |
Show no pin at all. |
minZoom, maxZoom |
Zoom limits. Defaults are 2 and 18. |
padding |
Space around pins when fitting several. A number, or {top, bottom, left, right}. |
timeout |
Milliseconds to wait for tiles before showing the fallback. Default 20000. |
loadingText, unavailableText |
Fallback messages. |
onReady(map, maplibregl) |
Called once the map is drawn. |
mapOptions |
Passed straight to MapLibre's Map. |
The returned controller has:
| Member | Meaning |
|---|---|
ready |
A promise. Resolves with the MapLibre map, or null if the map couldn't load. |
map, maplibregl |
The MapLibre map and library (null until loaded). |
setColors(themeOrColors) |
Recolor live, using a theme name or a colors object. |
recenter() |
Go back to the starting view. |
load() |
Load now, even if the map is off screen. |
destroy() |
Remove the map. |
Events: the map element fires openpin:ready (event.detail.map) and openpin:error. If tiles were slow but arrive later, openpin:ready still fires, even after openpin:error.
Other exports: themes, colorRoles, getMap(element), autoInit(root), resolveColors(theme, colors), toLngLat(value), and openStreetMapUrl([lng, lat]). The same functions are available as window.OpenPin for plain scripts.
- Go to openstreetmap.org and find your place.
- Right-click the exact spot and choose Show address. The left panel shows
latitude, longitude. - In the US, latitude is positive (about 25 to 49) and longitude is negative (about −67 to −125).
You can also right-click any spot in Google Maps. The first line of the menu is latitude, longitude.
⚠️ When you write a location as an array, the order is[longitude, latitude](x, y).{ lat, lng }objects anddata-lat/data-lngattributes avoid that mix-up.
Visitors usually want directions on their phone. You can link to any maps app. None of them need a key:
<!-- OpenStreetMap -->
<a href="https://www.openstreetmap.org/directions?to=30.79694%2C-86.68139">Get directions</a>
<!-- Apple Maps (opens the Maps app on iPhone and Mac) -->
<a href="https://maps.apple.com/?daddr=30.79694,-86.68139">Get directions</a>
<!-- Google Maps (a plain link; no API needed) -->
<a href="https://www.google.com/maps/dir/?api=1&destination=30.79694,-86.68139">Get directions</a>- WordPress, Squarespace, Wix, Webflow: upload the
openpinfolder (by FTP or file manager on WordPress, or as hosted files elsewhere). Then paste the two-part snippet into a Custom HTML or Embed block and use the full URL insrc. - React, Vue, Svelte: put the folder in
public/. CallcreateMap(ref)after the component mounts andcontroller.destroy()when it unmounts. Load it withimport(/* @vite-ignore */ '/openpin/openpin.js')so your bundler doesn't try to bundle it. - Static sites (Hugo, Jekyll, Eleventy, Astro): put the folder in
static/orpublic/and paste the snippet into a template.
- Tiles come from the free public VersaTiles server at
tiles.versatiles.org. It needs no key or sign-up. It's a community service, so for a high-traffic site, read their usage guide or host your own tiles. To switch tile servers, edit thesourcesandglyphsURLs instyle.json. - Attribution: OpenStreetMap's license requires visible credit. The small (i) button on the map provides it. Don't hide it.
- Size: MapLibre is about 1 MB uncompressed, or roughly 300 KB with gzip, which most hosts apply automatically. It downloads only when a map nears the screen, so pages without a map pay nothing.
- Browsers: the map needs WebGL, which every modern browser has. Older browsers see the fallback address and link.
- Content Security Policy: if your site sends a CSP header, allow
worker-src 'self' blob:,connect-src https://tiles.versatiles.org, andimg-src data: blob:.
| Problem | Fix |
|---|---|
| The map area is blank or 0px tall | Give the element a height (style="height: 480px"). |
| "Loading map…" never finishes on your computer | Use a local server (see step 3), not a double-clicked file. |
| Error in the console: Failed to load module script | The src path is wrong. Open that URL in your browser to check it. |
| The pin is in the ocean or the wrong hemisphere | Latitude and longitude are swapped. Arrays are [lng, lat]. |
| Nothing happens on a page that adds the div later (single-page apps) | Call OpenPin.autoInit() or createMap(el, …) after adding it. |
The OpenPin code is under the MIT License. The bundled MapLibre GL JS is BSD-3-Clause (openpin/maplibre/LICENSE.txt). The map style is adapted from the VersaTiles CC0 style. Map data © OpenStreetMap contributors (ODbL). See THIRD-PARTY-NOTICES.md.
