Framework integration
The Kaleidr SDK is one script, kaleidr.js, that defines a global
(window.Kaleidr) and a custom element (<kaleidr-map>). It has no
framework bindings, so it works in any of them the same way: load the script
in the browser, mount into an element that exists, keep the handle, and
destroy it when the element goes away.
Three facts to plan around:
- There is no npm package. You cannot
npm installorimportthe SDK. Load it with a script tag, from your HTML or from code. - The SDK ships no TypeScript types. Copy the declaration in TypeScript.
- There is no native mobile SDK. See Mobile apps.
The rules, in any framework
| Rule | Why |
|---|---|
Load kaleidr.js once, in the browser only. | It needs window and document, and a second copy has its own separate state. |
| Mount after the target element exists. | Kaleidr.mount resolves its target immediately. A missing target is logged and nothing mounts. |
| Keep the handle. | It is the only way to move the camera, swap a tile style, restyle the chat, or tear the mount down. |
Call handle.destroy() when the component unmounts. | Otherwise the session keeps renewing and, for tile, keeps counting map loads. |
| Do not mount twice for one component. | Every Kaleidr.mount call starts a session exchange at once, even if you destroy the handle a moment later. For tile, each one counts a map load. A second mount after a destroy() is safe; it is the extra request you are avoiding. |
| Change things through the handle, not by remounting. | A remount is a new session. On the editor it discards the edits. On chat it starts a new conversation. |
| Mount one editor per page at a time. | A second editor is not supported. |
For chat on a map library you import from npm, put the library on window first. | The chat bundle finds the library through a page global. See Chat with a bundled map library. |
A loader you can import
In an application with a build step, load the script from code so that every component waits on the same promise. This helper is used by the examples below.
// loadKaleidr.ts
let loading: Promise<KaleidrGlobal> | undefined;
export function loadKaleidr(): Promise<KaleidrGlobal> {
if (typeof window === 'undefined') {
return Promise.reject(new Error('kaleidr.js loads in the browser only'));
}
if (window.Kaleidr) return Promise.resolve(window.Kaleidr);
if (!loading) {
loading = new Promise<KaleidrGlobal>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.kaleidr.com/embed/v1/kaleidr.js';
script.async = true;
script.onload = () => resolve(window.Kaleidr!);
script.onerror = () => {
loading = undefined; // allow a retry
script.remove();
reject(new Error('kaleidr.js failed to load'));
};
document.head.appendChild(script);
});
}
return loading;
}
KaleidrGlobal comes from the declaration below. Use either
this helper or a script tag in your HTML, not both, so the script cannot load
twice.
Plain HTML
Put the loader tag before the script that calls Kaleidr.mount, without
async or defer. Elements need no ordering at all.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Kaleidr in plain HTML</title>
</head>
<body>
<!-- Declarative: mounts by itself. -->
<kaleidr-map product="viewer" share-id="YOUR_MAP_ID"
style="display:block; height:360px"></kaleidr-map>
<!-- Imperative: you keep the handle. -->
<div id="basemap" style="height:360px"></div>
<button id="night">Night</button>
<script src="https://cdn.kaleidr.com/embed/v1/kaleidr.js"></script>
<script>
const handle = Kaleidr.mount('#basemap', {
product: 'tile',
publishableKey: 'kld_pk_live_…',
styleId: 'kaleidr-morning',
});
document.getElementById('night').addEventListener('click', () => {
handle.setStyle('kaleidr-night');
});
</script>
</body>
</html>
If your page swaps content without a full reload (Turbo, htmx, a hand-written
router), call handle.destroy() before you discard the container. Elements
clean up by themselves when they are removed from the document.
React
Mount in an effect, keep the handle in a ref, and destroy in the cleanup.
// Basemap.tsx
import { useEffect, useRef } from 'react';
import { loadKaleidr } from './loadKaleidr';
type Props = {
styleId: string;
center: [number, number]; // [longitude, latitude]
zoom: number;
};
export function Basemap({ styleId, center, zoom }: Props) {
const targetRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<KaleidrHandle | null>(null);
// Mount once. Options are read at mount, so this effect has no dependencies.
useEffect(() => {
let cancelled = false;
loadKaleidr()
.then((Kaleidr) => {
if (cancelled || !targetRef.current) return;
handleRef.current = Kaleidr.mount(targetRef.current, {
product: 'tile',
// Your publishable key. Shown with Vite's syntax for build-time variables.
publishableKey: import.meta.env.VITE_KALEIDR_KEY,
styleId,
center,
zoom,
});
})
.catch((err) => console.error(err));
return () => {
cancelled = true;
handleRef.current?.destroy();
handleRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Later changes go through the handle. No remount, no new session.
useEffect(() => {
handleRef.current?.setStyle(styleId);
}, [styleId]);
useEffect(() => {
handleRef.current?.setCamera({ center, zoom, duration: 600 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [center[0], center[1], zoom]);
// The SDK owns this element's children. Render nothing inside it.
return <div ref={targetRef} style={{ height: 480 }} />;
}
What each part is for:
targetRef: the mount target. The viewer and tile replace its children with an iframe, so keep it empty in JSX. React does not touch children it did not render.handleRef: the handle outlives renders, and changing it must not cause one, so it belongs in a ref, not in state.- The empty dependency array: mount options are read once. Listing props as dependencies would destroy and remount on every change.
- The two prop effects: on the first render the handle does not exist yet, so they do nothing, and the mount uses the props directly. On later renders they forward the change.
Strict mode and double mounts
In development, React's strict mode runs every effect twice: setup, cleanup,
setup. A cleanup that calls destroy() is correct, and it is all you need
for a working embed: mounting, destroying and mounting again into the same
element is safe. The destroyed mount adds nothing to the element and does not
disturb the second one, even when both were still loading.
What is left is a cost, not a bug. Kaleidr.mount sends its session request
the moment it is called, so "mount, destroy, mount" sends two requests, and
on tile that is two map loads. Strict mode runs in development only, so the
extra request never reaches production; the patterns below avoid it in
development as well.
The cancelled flag in the example removes the first mount entirely.
loadKaleidr() returns a promise, and a promise callback never runs in the
same turn in which it was attached. Strict mode's cleanup runs in that same
turn, so it sets cancelled before the first callback has a chance to mount.
Only the second setup mounts.
If you load the script with a tag in your HTML and call window.Kaleidr
directly, you can save the extra request the same way, by deferring the
mount:
useEffect(() => {
let handle: KaleidrHandle | undefined;
const timer = window.setTimeout(() => {
handle = window.Kaleidr!.mount(targetRef.current!, options);
}, 0);
return () => {
window.clearTimeout(timer);
handle?.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Do not "fix" strict mode by skipping the cleanup or by guarding the mount with a ref that is never reset. Both leave a mount with no owner when the component really unmounts.
When an option must change
shareId, publishableKey, product, contained, the chat's lang,
title and placeholder and, on the editor, styleId and theme are read
at mount only. To change one, the component
has to mount again. Put the value in the mount effect's dependency array, or
give the component a key:
<PublishedMap key={shareId} shareId={shareId} />
A new key unmounts the old component (which destroys its mount) and mounts
a new one.
The element in JSX
You can render <kaleidr-map> from React. React passes attributes on a
custom element through as written:
<kaleidr-map
key={shareId}
product="viewer"
share-id={shareId}
style={{ display: 'block', height: 480 }}
/>
Remember what the element observes. A re-render that changes share-id or
style-id on the same element does nothing, which is why the example sets
key. A re-render that changes center or zoom moves the map. In a
server-rendered app, render the element on the client only: a viewer or tile
mount changes the element's children, and the server did not render them.
Next.js
The SDK cannot run during server rendering. Keep every use of it in a client component, inside an effect.
// app/components/PublishedMap.tsx
'use client';
import { useEffect, useRef } from 'react';
import { loadKaleidr } from '@/lib/loadKaleidr';
export function PublishedMap({ shareId }: { shareId: string }) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let handle: KaleidrHandle | undefined;
loadKaleidr()
.then((Kaleidr) => {
if (cancelled || !targetRef.current) return;
handle = Kaleidr.mount(targetRef.current, {
product: 'viewer',
shareId,
});
})
.catch((err) => console.error(err));
return () => {
cancelled = true;
handle?.destroy();
};
}, [shareId]);
return <div ref={targetRef} style={{ height: 480 }} />;
}
// app/page.tsx (a server component)
import { PublishedMap } from './components/PublishedMap';
export default function Page() {
return (
<main>
<h1>Our locations</h1>
<PublishedMap shareId="YOUR_MAP_ID" />
</main>
);
}
Points specific to Next.js:
'use client'is required on the file that calls the SDK. The server renders the empty<div>, and the effect mounts into it in the browser.- Do not touch
windowat module scope.window.Kaleidr, andwindow.maplibregl = …for chat, belong inside the effect. - Keys. A publishable key is meant to be in the browser, so it can live
in a
NEXT_PUBLIC_variable such asNEXT_PUBLIC_KALEIDR_KEY. A server key (kld_sk_…) must never be in aNEXT_PUBLIC_variable or in any client component. Use it from route handlers and server actions only, against the server API. - Navigation. Client-side navigation unmounts the component, and the effect cleanup destroys the mount. Nothing else is needed.
next/script. You can loadkaleidr.jswithnext/scriptand mount in itsonReadycallback. Use that orloadKaleidr(), not both.- Development. The App Router enables strict mode in development, so the double-mount note applies.
Vue
Mount in onMounted and destroy in onBeforeUnmount. Keep the handle in a
plain variable: it is not reactive data.
<!-- PublishedMap.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { loadKaleidr } from './loadKaleidr';
const props = defineProps<{
shareId: string;
center?: [number, number];
zoom?: number;
}>();
const target = ref<HTMLDivElement | null>(null);
let handle: KaleidrHandle | undefined;
let cancelled = false;
onMounted(async () => {
const Kaleidr = await loadKaleidr();
if (cancelled || !target.value) return;
handle = Kaleidr.mount(target.value, {
product: 'viewer',
shareId: props.shareId,
center: props.center,
zoom: props.zoom,
});
});
// Camera changes go through the handle.
watch(
() => [props.center, props.zoom] as const,
([center, zoom]) => handle?.setCamera({ center, zoom, duration: 600 }),
);
onBeforeUnmount(() => {
cancelled = true;
handle?.destroy();
});
</script>
<template>
<div ref="target" style="height: 480px"></div>
</template>
To show a different map, give the component a :key so Vue replaces it:
<PublishedMap :key="shareId" :share-id="shareId" />.
To use the element in a template, tell the Vue compiler that kaleidr-map
is a custom element. Otherwise Vue tries to resolve it as a component and
warns.
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === 'kaleidr-map',
},
},
}),
],
});
<template>
<kaleidr-map :key="shareId" product="viewer" :share-id="shareId"
style="display:block; height:480px"></kaleidr-map>
</template>
With the element you still need kaleidr.js on the page. Call
loadKaleidr() once at startup, or add the script tag to index.html.
Chat with a bundled map library
The chat does not include a map library. It works with the one your page already uses, and it finds that library through a page global:
| Library | Global the chat reads |
|---|---|
| Mapbox GL JS | window.mapboxgl |
| MapLibre GL JS | window.maplibregl |
| Leaflet | window.L |
| Google Maps JavaScript API | google.maps, set by Google's own loader |
A library loaded with a script tag sets its global by itself. A library you
import from npm does not, so set it yourself before the first chat mount
on the page. The chat bundle reads the global once, when it first loads.
Setting it afterwards has no effect until the page reloads.
// MapWithChat.tsx
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { loadKaleidr } from './loadKaleidr';
export function MapWithChat() {
const mapRef = useRef<HTMLDivElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
let handle: KaleidrHandle | undefined;
// 1. Make the library a page global before the chat bundle loads.
window.maplibregl = maplibregl;
// 2. Create your map as usual.
const map = new maplibregl.Map({
container: mapRef.current!,
style: 'https://demotiles.maplibre.org/style.json',
center: [-0.1276, 51.5072],
zoom: 10,
});
// 3. Mount the chat with the live map instance.
loadKaleidr()
.then((Kaleidr) => {
if (cancelled || !chatRef.current) return;
handle = Kaleidr.mount(chatRef.current, {
product: 'chat',
publishableKey: import.meta.env.VITE_KALEIDR_KEY,
map,
contained: true,
});
})
.catch((err) => console.error(err));
return () => {
cancelled = true;
handle?.destroy(); // the chat first, then the map it was attached to
map.remove();
};
}, []);
return (
<div style={{ position: 'relative', height: 560 }}>
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 0 }} />
<div
ref={chatRef}
style={{ position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none' }}
/>
</div>
);
}
The two layers are the layout that contained: true needs: a positioned,
sized mount target over the map, with pointer events passed through to the
map. Each declaration is explained in
Theming and layout. Which map libraries are
supported, and what the assistant can do on each, is in
Supported maps.
TypeScript
Add this file to your project. It describes window.Kaleidr as the loader
defines it.
// kaleidr.d.ts
export {};
declare global {
type KaleidrProduct = 'chat' | 'viewer' | 'editor' | 'tile';
interface KaleidrCamera {
/** [longitude, latitude] */
center?: [number, number];
zoom?: number;
pitch?: number;
bearing?: number;
/** Animation length in milliseconds. Omitted or 0 jumps. */
duration?: number;
}
interface KaleidrMountOptions {
product: KaleidrProduct;
/** `kld_pk_…`. Chat, editor and tile. Never a server key. */
publishableKey?: string;
/** Deprecated alias of `publishableKey`. */
apiKey?: string;
/** Viewer: the published map. Chat with no key: the map that provides the chat. */
shareId?: string;
/** Alias of `shareId` on viewer. */
mapId?: string;
/** Tile and editor: a basemap style id or a style URL. */
styleId?: string;
/** Chat: your live map instance. */
map?: unknown;
/** Chat: selector of a `<kaleidr-map>` viewer or tile element to drive. */
mapTarget?: string;
/** Chat: dock the panel inside the positioned, sized mount target. */
contained?: boolean;
/** Chat: BCP 47 language the panel answers in. Default: the page's <html lang>, else English. */
lang?: string;
/** Chat: the panel's header title. The element attribute is `chat-title`. */
title?: string;
/** Chat: the placeholder text of the panel's input. */
placeholder?: string;
/** Viewer, tile, editor. */
center?: [number, number];
zoom?: number;
pitch?: number;
bearing?: number;
/** Chat: CSS custom properties. Editor: the 17 theme keys. Object or JSON string. */
theme?: string | Record<string, string>;
/** Per-mount arm of the enable gate. */
enabled?: boolean;
/** Deployment overrides. Not needed in production. `base` is honored only for a Kaleidr
* host, the origin kaleidr.js was loaded from, or the page's own origin. */
base?: string;
viewerBase?: string;
apiBase?: string;
tileBase?: string;
}
interface KaleidrHandle {
readonly product: KaleidrProduct;
/** Viewer, tile, editor. On chat it warns and does nothing. */
setCamera(camera: KaleidrCamera): void;
/** Chat. On other products it warns and does nothing. */
setTheme(theme: string | Record<string, string>): void;
/** Tile. On other products it warns and does nothing. */
setStyle(style: string): void;
destroy(): void;
}
interface KaleidrGlobal {
mount(target: string | HTMLElement, options: KaleidrMountOptions): KaleidrHandle;
init(): void;
/** No argument or true: on. false: hold every later mount. null: back to the default. */
enable(on?: boolean | null): void;
readonly version: string;
}
interface Window {
Kaleidr?: KaleidrGlobal;
__KALEIDR_EMBED_ENABLED__?: boolean;
__KALEIDR_EMBED_BASE__?: string;
// Map libraries the chat reads from the page.
maplibregl?: unknown;
mapboxgl?: unknown;
L?: unknown;
}
}
To use <kaleidr-map> in TSX, declare the element too:
// kaleidr-jsx.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
type KaleidrMapAttributes = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
product: 'chat' | 'viewer' | 'editor' | 'tile';
'publishable-key'?: string;
'api-key'?: string; // deprecated alias of publishable-key
'share-id'?: string;
'map-id'?: string;
'style-id'?: string;
'map-target'?: string;
contained?: boolean | '';
// Chat. `lang` is already a standard HTML attribute.
'chat-title'?: string;
placeholder?: string;
center?: string; // "longitude,latitude"
zoom?: number | string;
pitch?: number | string;
bearing?: number | string;
theme?: string; // JSON
flag?: string;
// Deployment overrides. Not needed in production.
base?: string;
'viewer-base'?: string;
'api-base'?: string;
'tile-base'?: string;
};
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'kaleidr-map': KaleidrMapAttributes;
}
}
}
This form targets current @types/react, where JSX types live in the
React.JSX namespace. The declarations are yours to maintain: they describe
/embed/v1, and the SDK does not publish types of its own.
Single-page app checklist
| Situation | What to do |
|---|---|
| The route or component that shows an embed goes away | handle.destroy(). Removing a <kaleidr-map> element does it for you. |
| The same component re-renders | Nothing. Do not mount in render, and do not list changing props as dependencies of the mount effect. |
| A list of embeds is re-ordered or re-keyed | Give each a stable key. Moving a <kaleidr-map> element in the DOM destroys it and mounts it again. |
| A tile map sits in a tab or dialog that closes | Destroy it when it closes if it will not come back soon. A mounted tile keeps renewing its session whether or not it is visible. |
| The camera or tile style should follow app state | handle.setCamera, handle.setStyle. You can call them as soon as you have the handle; calls made before the map is ready are applied when it is. |
| The chat should answer in the app's language | Pass lang at mount. Without it the panel uses the page's <html lang>. Changing the language means mounting again. |
| The published map, the key or the product changes | Mount again: change the component's key. |
| The embed must wait for consent | Do not call mount until you have it, or use the enable gate. |
| You need to know that a map loaded | Listen for kaleidr-embed:ready from the viewer or tile iframe. See Viewer messages. Chat and editor have no signal. You do not need this message before calling the handle. |
| An editor is on the page | Only one. Destroy it before mounting another. |
Mobile apps
There is no native Kaleidr SDK for iOS, Android, React Native or Flutter. The SDK runs in web pages. In a mobile app you have two options:
- A web view. Show a web page that uses the SDK. Serve that page over
HTTPS from an origin that is in your key's allowed origins. A page loaded
from the app bundle or from a
file://URL has no origin the key can allow. - Your backend. Call the Kaleidr API from your server with a server key and render the results with your app's own map. Start with the server quickstart. Never put a server key in an app binary.
The Security model covers both in more detail.