)}
/>
```
Use `renderPopup={null}` when a click should navigate instead of opening a card, and handle `onMarkerClick`.
# Themes and colour tokens
> The four built-in themes and all 36 colour tokens you can override.
A theme is a palette of 36 colour tokens. The server draws the map with them and the browser draws markers, popups and controls with them, so the still image and the live map always match. Pick a theme, then override any token:
```html
```
```js
map.theme = "paper";
map.colors = { water: "#9fd3ff", accent: "#ff5a5f" };
```
```ts
staticUrl({ center, zoom, width, height, theme: "mono", colors: { accent: "#0a6fd6" } });
```
Colours are `#rrggbb` or `#rrggbbaa`. In the `colors` attribute and the URL, leave out the `#`. Changing the theme or colours on a live map reloads the tiles in the new colours. ## Tokens [Section titled “Tokens”](#tokens) | Token | Paints | `daylight` | `midnight` | `paper` | `mono` | | ----------------- | ---------------------------------------------- | ----------- | ----------- | ----------- | ----------- | | `land` | Background land | `#f3f2ec` | `#161a21` | `#f3f1ec` | `#f4f4f3` | | `water` | Oceans, lakes and rivers | `#8ec6ee` | `#0c2a47` | `#d0dee8` | `#dde2e7` | | `waterLine` | Shorelines and narrow rivers | `#6fb2e2` | `#1b4670` | `#b9d0e0` | `#c9d0d8` | | `waterLabel` | Ocean, lake and bay names | `#2d6c9e` | `#6f9fcc` | `#5f849f` | `#6b7480` | | `park` | Parks, forests and grass | `#c3e4b4` | `#14281d` | `#e3ead9` | `#e9ece8` | | `landuse` | Schools, hospitals, industrial areas, airports | `#ecebe3` | `#191e26` | `#eceae5` | `#efefee` | | `sand` | Beaches and sand | `#f3e7c6` | `#1c1b17` | `#f1ece0` | `#f1f0ec` | | `building` | Building footprints (zoom 14+) | `#e2dfd5` | `#1e242e` | `#e4e0d8` | `#e6e6e4` | | `road` | Minor road fill | `#ffffff` | `#252c37` | `#ffffff` | `#ffffff` | | `roadCasing` | Minor road outline | `#d8d2c6` | `#161a21` | `#dfd9cf` | `#dededc` | | `majorRoad` | Major road fill | `#ffffff` | `#333d4c` | `#ffffff` | `#ffffff` | | `majorRoadCasing` | Major road outline | `#cbc3b4` | `#161a21` | `#d2cbc0` | `#d2d2cf` | | `highway` | Highway fill | `#ffd27a` | `#6e6043` | `#ecdcbd` | `#ffffff` | | `highwayCasing` | Highway outline | `#dea543` | `#1b1d22` | `#b3a790` | `#bdbdb9` | | `rail` | Railways | `#cbc7c0` | `#2a313c` | `#d6d1c9` | `#d0d0cd` | | `path` | Footpaths and cycleways | `#e5e1da` | `#1d232c` | `#e2ddd5` | `#e4e4e2` | | `boundary` | State and province borders | `#bdb6c8` | `#3d4656` | `#c9c1b4` | `#c4c4c0` | | `countryBoundary` | Country borders | `#9a93a8` | `#5a6479` | `#9d9384` | `#9a9a96` | | `label` | City names | `#1c2530` | `#e2e7ee` | `#3d3a35` | `#2b2b2b` | | `labelMinor` | Town names | `#4d5763` | `#9ba5b3` | `#7a756d` | `#6a6a6a` | | `labelHalo` | Outline behind every label | `#ffffff` | `#161a21` | `#f3f1ec` | `#ffffff` | | `neighbourhood` | Neighbourhood names | `#6b7480` | `#7e8896` | `#857f75` | `#828282` | | `region` | State and region names | `#8b8794` | `#6d7786` | `#9a948a` | `#8f8f8f` | | `shield` | Road shield fill | `#ffffff` | `#333d4c` | `#ffffff` | `#ffffff` | | `shieldText` | Road shield number | `#3f434a` | `#c9d1dc` | `#5c564e` | `#4a4a4a` | | `shieldLine` | Road shield outline | `#c9c3b8` | `#3b485b` | `#bdb6aa` | `#c4c4c0` | | `interstate` | Interstate shield fill | `#3d4b63` | `#3a6ea5` | `#4a5563` | `#4a4a4a` | | `interstateText` | Interstate shield number | `#ffffff` | `#ffffff` | `#ffffff` | `#ffffff` | | `marker` | Marker pill fill and dot ring | `#0f2233` | `#eef3f1` | `#1d2b3a` | `#0f2233` | | `markerText` | Marker pill text | `#ffffff` | `#0f2233` | `#ffffff` | `#ffffff` | | `accent` | Dots without a label, focus rings | `#0f2233` | `#19d3a2` | `#1d2b3a` | `#0f2233` | | `cluster` | Cluster bubble | `#0f2233` | `#19d3a2` | `#1d2b3a` | `#0f2233` | | `clusterText` | Cluster count | `#ffffff` | `#0f2233` | `#ffffff` | `#ffffff` | | `selected` | Selected and hovered marker | `#19d3a2` | `#ffd27a` | `#f0b429` | `#19d3a2` | | `selectedText` | Selected marker text | `#0f2233` | `#2a1a00` | `#2a1a00` | `#0f2233` | | `shadow` | Marker and popup shadow (with alpha) | `#0000004d` | `#00000099` | `#2b24184d` | `#00000040` |
# Angular
> Render an Instant Map in Angular with server rendering and the first image in the HTML.
Use the `` element in a standalone Angular component. Server rendering puts the first image in the HTML, and the element takes over in the browser. ## Install [Section titled “Install”](#install)
```sh
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) Define the element before Angular starts. If it is defined later, Angular binds `markers` to a plain element and the setter never runs. src/main.ts
```ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import('instantmap-js').then(() => bootstrapApplication(App, appConfig));
```
Allow the custom element in the component and build the poster URL. src/app/app.ts
```ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import type { Marker } from 'instantmap-js';
import { staticUrl } from 'instantmap-js/server';
import { listings } from './listings';
@Component({
selector: 'app-root',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
readonly center: [number, number] = [-97.74, 30.27];
readonly zoom = 12;
readonly markers: Marker[] = listings();
readonly poster = staticUrl({
center: this.center,
zoom: this.zoom,
width: 1200,
height: 640,
ratio: 2,
markers: this.markers,
});
}
```
src/app/app.html
```html
```
Size the map in the stylesheet, not with an inline `style`. Angular rewrites an inline `style` attribute on the element and removes the variables the map sets on itself. src/app/app.css
```css
instant-map {
display: block;
position: relative;
height: 520px;
}
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) Create the app with server rendering and keep the render mode on `Server`. src/app/app.routes.server.ts
```ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }];
```
Build, start the server and check the HTML:
```sh
ng build
node dist/angular/server/server.mjs
curl -s https://your-site.example/ | grep -o ']*static?[^>]*'
```
You should see an `` whose `src` points at `/static?`. ## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) Listen with `(markerclick)` and call methods through a template reference. Use a signal for anything the event changes, because a zoneless app does not refresh on a plain property. src/app/app.ts
```ts
import { ElementRef, signal, ViewChild } from '@angular/core';
import type { InstantMapElement } from 'instantmap-js';
export class App {
@ViewChild('map') map?: ElementRef;
opened = signal('Click a price to open its card.');
showDowntown() {
void this.map?.nativeElement.flyTo({ center: [-97.743, 30.267], zoom: 15 });
}
onMarkerClick(event: Event) {
const { marker } = (event as CustomEvent<{ marker: Marker }>).detail;
this.opened.set(`${marker.title}, ${marker.label}`);
}
}
```
src/app/app.html
```html
{{ opened() }}
```
## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Starting Angular before `import('instantmap-js')` finishes. `markers` is then bound to an undefined element and never reaches the map. * Putting `style="..."` on ``. Angular overwrites it and removes the theme variables the map sets on itself. * Leaving out `CUSTOM_ELEMENTS_SCHEMA`. Angular rejects the unknown tag. * Giving the map no height. It collapses to nothing. * Leaving out `ngSkipHydration`. Hydration then fights the element for its children. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my Angular app with server rendering. Follow https://instantmap.org/docs/frameworks/angular.md exactly. Import instantmap before bootstrapApplication, add CUSTOM_ELEMENTS_SCHEMA, build the poster URL with staticUrl from instantmap-js/server and render it as an img inside instant-map with ngSkipHydration, and size the element in CSS rather than with an inline style. Build, start the server, and confirm the HTML from curl contains an img with /static? in its src.
```
## Full example [Section titled “Full example”](#full-example) [examples/angular on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/angular)
# Astro
> Add Instant Map to an Astro page, with the first image rendered on the server.
You will end up with an Astro page that ships the first map image in the HTML and becomes an interactive map on the first hover, touch or scroll. ## Install [Section titled “Install”](#install)
```bash
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) Put your markers in a module so the page and the script share them. src/lib/listings.ts
```ts
import type { Marker } from "instantmap-js";
export const center: [number, number] = [-97.7431, 30.2672];
export const zoom = 12;
export const listings: Marker[] = [
{ id: 1, lng: -97.75, lat: 30.26, label: "$120", title: "Apartment 1", subtitle: "$120 per night" },
{ id: 2, lng: -97.73, lat: 30.28, label: "$85", title: "Apartment 2", subtitle: "$85 per night" },
];
```
```astro
---
import { staticUrl } from "instantmap-js/server";
import { center, listings, zoom } from "../lib/listings";
const poster = staticUrl({ center, zoom, width: 1280, height: 720, ratio: 2, markers: listings });
---
```
Astro bundles the `
```
To check it, view the page source or run `curl`:
```bash
curl -s http://localhost:4321/ | grep "` inside ``. ## Markers, popups and events [Section titled “Markers, popups and events”](#markers-popups-and-events) * Use attributes for simple values: `center`, `zoom`, `theme`. Attributes are strings. * Use properties for objects. Set `map.markers = listings` in the script. Astro cannot pass an array through an attribute. * Listen with `addEventListener`:
```ts
map?.addEventListener("markerclick", (event) => {
const { marker } = (event as CustomEvent).detail;
console.log(marker.title);
});
```
* Call methods on the element, for example `map.fitBounds(listings, { padding: 40 })`. Markers set from the script do not change the server image. Pass the same markers to `staticUrl` so the first image matches. ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Do not use `client:only`. Astro would skip the server render and you lose the instant first image. Use a plain `
```
With a bundler, import the package and call `defineInstantMap()` once. A bare `import "instantmap-js"` is dropped by the bundler. main.ts
```ts
import { defineInstantMap } from "instantmap-js";
defineInstantMap();
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) Without a poster the element builds its own first image in the browser. To have the image in the server HTML, put an `` inside the element that points at `/static`. `posterHtml()` from `instantmap-js/server` returns the whole element with the image inside. server.ts
```ts
import express from "express";
import { posterHtml } from "instantmap-js/server";
const center: [number, number] = [-97.74, 30.27];
const markers = [
{ id: 1, lng: -97.75, lat: 30.27, label: "$420", title: "Apartment 1" },
{ id: 2, lng: -97.73, lat: 30.28, label: "$385", title: "Apartment 2" },
];
const app = express();
app.get("/", (_request, response) => {
const map = posterHtml({ center, zoom: 12, width: 1200, height: 640, markers, alt: "Map of Austin", className: "map" });
response.type("html").send(`
${map}
`);
});
app.listen(3000);
```
Hono, Fastify and any other Node or Bun server work the same way. Check the response:
```sh
curl -s https://your-site.example/ | grep -o ']*static?[^>]*'
```
You should see an `` whose `src` points at `/static?`. ### Python, PHP, Rails, Django [Section titled “Python, PHP, Rails, Django”](#python-php-rails-django) Any language can build the same URL. The image is `https://instantmap.org/static` with `center`, `zoom`, `size`, `r` and `markers` in the query string. Each marker is `lng,lat,label`, and markers are joined with `;`. See the [HTTP API](/docs/reference/http-api/) for every parameter. Keep `,` and `;` unescaped and encode everything else. static_url.py
```py
from urllib.parse import urlencode
def static_url(center, zoom, markers, size=(1200, 640), ratio=2):
pins = ";".join(f"{m['lng']},{m['lat']},{m['label']}" for m in markers)
query = urlencode(
{
"center": f"{center[0]},{center[1]}",
"zoom": zoom,
"size": f"{size[0]}x{size[1]}",
"r": ratio,
"markers": pins,
},
safe=",;",
)
return f"https://instantmap.org/static?{query}"
```
Put the URL in an `` inside the element, then set the markers from JavaScript as above. template.html
```html
```
The PHP version below was not run, because PHP is not installed on the machine that wrote this guide. Test it before you rely on it. static_url.php
```php
"{$m['lng']},{$m['lat']},{$m['label']}", $markers));
$query = http_build_query([
'center' => "{$center[0]},{$center[1]}",
'zoom' => $zoom,
'size' => "{$width}x{$height}",
'r' => $ratio,
'markers' => $pins,
], '', '&', PHP_QUERY_RFC3986);
return 'https://instantmap.org/static?' . str_replace(['%2C', '%3B'], [',', ';'], $query);
}
```
## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) Markers are a property. Events bubble from the element, and methods are called on it. events.js
```js
const map = document.getElementById("map");
map.addEventListener("markerclick", (event) => {
document.getElementById("opened").textContent = event.detail.marker.title;
});
map.addEventListener("select", (event) => {
console.log(event.detail);
});
document.getElementById("downtown").addEventListener("click", () => {
map.flyTo({ center: [-97.743, 30.267], zoom: 15 });
});
listItem.addEventListener("mouseenter", () => map.highlight(1));
listItem.addEventListener("mouseleave", () => map.highlight(null));
listItem.addEventListener("click", () => map.select(1));
```
## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Giving the map no height. It collapses to nothing. * Setting `markers` as an attribute. It is a property, so assign `map.markers = ...`. * Writing marker JSON into a script tag without escaping `<`. A label containing `` ends the script. * Setting `markers` before the element is defined. Wait for `customElements.whenDefined("instant-map")`. * Building the poster URL with a different `center` or `zoom` than the element. The image then jumps when the map loads. * Expecting tiles outside the covered area. The tiles cover Austin only for now. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my website. Follow https://instantmap.org/docs/frameworks/html.md exactly. Load the element from the script tag or import defineInstantMap from instantmap, give the element a fixed height, set markers as a property after customElements.whenDefined, and render the first image on the server as an img inside the element pointing at /static, using posterHtml from instantmap-js/server when the server is Node or Bun. Start the site and confirm the HTML from curl contains an img with /static? in its src.
```
## Full example [Section titled “Full example”](#full-example) [examples/html on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/html) and [examples/express on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/express)
# Next.js
> Render an Instant Map from a Server Component with the first image in the server HTML.
Use `instantmap-js/react` in the App Router. The map works in a Server Component and sends its first image in the HTML. ## Install [Section titled “Install”](#install)
```sh
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) Render `InstantMap` in a Server Component. Do not wrap it in `dynamic(..., { ssr: false })`. app/page.tsx
```tsx
import { InstantMap } from "instantmap-js/react";
import { listings } from "../lib/listings";
export default function Page() {
return (
);
}
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) Nothing to add. The server HTML contains an `` whose `src` points at `/static?`. The browser paints it before any JavaScript loads, then the interactive map takes over. ## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) Event handlers and refs need a Client Component. Mark the file `"use client"` and use the handle for camera calls. app/listings-map.tsx
```tsx
"use client";
import { useRef, useState } from "react";
import { InstantMap, type InstantMapHandle } from "instantmap-js/react";
import type { Marker } from "instantmap-js";
import type { Listing } from "../lib/listings";
export function ListingsMap({ listings }: { listings: Marker[] }) {
const map = useRef>(null);
const [clicked, setClicked] = useState | null>(null);
return (
<>
{clicked ? `Opened ${clicked.title}` : "Click a pin"}
setClicked(marker)}
alt="Map of stays in Austin"
style={{ height: 480, borderRadius: 12 }}
/>
>
);
}
```
Pass `listings` from the Server Component as a prop. Each marker needs `id`, `lng` and `lat`. `label`, `title`, `subtitle` and `href` are optional. ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Passing a function prop such as `onMarkerClick` from a Server Component. Move it into a file with `"use client"`. * Using `ref` in a Server Component. Refs only work in Client Components. * Giving the map no height. Set `style={{ height: 480 }}` or a class that sets one. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my Next.js App Router project. Follow https://instantmap.org/docs/frameworks/nextjs.md exactly. Render InstantMap from instantmap-js/react in a Server Component with a fixed height, and put onMarkerClick and refs in a separate "use client" file. Do not use next/dynamic with ssr: false. Run the build and confirm the server HTML contains an img with /static? in its src.
```
## Full example [Section titled “Full example”](#full-example) [examples/nextjs on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/nextjs)
# Nuxt
> Add Instant Map to a Nuxt 4 app, with the first image rendered on the server.
You will end up with a Nuxt page that ships the first map image in the server HTML and becomes an interactive map in the browser. ## Install [Section titled “Install”](#install)
```bash
npm install instantmap-js
```
Tell Vue that `instant-map` is a custom element. nuxt.config.ts
```ts
export default defineNuxtConfig({
compatibilityDate: "2026-11-01",
vue: { compilerOptions: { isCustomElement: (tag) => tag === "instant-map" } },
});
```
Register the element in the browser with a client plugin. app/plugins/instantmap.client.ts
```ts
import { defineInstantMap } from "instantmap-js";
export default defineNuxtPlugin(() => {
defineInstantMap();
});
```
## Add the map [Section titled “Add the map”](#add-the-map)
```vue
Selected: {{ clicked.title }}, {{ clicked.subtitle }}
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) `staticUrl` runs during server rendering and returns the URL of the first image. The `` is part of the HTML Nuxt sends, so the map shows before JavaScript loads. The element takes over in the browser. To check it, build and preview, then look at the HTML the server sends:
```bash
npm run build
npm run preview
curl -s http://localhost:3000/ | grep "` inside ``. View source in the browser shows the same. ## Markers, popups and events [Section titled “Markers, popups and events”](#markers-popups-and-events) * `center` and `zoom` are attributes. Bind them with `:center` and `:zoom`. * Markers are objects, so bind them as a property: `:markers.prop="listings"`. * Listen with `@markerclick`, `@moveend` or any other event. The payload is in `event.detail`. * Call methods through a template ref: `map.value?.fitBounds(...)`. * Markers set as a property do not change the server image. Pass the same markers to `staticUrl`, as above. ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Registering the element in a plain plugin. Use the `.client.ts` suffix, because the element needs the browser. * Missing `isCustomElement`. Vue warns it cannot resolve `instant-map`. * Wrapping the map in ``. You lose the server-rendered image, which is the point. * Mismatched poster and element. Use the same `center`, `zoom` and size, or the image jumps on hydration. * No height. Set one on the element. * Calling `defineInstantMap()` from server code. Keep it in the client plugin. `instantmap-js/server` is the only entry that is safe there. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add Instant Map to my Nuxt 4 app. Follow https://instantmap.org/docs/frameworks/nuxt.md
Set isCustomElement in nuxt.config.ts, register the element in app/plugins/instantmap.client.ts,
render the first image on the server with staticUrl from instantmap-js/server,
and bind markers with :markers.prop.
```
## Example [Section titled “Example”](#example) A full working project is at [examples/nuxt](https://github.com/williamrogersdev/instantmap/tree/master/examples/nuxt).
# React
> Use Instant Map in a React app with Vite, with custom popups and list hover sync.
Use `instantmap-js/react` in any React 18 or 19 client app. This page uses Vite. ## Install [Section titled “Install”](#install)
```sh
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) src/app.tsx
```tsx
import { InstantMap } from "instantmap-js/react";
import { listings } from "./listings";
export function App() {
return (
);
}
```
## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) `renderPopup` returns any React element. The map renders it inside the popup. `onSelect` fires when a marker is selected. The handle on `ref` controls the map. `highlight(id)` marks one marker, and `highlight(null)` clears it. Use it to sync a list with the map. src/app.tsx
```tsx
import { useRef, useState } from "react";
import { InstantMap, type InstantMapHandle } from "instantmap-js/react";
import type { Marker } from "instantmap-js";
import { listings, type Listing } from "./listings";
export function App() {
const map = useRef>(null);
const [selected, setSelected] = useState | null>(null);
return (
);
}
```
A client-only Vite app has no server HTML, so there is no first image to render on the server. For that, use [Next.js](/docs/frameworks/nextjs/) or [React Router](/docs/frameworks/react-router/). ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Creating the markers array inside the component body on every render. Memoize it, or define it outside the component. * Giving the map no height. It collapses to nothing. * Two copies of React in the bundle, which fails with `Cannot read properties of null (reading 'useRef')`. This happens when `instantmap-js` is installed from a local folder. Add `resolve: { dedupe: ["react", "react-dom"] }` to `vite.config.ts`. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my React and Vite app. Follow https://instantmap.org/docs/frameworks/react.md exactly. Use InstantMap from instantmap-js/react with a fixed height, renderPopup for the popup card, and a ref with highlight(id) to sync a list with the map. Memoize the markers array. Run the build and open the page to confirm the map loads with no console errors.
```
## Full example [Section titled “Full example”](#full-example) [examples/react-vite on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/react-vite)
# React Router
> Render an Instant Map in React Router v7 framework mode with the first image in the server HTML.
Use `instantmap-js/react` in React Router v7 framework mode. The loader supplies the markers and the server renders the first image. ## Install [Section titled “Install”](#install)
```sh
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) Return the markers from the route loader and render `InstantMap` in the component. app/routes/home.tsx
```tsx
import { InstantMap } from "instantmap-js/react";
import { listings } from "../listings";
import type { Route } from "./+types/home";
export function loader() {
return { listings };
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
);
}
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) Keep `ssr: true` in `react-router.config.ts`. react-router.config.ts
```ts
import type { Config } from "@react-router/dev/config";
export default { ssr: true } satisfies Config;
```
The server HTML contains an `` whose `src` points at `/static?`. The browser paints it before JavaScript loads. ## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) Use a ref for camera calls and `onMarkerClick` for clicks. Both work in the route component. app/routes/home.tsx
```tsx
import { useRef, useState } from "react";
import { InstantMap, type InstantMapHandle } from "instantmap-js/react";
import type { Marker } from "instantmap-js";
import type { Listing } from "../listings";
import type { Route } from "./+types/home";
export default function Home({ loaderData }: Route.ComponentProps) {
const { listings } = loaderData;
const map = useRef>(null);
const [clicked, setClicked] = useState | null>(null);
return (
<>
{clicked ? `Opened ${clicked.title}` : "Click a pin"}
>
);
}
```
## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Setting `ssr: false`. The page then has no first image in its HTML. * Giving the map no height. It collapses to nothing. * Two copies of React on the server, which fails with `Cannot read properties of null (reading 'useRef')`. This happens when `instantmap-js` is installed from a local folder. Add `resolve: { dedupe: ["react", "react-dom"] }` and `ssr: { noExternal: ["instantmap-js"] }` to `vite.config.ts`. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my React Router v7 framework-mode app. Follow https://instantmap.org/docs/frameworks/react-router.md exactly. Return the markers from the route loader, render InstantMap from instantmap-js/react in the component with a fixed height, and keep ssr: true. Build, start the server, and confirm the HTML from curl contains an img with /static? in its src.
```
## Full example [Section titled “Full example”](#full-example) [examples/react-router on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/react-router)
# Solid
> Render an Instant Map in SolidJS with Vite and a typed custom element.
Use the `` element in a Solid component. Solid has special prefixes for custom elements: `attr:` for attributes, `prop:` for properties and `on:` for events. ## Install [Section titled “Install”](#install)
```sh
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) Call `defineInstantMap()` once before rendering. A bare `import "instantmap-js"` is dropped by the bundler, so the element never gets defined. src/index.tsx
```tsx
import { defineInstantMap } from "instantmap-js";
import { render } from "solid-js/web";
import { App } from "./app";
defineInstantMap();
render(() => , document.getElementById("root")!);
```
Tell TypeScript about the element. src/instantmap.d.ts
```ts
import type { InstantMapElement, InstantMapEventMap, Marker } from "instantmap-js";
import "solid-js";
declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
"instant-map": JSX.HTMLAttributes & {
ref?: InstantMapElement | ((element: InstantMapElement) => void);
"attr:center"?: string;
"attr:zoom"?: number | string;
"prop:markers"?: Marker[];
"on:markerclick"?: (event: InstantMapEventMap["markerclick"]) => void;
};
}
}
}
```
src/app.tsx
```tsx
import { staticUrl } from "instantmap-js/server";
import { listings } from "./listings";
const center: [number, number] = [-97.74, 30.27];
const zoom = 12;
const markers = listings();
const poster = staticUrl({ center, zoom, width: 1200, height: 640, ratio: 2, markers });
export function App() {
return (
);
}
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) A Vite and Solid app renders in the browser, so the server HTML has an empty root. The `/static` poster is the first map request once Solid renders, and the element swaps it for the live map. For a first image in the server HTML, render the same component with SolidStart. Check what loads first:
```sh
npm run build
npm run preview
```
Open the page with the network panel on. The `/static?` image is the first map request. ## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) Use `on:markerclick` and a `ref` for camera calls. src/app.tsx
```tsx
import { createSignal } from "solid-js";
import type { InstantMapElement } from "instantmap-js";
export function App() {
let map: InstantMapElement | undefined;
const [opened, setOpened] = createSignal("Click a price to open its card.");
return (
<>
setOpened(`${event.detail.marker.title}, ${event.detail.marker.label}`)}
attr:center={center.join(",")}
attr:zoom={zoom}
prop:markers={markers}
/>
{opened()}
>
);
}
```
## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Writing `center="..."` or `markers={...}` without a prefix. Solid sets them as properties and the element ignores them. Use `attr:` for strings and `prop:` for the markers array. * Writing `onMarkerClick`. Solid lowercases event names for custom elements. Use `on:markerclick`. * Importing `instantmap-js` for its side effect only. Call `defineInstantMap()`. * Giving the map no height. It collapses to nothing. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my SolidJS app. Follow https://instantmap.org/docs/frameworks/solid.md exactly. Call defineInstantMap() from instantmap before render, declare instant-map in the solid-js JSX IntrinsicElements, and use attr:center, attr:zoom, prop:markers and on:markerclick on the element. Render a staticUrl poster img from instantmap-js/server inside it and give the map a fixed height. Build and confirm the page loads a /static? image first.
```
## Full example [Section titled “Full example”](#full-example) [examples/solid on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/solid)
# SvelteKit
> Render an Instant Map in SvelteKit with the first image in the server HTML.
Use the `` element directly in a SvelteKit page. The load function builds the first image on the server, and the element takes over in the browser. ## Install [Section titled “Install”](#install)
```sh
npm install instantmap-js
```
## Add the map [Section titled “Add the map”](#add-the-map) Build the poster URL in `+page.server.ts` so it is part of the server HTML. src/routes/+page.server.ts
```ts
import { staticUrl } from 'instantmap-js/server';
import { listings } from '$lib/listings';
const center: [number, number] = [-97.74, 30.27];
const zoom = 12;
export function load() {
const markers = listings();
return {
markers,
center,
zoom,
poster: staticUrl({ center, zoom, width: 1200, height: 640, ratio: 2, markers })
};
}
```
Define the element in `onMount`, then give it the markers. The element only exists in the browser, so import it there. src/routes/+page.svelte
```svelte
```
## Render the first image on the server [Section titled “Render the first image on the server”](#render-the-first-image-on-the-server) The `` is inside the element and is rendered by SvelteKit, so it is in the HTML before any JavaScript runs. Check it:
```sh
curl -s https://your-site.example/ | grep -o ']*static?[^>]*'
```
You should see an `` whose `src` points at `/static?`. ## Markers, popups, events [Section titled “Markers, popups, events”](#markers-popups-events) Set `markers` as a property. Listen with an `on` attribute, and call methods on the bound element. src/routes/+page.svelte
```svelte
{opened}
```
## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Importing `instantmap-js` at the top of the file. It touches `customElements`, which does not exist on the server. Import it inside `onMount`. * Giving the map no height. It collapses to nothing. * Setting `markers` as an attribute. It is a property, so assign `map.markers = ...`. * Building the poster URL in the browser. The server HTML then has no first image. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add an Instant Map to my SvelteKit app. Follow https://instantmap.org/docs/frameworks/sveltekit.md exactly. Build the poster URL with staticUrl from instantmap-js/server in +page.server.ts, render it as an img inside the instant-map element, and import instantmap inside onMount before setting map.markers. Give the map a fixed height. Build, start the server, and confirm the HTML from curl contains an img with /static? in its src.
```
## Full example [Section titled “Full example”](#full-example) [examples/sveltekit on GitHub](https://github.com/williamrogersdev/instantmap/tree/master/examples/sveltekit)
# Vue
> Add Instant Map to a Vue 3 app with Vite.
You will end up with a Vue 3 component that shows an interactive map with markers, reacts to marker clicks and can fit the view to your data. ## Install [Section titled “Install”](#install)
```bash
npm install instantmap-js
```
Tell Vue that `instant-map` is a custom element, not a component. vite.config.ts
```ts
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
vue({
template: { compilerOptions: { isCustomElement: (tag) => tag === "instant-map" } },
}),
],
});
```
## Add the map [Section titled “Add the map”](#add-the-map)
```vue
Selected: {{ clicked.title }}, {{ clicked.subtitle }}
```
## Markers, popups and events [Section titled “Markers, popups and events”](#markers-popups-and-events) * `center` and `zoom` are attributes. Bind them with `:center` and `:zoom`. * Markers are an array of objects, so bind them as a property with the `.prop` modifier: `:markers.prop="listings"`. Without `.prop`, Vue turns the array into the string `[object Object]`. * Listen with `@markerclick`, `@moveend` or any other event. The payload is in `event.detail`. * Call methods through a template ref: `map.value?.fitBounds(...)`, `flyTo`, `select`. * To change markers later, replace the array. A `watch` on your data can assign a new one. This app renders in the browser only, so there is no server image. The element draws its own first image. For a server-rendered first image, see the [Nuxt guide](/docs/frameworks/nuxt/). ## Common mistakes [Section titled “Common mistakes”](#common-mistakes) * Forgetting `isCustomElement`. Vue warns “Failed to resolve component: instant-map” and the map does not render. * Binding markers without `.prop`. * Skipping `defineInstantMap()`. Call it once, before the component renders. * No height. The element is 0 pixels tall until you set one. * Typing the template ref as `HTMLElement`. Use `InstantMapElement` so `fitBounds` type checks. ## Prompt for your agent [Section titled “Prompt for your agent”](#prompt-for-your-agent)
```text
Add Instant Map to my Vue 3 app. Follow https://instantmap.org/docs/frameworks/vue.md
Set isCustomElement for instant-map in vite.config.ts, call defineInstantMap(),
bind markers with :markers.prop, and handle @markerclick.
```
## Example [Section titled “Example”](#example) A full working project is at [examples/vue](https://github.com/williamrogersdev/instantmap/tree/master/examples/vue).
# Accessibility
> What the map does for keyboard and screen-reader users, and what you should add.
## What you get out of the box [Section titled “What you get out of the box”](#what-you-get-out-of-the-box) | | | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **A name** | The element has `role="application"`, `aria-roledescription="map"`, and an `aria-label` (default “Map”; set your own). | | **Keyboard use** | Tab to the map; arrow keys pan and `+` / `-` zoom. Tab on to the zoom buttons, then to each visible marker. | | **A marker list** | The map keeps a hidden list with a button for each visible marker (up to 50), named by its title and label, for example “Sunny loft, $420”. Enter opens its card. | | **Focus is visible** | Focus rings use the theme’s `accent` colour. | | **Touch targets** | Zoom buttons are 44×44 px, and markers accept taps 6 px beyond their edge. | | **Reduced motion** | `flyTo`, flings, the wake ripple and card animations turn off under `prefers-reduced-motion`. | | **The image has a name** | The poster’s `alt` uses the map’s label. | ## What you should add [Section titled “What you should add”](#what-you-should-add) * **Name the map for its job:** `aria-label="Map of 24 homes for rent in Austin"`, not “Map”. * **Put the data somewhere else too.** A map is never the only way to reach the listings, so render the same results as a normal list on the page. The [list and map](/docs/guides/list-and-map/) pattern does this. * **Keep labels readable.** Short labels in high-contrast colours. See the [contrast checklist](/docs/guides/theming/#contrast-checklist).
# Camera, animation and events
> Move the map from code, fit results, and react to what people do.
## Moving the map [Section titled “Moving the map”](#moving-the-map)
```js
map.setCamera({ center: [2.3522, 48.8566], zoom: 12 }); // jump
await map.flyTo({ center: [-0.1276, 51.5072], zoom: 11 }); // animate (zooms out for long trips)
await map.fitBounds(map.markers, { padding: 64, maxZoom: 15 }); // show every marker
await map.fitBounds([-74.05, 40.68, -73.9, 40.82]); // [west, south, east, north]
```
The animations follow `prefers-reduced-motion`: they jump instead. ## Reading the view [Section titled “Reading the view”](#reading-the-view)
```js
map.camera; // { center: [lng, lat], zoom }
map.bounds; // [west, south, east, north]
map.project(-0.1276, 51.5072); // [x, y] in CSS pixels, relative to the element
map.unproject(200, 120); // [lng, lat]
```
## Events [Section titled “Events”](#events)
```js
map.addEventListener("moveend", ({ detail }) => {
history.replaceState(null, "", `?at=${detail.center.map((n) => n.toFixed(4)).join(",")}&z=${detail.zoom.toFixed(1)}`);
});
```
| Event | When | | ------------------------------ | ----------------------------------------------------------------------------- | | `move` | Every frame while the view changes. Keep the handler cheap. | | `moveend` | The view has settled after a drag, fling, zoom or animation. Fetch data here. | | `markerclick` / `clusterclick` | A tap or click on a marker or cluster. Both are cancelable. | | `markerenter` / `markerleave` | The pointer crosses a marker. | | `select` | The selection changed, from a click, `select()`, or the popup closing. | | `interactive` | The live map has taken over from the image. | | `idle` | Everything in view has loaded. Good for screenshots and tests. | ## Gestures [Section titled “Gestures”](#gestures) | Input | Result | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Drag, then release with speed | Pans, then glides to a stop | | Mouse wheel | Zooms smoothly around the pointer. In `cooperative` mode it needs ⌘/Ctrl until the visitor has clicked or dragged the map. | | Trackpad pinch | Zooms around the pointer | | Touch: two-finger pinch | Zooms and pans | | Touch: one finger, vertical | Scrolls the page until the visitor taps the map. After that the map takes every gesture. | | Double-click | Zooms in one level, animated | | Arrow keys, `+`, `-` | Pan and zoom when the map has focus | Set `scroll-zoom="always"` on full-screen maps, where the page has nothing to scroll.
# List and map together
> The search-results layout — hover a card to highlight its pin, click a pin to jump to its card, and filter the list to what is in view.
The search-results pattern Airbnb, Zillow and most marketplaces use takes three wires: | User does | Map does | Code | | ------------------------- | --------------------------------------------------------- | ------------------------------------------- | | Hovers a card in the list | Lifts that card’s marker | `map.highlight(id)` / `map.highlight(null)` | | Clicks a marker | Opens its card, and the list scrolls to the matching item | `select` event | | Pans or zooms | The list shows what’s in view | `moveend` event + `map.bounds` | * HTML / JS
```html
);
}
```
## Details that matter [Section titled “Details that matter”](#details-that-matter) * **Highlight, don’t select, on hover.** `highlight()` only lifts the pin. `select()` opens a card, and opening cards as the pointer crosses a list is noisy. * **A highlighted marker inside a cluster** lifts the whole cluster, so the visitor still sees where it is. * **Show all results** after a search: `map.fitBounds(markers, { padding: 64 })`. * **On phones**, stack the map above the list at 40–50% of the viewport height. Keep the map’s `scroll-zoom` at `cooperative` so the page still scrolls past it.
# Markers and clusters
> Feed the map your data, keep it in sync, and control clustering.
## The shape [Section titled “The shape”](#the-shape) The map takes plain objects. Map your records to them once:
```ts
map.markers = stores.map((store) => ({
id: store.id, // unique, string or number
lng: store.longitude, // longitude first, always
lat: store.latitude,
label: store.openNow ? "Open" : undefined, // a short pill label, or nothing for a dot
title: store.name,
subtitle: store.address,
badge: store.isNew ? "New" : undefined,
image: store.photoUrl, // about 560 px wide, see Cards and popups
href: `/stores/${store.slug}`,
data: store, // the original record, typed through Marker
}));
```
| Field | Drawn as | | ------------------------------ | --------------------------------------------------------------------------------------------- | | `label` set | A pill with the text, for example `$420`, `4.8★`, `Open` or `12 min`. Ten characters at most. | | no `label` | A dot in the theme’s `accent` colour. | | several markers close together | A cluster bubble showing the count. Clicking it zooms in. | The `title`, `subtitle`, `badge`, `image`, `href` and `label` fields fill the card that opens when a marker is clicked. See [Cards and popups](/docs/guides/popups/). Card photos are fetched ahead of the click for the markers nearest the centre, so serve them about 560 px wide, in WebP or AVIF, with long cache headers. ## Updating [Section titled “Updating”](#updating) Assign a new array. The map re-clusters (fast enough for tens of thousands of markers) and redraws on the next frame:
```js
map.markers = map.markers.filter((marker) => marker.data.price < maxPrice);
```
Changing the array keeps the camera, the open popup (if its marker is still there) and the selection. ## Clustering [Section titled “Clustering”](#clustering)
```html
```
Clusters are computed in world space at each whole zoom level. Panning never regroups them, and the first image the server renders has exactly the groups the live map draws. Pills and bubbles that would touch merge too, so markers never overlap. Handle a cluster click yourself:
```js
map.addEventListener("clusterclick", (event) => {
event.preventDefault();
openSidebar(event.detail.cluster.markers);
});
```
## How many markers? [Section titled “How many markers?”](#how-many-markers) | Count | Notes | | ----------------- | -------------------------------------------------------------------------------------- | | Up to 10,000 | Smooth on a mid-range phone. | | 10,000–100,000 | Works. Clustering runs once per whole zoom level, so the first zoom-in pauses briefly. | | More than 100,000 | Filter on the server first, by the visible bounds (`moveend` + `map.bounds`). | The first image is unaffected by the count, because `staticUrl()` sends only the visible groups.
# Cards and popups
> The built-in marker card, how to restyle it with options, CSS variables or a template, how its images load fast, and how to use your own popup or skip it.
Clicking a marker selects it, and the map opens a card above it. There are four levels, from no code to full control. Use the lowest one that does the job. | Level | You write | Good for | | ---------------- | ------------------------------------- | -------------------------------------------------------- | | 1. Marker fields | Data only | Most listings and store finders. | | 2. Options | An attribute or a small object | Layout, link text, link target. | | 3. CSS variables | A few lines of CSS | Matching your brand: radius, colours, font, image shape. | | 4. Template | A `` in your HTML | Your own markup without writing JavaScript. | For a card made of your own code or components, see [Your own popup](#your-own-popup). ## 1. Marker fields [Section titled “1. Marker fields”](#1-marker-fields) If a marker has a `title`, `subtitle` or `image`, the map draws the built-in card from these fields: | Field | Shown as | | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | | `image` | The photo at the top of the card. | | `badge` | A small tag over the photo, such as `New` or `Sold`. Without a photo it sits above the title. The `row` layout does not show it. | | `title` | The heading. Two lines at most. | | `subtitle` | The second line, for example a neighbourhood. | | `label` | The price, at the bottom left. This is the same text as the pill on the map. | | `href` | A link at the bottom right. Its text is **View details** unless you change it. |
```js
map.markers = [
{
id: 1,
lng: -122.4183,
lat: 37.7758,
label: "$4,950",
title: "Sunny 2-bed flat",
subtitle: "SoMa · 2 beds",
badge: "New",
image: "https://cdn.example.com/listings/1-560.webp",
href: "/listings/1",
},
];
```
A marker with none of `title`, `image` and `subtitle` opens no card. The card follows the theme: white on light themes, dark on `midnight`. Links and image addresses in the built-in card and in templates may only be `http`, `https`, `mailto`, `tel` or a relative address. Anything else, such as a `javascript:` address, is dropped. ## 2. Options [Section titled “2. Options”](#2-options) The same three options exist as attributes and as a property.
```html
```
```js
map.card = { layout: "row", action: "See listing", target: "_blank", closeLabel: "Close details" };
```
| Attribute | `card` property key | Values | Default | | ------------- | ------------------- | --------------------------------------------------------------------------------- | -------------- | | `card-layout` | `layout` | `stacked` (photo on top) or `row` (small photo beside the text) | `stacked` | | `card-action` | `action` | The link text. An empty string or `off` (`false` in the property) hides the link. | `View details` | | `card-target` | `target` | `_self` or `_blank` | `_self` | | none | `closeLabel` | The accessible name of the close button | `Close` | If you set both, the `card` property wins over the attributes. In React, pass the `card` prop. It takes the same object. To turn cards off for one map, set `card="off"` on the element, or set `map.popup = null`. ## 3. CSS variables [Section titled “3. CSS variables”](#3-css-variables) Set these on `instant-map`, or on any ancestor. The card sits in your page, so they inherit like any other custom property. | Variable | What it sets | Default | | ----------------------- | ------------------------------------------ | ------------------------------- | | `--im-card-width` | Card width. It never grows beyond the map. | `264px` (`320px` for `row`) | | `--im-card-radius` | Corner radius | `14px` | | `--im-card-padding` | Padding around the text | `12px` | | `--im-card-background` | Card background | The theme’s card colour | | `--im-card-color` | Text colour | The theme’s ink colour | | `--im-card-muted` | Subtitle colour | The theme’s muted colour | | `--im-card-accent` | Link and focus colour | The theme’s accent | | `--im-card-shadow` | Shadow | A soft shadow | | `--im-card-font` | Font family | `--im-font`, or the system font | | `--im-card-image-ratio` | Photo shape | `3/2` | | `--im-card-placeholder` | Photo background while it loads | A light tint |
```css
instant-map {
--im-card-radius: 4px;
--im-card-accent: #c2410c;
--im-card-font: Georgia, serif;
--im-card-image-ratio: 16/9;
}
```
For changes beyond these, the card has stable class names inside `.instant-map-card`: | Class | Element | | ----------- | -------------------------------------------------------------------- | | `.media` | The photo box (it holds the photo and, when there is one, the badge) | | `.badge` | The badge | | `.body` | The text area | | `.title` | The title | | `.subtitle` | The subtitle | | `.foot` | The row holding the price and the link | | `.price` | The label | | `.action` | The link | | `.close` | The close button | The `.row` class is added to `.instant-map-card` for the row layout.
```css
.instant-map-card .price { color: #047857; }
```
## 4. A template in your HTML [Section titled “4. A template in your HTML”](#4-a-template-in-your-html) For your own markup without JavaScript, put a `` inside the map. The map fills it in for the selected marker.
```html
{{title}}
{{subtitle}} · {{data.beds}} beds
Open {{label}}
```
How the template is filled: * `{{title}}`, `{{data.beds}}` and any other marker field or `data` path are replaced with the value as **text**. A value can never add markup. * In `href`, `src` and similar address attributes, the value goes through the same address check as the built-in card. An address that fails the check removes the attribute. * `srcset` and `style` attributes that contain a `{{…}}` are removed. * An element with `data-if="field"` is dropped when that field is empty. * Any `on…` attribute, such as `onclick`, is removed. * An element with `data-close` (or a `button.close`) closes the card. * The template must be a direct child of ``. ## Fast card images [Section titled “Fast card images”](#fast-card-images) The card opens with its photo already loaded when it can. The `card-images` attribute (the `cardImages` prop in React) controls this. | Value | Behaviour | | ------------------- | ------------------------------------------------------------------------------------------------------- | | `visible` (default) | When the map settles, the images of up to 8 single markers nearest the centre are fetched in idle time. | | `hover` | Images are fetched only when the pointer is over a marker, and on press. | | `off` | No fetching ahead. The card loads its photo when it opens. | With `visible`, nothing is fetched ahead when the visitor has Save-Data on or a 2G connection. In every mode except `off`, an image is also fetched when the pointer is over its marker and when the marker is pressed. The map also adds a `preconnect` for up to two image origins. To keep photos light: * Serve card images about 560 px wide. The card is 264 px wide, so that covers a 2x screen. * Use WebP or AVIF. * Send long cache headers, such as `Cache-Control: public, max-age=31536000, immutable`, for images with versioned file names. ## Your own popup [Section titled “Your own popup”](#your-own-popup) `map.popup` decides what a selected marker shows: | Value | Result | | ------------------- | ------------------------------------------------------------------ | | unset (the default) | The built-in card, or your `` if there is one. | | a function | Your own renderer. | | `null` | No card. |
```js
map.popup = (marker) => {
const card = document.createElement("article");
card.className = "my-card";
const heading = document.createElement("h3");
heading.textContent = marker.title;
const button = document.createElement("button");
button.textContent = "Add to cart";
button.onclick = () => addToCart(marker.id);
card.append(heading, button);
return card;
};
```
Return an element or an HTML string, or return `null` or an empty string to show nothing for that marker. The popup lives in your page, so your stylesheets, Tailwind and design-system classes apply. Caution A returned HTML string is inserted as markup. Escape any user content in it, or build elements and set `textContent`. If you need the built-in card inside your own renderer, import it from `instantmap-js/card`:
```js
import { defaultCard } from "instantmap-js/card";
map.popup = (marker) => (marker.data.soldOut ? null : defaultCard(marker, { layout: "row" }));
```
`instantmap-js/card` also exports `cardFromTemplate`, `safeUrl`, `escapeHtml` and `CARD_CSS`. ### React [Section titled “React”](#react)
```tsx
```
To render your own component, use `renderPopup`:
```tsx
} />
```
The component renders through a portal, so it keeps your providers, context and state. `renderPopup={null}` turns cards off. When `renderPopup` is set, the `card` options are not used. ## No card: navigate instead [Section titled “No card: navigate instead”](#no-card-navigate-instead) If a click should go straight to a page, skip the card and handle `markerclick`:
```js
map.popup = null;
map.addEventListener("markerclick", (event) => {
location.href = event.detail.marker.href;
});
```
This suits maps where each marker is a link, such as a directory or a search result page. ## Behaviour [Section titled “Behaviour”](#behaviour) * The map pans just enough to keep the open card on screen. * Escape, the close button or a click on empty map closes it. * `map.select(id)` opens a card from code, for example from a list. `map.select(null)` closes it. * On narrow maps the card shrinks to fit the map. * The card has `role="dialog"` and is named by the marker’s title.
# Self-hosting
> Run your own image server on Cloudflare Workers with your own tile source, before you launch.
`https://instantmap.org` is a shared demo server. Run your own before launch: * you control its uptime, * your map traffic isn’t shared with anyone else’s, * you can point it at your own tiles. It is one Cloudflare Worker, and it takes about ten minutes to set up. ## What you need [Section titled “What you need”](#what-you-need) | | | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cloudflare account | **Workers Paid** plan ($5/month). Rendering a tile takes 20–200 ms of CPU, more than the free plan allows per request. Cached images cost nothing to serve again. | | Vector tiles | Any source in the [Protomaps basemap schema](https://docs.protomaps.com/basemaps/layers), served as `{z}/{x}/{y}` MVT. See [Tile sources](#tile-sources). | | Toolchain | [Rust](https://rustup.rs) with the `wasm32-unknown-unknown` target, `wasm-bindgen-cli` 0.2.129, and [Bun](https://bun.sh). | ## Deploy [Section titled “Deploy”](#deploy)
```bash
git clone https://github.com/williamrogersdev/instantmap
cd instantmap
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.129
bun install
bun run build
```
In `worker/wrangler.jsonc`, make these changes: 1. Set `TILE_URL` to your tile source. 2. Remove `account_id` and `routes`; they belong to instantmap.org. 3. Add your own domain, if you want one.
```jsonc
{
"name": "maps",
"vars": { "TILE_URL": "https://tiles.example.com/{z}/{x}/{y}.mvt" },
"routes": [{ "pattern": "maps.example.com", "custom_domain": true }]
}
```
Then deploy:
```bash
cd worker && bunx wrangler deploy
```
Point your maps at the new server:
```html
```
```ts
staticUrl({ src: "https://maps.example.com", … });
```
Your server also serves `/instant-map.js` and the font, so you can load the client from it as well. ## Tile sources [Section titled “Tile sources”](#tile-sources) | Source | `TILE_URL` | Notes | | ---------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Protomaps API | `https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=YOUR_KEY` | Fastest start. Needs a free key from [protomaps.com](https://protomaps.com). | | Your own PMTiles on R2 | The URL of a [PMTiles Worker](https://docs.protomaps.com/deploy/cloudflare) in front of your bucket | Download a planet or regional `.pmtiles` build, upload it to R2, and deploy Protomaps’ serverless Worker. You own every part of the stack. | | Any z/x/y server | Your URL with `{z}`, `{x}`, `{y}` | Must use the Protomaps schema (layers `earth`, `water`, `landuse`, `roads`, `boundaries`, `places`, `buildings`). | The Worker caches upstream tiles for 7 days, so your tile source sees roughly one request per tile per data centre per week (once per tile in total with the `TILE_CACHE` bucket below). A request to the tile source times out after 8 seconds and is retried once. ## How pictures are cached [Section titled “How pictures are cached”](#how-pictures-are-cached) The Worker is set up for Cloudflare’s own cache, with three layers. Workers Cache and the per-data-centre cache need no provisioning; create the R2 bucket as described below. | Layer | What it does | | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Workers Cache](https://developers.cloudflare.com/workers/cache/) (`"cache": { "enabled": true }` in `worker/wrangler.jsonc`) | Sits in front of the Worker. A repeated address is answered without running the Worker, so it costs no CPU time. It is tiered, so a picture drawn for one visitor is shared with other Cloudflare locations, and simultaneous requests for the same picture are drawn once. Each deployment starts a new cache. | | The Worker’s own cache | Keyed by what the address means, not how it is written. The same picture asked for with its parameters in another order, or after a new deployment, is not drawn again in that data centre. | | R2 (optional, below) | Shares rendered tiles across locations until the bucket lifecycle rule expires them. | With Workers Cache on, Cloudflare bills requests for the site’s static files as Worker requests; without it they are free. If you serve only pictures from the Worker, this makes no difference. To turn it off, remove the `cache` line. ## Optional: keep tiles in R2 [Section titled “Optional: keep tiles in R2”](#optional-keep-tiles-in-r2) The checked-in Worker configuration binds `TILE_CACHE` to an R2 bucket named `instantmap-tiles`. Create that bucket in your own account before deploying, or remove the `r2_buckets` block to use only the Cloudflare cache layers. With R2 enabled, an edge-cache miss checks the bucket before drawing a tile.
```bash
cd worker && bunx wrangler r2 bucket create instantmap-tiles
```
The binding in `worker/wrangler.jsonc` is:
```jsonc
"r2_buckets": [{ "binding": "TILE_CACHE", "bucket_name": "instantmap-tiles" }]
```
Give the bucket a lifecycle rule, for example delete objects after 90 days, so it cannot grow without bound. Only tiles are stored. Static images are not. ## Limit on uncached renders [Section titled “Limit on uncached renders”](#limit-on-uncached-renders) A request that has to be drawn costs CPU time. The `ratelimits` block in `worker/wrangler.jsonc` adds a [rate limit binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `RENDER_LIMIT`: one address may ask for 600 uncached pictures per 60 seconds at each Cloudflare location. Pictures that are already cached are never limited. A request over the limit gets `429` with `Retry-After: 10`. Change the numbers to suit your traffic, or remove the block to have no limit. ## Logs [Section titled “Logs”](#logs) Each picture that is drawn writes one JSON line to [Workers Logs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/): the tile or image size, the milliseconds spent fetching vector tiles (`upstreamMs`), the size in bytes and the Cloudflare location (`colo`). Cloudflare records the CPU time of each request alongside it. Cached pictures write nothing, because the Worker does not run for them. ## Check it [Section titled “Check it”](#check-it)
```bash
curl -sI "https://maps.example.com/static?center=0,51.5&zoom=10&size=400x300" | grep -iE "^(HTTP|content-type|cache-control)"
```
You should see `200`, `image/jpeg` and `public, max-age=604800, immutable`. Ask again and the response carries `cf-cache-status: HIT`. The Worker also answers `GET /health` with `{ "ok": true, "renderer": "…" }`, which suits an uptime check.
# Static images
> Map images with no JavaScript at all — for emails, Open Graph cards, PDFs, AMP and RSS.
Every map starts as an image, and sometimes the image is all you need. ## In a page, with no script [Section titled “In a page, with no script”](#in-a-page-with-no-script)
```html
```
Always set `width` and `height` so the page doesn’t shift while the image loads. For anything above the fold, set `loading="eager"` and `fetchpriority="high"`. ## Build the URL in code [Section titled “Build the URL in code”](#build-the-url-in-code)
```ts
import { staticUrl } from "instantmap-js/server";
const src = staticUrl({ center: [-73.9857, 40.7484], zoom: 14, width: 640, height: 360, ratio: 2, markers: [{ lng: -73.9857, lat: 40.7484 }] });
```
`staticUrl()` is a pure function with no dependencies. It runs in Node, Bun, Deno, Workers and the browser. For other languages, build the query string yourself; the format is in the [HTTP API](/docs/reference/http-api/). Ruby, for example:
```ruby
params = { center: "#{lng},#{lat}", zoom: 13, size: "640x360", r: 2, markers: "#{lng},#{lat},$#{price}" }
url = "https://instantmap.org/static?#{URI.encode_www_form(params)}"
```
## Lighter images on slow connections [Section titled “Lighter images on slow connections”](#lighter-images-on-slow-connections) A 2× image is about three times the bytes of a 1× image. The element chooses for itself (see the [`detail` attribute](/docs/reference/element/)). When your server builds the address, it can do the same: browsers that support it send a `Save-Data: on` request header when the visitor has asked to save data.
```ts
const saveData = request.headers.get("save-data") === "on";
const src = staticUrl({ center, zoom, width: 640, height: 360, ratio: saveData ? 1 : 2 });
```
If you cache the page, add `Vary: Save-Data` to its response so the two versions are kept apart. ## Email [Section titled “Email”](#email) Email clients block scripts and many block CSS, but they all show images. Use `r=2`, keep the width at 600 or less, and link the image to the full page:
```html
```
Gmail and Outlook proxy and cache the image, which is fine: the URL always renders the same picture. ## Open Graph and social cards [Section titled “Open Graph and social cards”](#open-graph-and-social-cards) Use 1200×630 for link previews. Requests are capped at 1280 px per side, so use `r=1`:
```html
```
## PNG [Section titled “PNG”](#png) Add `format=png` for print, transparency workflows or pixel-exact tests. JPEG is three to five times smaller for the same map.
# Customising the look
> Build a map theme from your brand colours, pick a theme, recolour it with tokens, restyle controls and cards, and keep the first image in sync.
The look has four layers, from broadest to narrowest: 1. **Brand colours.** Give `brandTheme()` the colours from your style guide and it returns a theme and the colours to go with it. 2. **Theme.** One of `daylight`, `midnight`, `paper` or `mono`. 3. **Colour tokens.** Override any of the [36 tokens](/docs/reference/themes/) on top of the theme. 4. **CSS.** Restyle the controls, attribution and [cards](/docs/guides/popups/) like any other part of your page. The server reads layers 1 to 3 too, so the first image is painted in your colours. ## Brand colours from your style guide [Section titled “Brand colours from your style guide”](#brand-colours-from-your-style-guide) `brandTheme()` turns a handful of colours into a whole map palette: land, water, parks, roads, labels, markers and clusters. Only `primary` is required.
```ts
import { brandTheme } from "instantmap-js"; // or "instantmap-js/server", or "instantmap-js/brand"
const brand = brandTheme({ primary: "#e11d48", background: "#fff7ed", foreground: "#1c1917" });
// { theme: "daylight", colors: { marker: "#…", cluster: "#…", land: "#…", … } }
```
| Input | Used for | Default | | -------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | | `primary` (required) | Markers, clusters and highways | | | `background` | The page colour. Land is a tint of it. | Near-white, or near-black when `mode` is `"dark"` | | `foreground` | Text colour. Labels and outlines are mixed from it. | Dark ink, or light ink on a dark background | | `accent` | The selected marker | The foreground colour | | `water` | Water | A quiet blue leaning toward `primary` | | `park` | Parks and forests | A quiet green | | `mode` | `"light"` or `"dark"` | Decided from `background` | | `tint` | How strongly `primary` tints land, water and roads, from `0` (neutral) to `1` | `0.5` | Colours can be `#rgb`, `#rrggbb`, `rgb()`, `hsl()`, or the bare `h s% l%` triplets that shadcn-style tokens use, such as `222.2 47.4% 11.2%`. The result is `{ theme, colors }`. It builds on `daylight` (or `midnight` for dark brands) and lists only the colours that differ. Label and marker text colours are chosen so they stay readable against the colour behind them. Spread it wherever you give a theme and colours:
```ts
import { staticUrl } from "instantmap-js/server";
const src = staticUrl({ center: [-122.42, 37.77], zoom: 12, width: 960, height: 480, ratio: 2, markers, ...brand });
```
```tsx
```
```ts
import { posterHtml } from "instantmap-js/server";
const html = posterHtml({ center: [-122.42, 37.77], zoom: 12, width: 960, height: 480, ...brand });
```
```js
map.theme = brand.theme;
map.colors = brand.colors;
```
### Plain tokens [Section titled “Plain tokens”](#plain-tokens) If your style guide is a list of hex colours, pass them straight in:
```ts
const brand = brandTheme({
primary: "#0f766e",
background: "#f0fdfa",
foreground: "#134e4a",
accent: "#f59e0b",
tint: 0.7,
});
```
### Tailwind and shadcn CSS variables [Section titled “Tailwind and shadcn CSS variables”](#tailwind-and-shadcn-css-variables) shadcn/ui and many Tailwind setups keep colours as CSS variables such as `--primary`, `--background` and `--foreground`. **On the server**, pass the same values you put in your stylesheet. Bare triplets work as they are. For `oklch()` values, convert them to hex, `rgb()` or `hsl()` first, because the server reads only those.
```ts
const brand = brandTheme({
primary: "222.2 47.4% 11.2%",
background: "0 0% 100%",
foreground: "222.2 84% 4.9%",
});
```
**In the browser**, `brandThemeFromCss()` reads the variables for you. It accepts any CSS colour syntax, including `oklch()`.
```js
import { brandThemeFromCss } from "instantmap-js";
const brand = brandThemeFromCss({ primary: "--primary", background: "--background", foreground: "--foreground" });
map.theme = brand.theme;
map.colors = brand.colors;
```
Its second argument is optional: `{ element, mode, tint }`. `element` is where the variables are read (default ``), which is useful for a section that overrides them. It throws if `primary` is not set. ### A design-token file [Section titled “A design-token file”](#a-design-token-file) If your tokens live in a JSON file, read them with your own code and pass the values in:
```ts
import tokens from "./design-tokens.json";
import { brandTheme } from "instantmap-js/server";
const brand = brandTheme({
primary: tokens.color.brand.primary,
background: tokens.color.surface.page,
foreground: tokens.color.text.default,
});
```
The shape of your file will differ. Only the values matter: any colour in one of the syntaxes above. ## Pick a theme [Section titled “Pick a theme”](#pick-a-theme) If you do not need brand colours, use a built-in theme (`daylight`, `midnight`, `paper` or `mono`) and override a few tokens. Most brands need four: the marker pill, its text, the selected state and clusters.
```html
```
The `colors` attribute is a comma-separated list of `token:hex` pairs, without the `#`. Tokens are written in alphabetical order when the library builds the value for you, so the same colours always give the same address. ## Dark mode that follows the page [Section titled “Dark mode that follows the page”](#dark-mode-that-follows-the-page)
```js
const media = matchMedia("(prefers-color-scheme: dark)");
const apply = () => (map.theme = media.matches ? "midnight" : "daylight");
media.addEventListener("change", apply);
apply();
```
On the server, pick the theme for the first image from the visitor’s `Sec-CH-Prefers-Color-Scheme` header, or from a cookie you set. For a brand with both a light and a dark set of colours, build two `brandTheme()` results and switch between them the same way. ## Quieter maps for data [Section titled “Quieter maps for data”](#quieter-maps-for-data) Use `mono` when your markers carry the meaning and the map is only context:
```html
```
## Read the resolved colours [Section titled “Read the resolved colours”](#read-the-resolved-colours) To see every colour a theme and your overrides produce, call `resolvePalette()`. The map element has no palette property.
```ts
import { resolvePalette } from "instantmap-js"; // or "instantmap-js/server"
const palette = resolvePalette(map.theme, map.colors);
palette.water; // "#…"
```
## Restyle the controls [Section titled “Restyle the controls”](#restyle-the-controls)
```css
instant-map::part(controls) {
top: auto;
bottom: 28px;
right: 16px;
border-radius: 999px;
}
instant-map::part(attribution) {
font-size: 10px;
}
```
## Restyle the card [Section titled “Restyle the card”](#restyle-the-card) The built-in card lives in your page (light DOM). Set a few `--im-card-*` variables on `instant-map` to change its radius, colours, font and photo shape, or use the `.instant-map-card` class names for anything else:
```css
instant-map {
--im-card-radius: 4px;
--im-card-accent: #c2410c;
--im-card-font: "Inter", system-ui, sans-serif;
}
```
The full list is in [Cards and popups](/docs/guides/popups/#3-css-variables). To replace the card entirely, use a [template or your own popup](/docs/guides/popups/). ## Keep the first image in sync [Section titled “Keep the first image in sync”](#keep-the-first-image-in-sync) Pass the same `theme` and `colors` to `staticUrl()` (or the React component’s props) that you give the element. Otherwise the visitor sees the colours change at the hand-off. Spreading one `brandTheme()` result into both is the simplest way. ## Contrast checklist [Section titled “Contrast checklist”](#contrast-checklist) * `brandTheme()` keeps label and marker text readable for you. If you set tokens by hand, keep `markerText` on `marker` at 4.5:1 or better. Prices are small text. * Keep `label` against `land` readable at 12 px. * Don’t use colour alone to carry meaning. A selected marker also grows and gets a stronger shadow.