Vanilla JavaScript
This guide embeds an Emban dashboard into any HTML page using nothing but the browser's native APIs — no framework, no build step, no npm install. The same pattern works inside Vue, Svelte, Angular, plain server-rendered HTML, or a static site.
embed_url to the page.The minimal embed
An Emban dashboard is a standalone HTML page that runs inside an iframe you control. The simplest integration is two lines — render the iframe with the embed_url:
<!-- index.html -->
<iframe
src="https://emban.sidelabs.dev/embed/dash_abc123?token=eyJhbGc..."
style="width:100%;height:640px;border:0;"
title="Usage dashboard"></iframe>
That's enough for a working dashboard. The remainder of this guide is about the optional upgrades: auto-resize, cross-frame theming, external filter control, and reacting to user clicks inside the dashboard.
Fetch the embed URL from your backend
Most integrations keep session minting on a trusted endpoint of your own (e.g. /api/emban-session). The page fetches it at load time and injects the iframe:
<div id="emban-mount" style="min-height:640px"></div>
<script>
async function mountEmban() {
// Your backend returns { embed_url, expires_at }
const r = await fetch('/api/emban-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dashboard_id: 'dash_abc123',
tenant_id: 'acme',
}),
});
const { embed_url } = await r.json();
const iframe = document.createElement('iframe');
iframe.src = embed_url;
iframe.style.cssText = 'width:100%;height:640px;border:0;';
iframe.title = 'Emban dashboard';
document.getElementById('emban-mount').replaceChildren(iframe);
}
mountEmban();
</script>
See Node.js Backend for the server side of /api/emban-session — it's a thin wrapper around POST /v1/embed-sessions.
Auto-resize the iframe
Emban dashboards post their rendered height to the parent window whenever content changes. Listening for that message lets the iframe track the dashboard's real height instead of hardcoding a pixel value:
window.addEventListener('message', (event) => {
// Verify origin — only trust messages from the Emban domain
if (event.origin !== 'https://emban.sidelabs.dev') return;
let msg;
try {
msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
} catch {
return;
}
if (!msg || !msg._emban) return;
if (msg.type === 'resize' && typeof msg.height === 'number') {
const iframe = document.querySelector('iframe[src^="https://emban.sidelabs.dev/embed/"]');
if (iframe) iframe.style.height = msg.height + 'px';
}
});
event.origin. Any page on the web can postMessage into your window. Without an origin check, an attacker could inject fake resize or filter events. Pin it to your Emban domain.The postMessage bridge
The dashboard sends and receives structured messages. Every Emban message carries _emban: true so you can filter out unrelated traffic, and every message body is either a plain object or a JSON string (older browsers stringify; newer ones pass objects).
Messages the dashboard sends to your page
| Type | Payload | When |
|---|---|---|
ready | {dashboardId} | The dashboard has mounted and is ready to receive commands. |
resize | {height} | Content height has changed. Fires on initial render and every 500ms poll. |
filter | {period, drillFilters, ...} | User changed a filter, clicked a bar/slice, or selected a period. Fires after each refetch. |
period-change | {period, previousPeriod} | Fired alongside filter when the period specifically changed (handy when you only care about time range). |
Messages your page sends to the dashboard
Use iframe.contentWindow.postMessage(msg, 'https://emban.sidelabs.dev'). Your message must carry _emban: true or the dashboard will ignore it.
| Type | Payload | Effect |
|---|---|---|
setFilters | {filters: {...}} | Override filter state. Accepts the same shape as the filter message body. |
setTheme | {theme: {primaryColor, backgroundColor, cardBackground, cardBorder, textColor, mutedColor}} | Repaint the dashboard with new colors without remounting. |
reload | {} | Force a data refetch. Useful after you ingest fresh events and want the customer to see them immediately. |
A full example with theming + filter sync
<!-- emban-embed.html -->
<div id="emban-mount" style="min-height:640px"></div>
<button id="theme-toggle">Toggle dark mode</button>
<button id="reload-btn">Reload data</button>
<script>
const EMBAN_ORIGIN = 'https://emban.sidelabs.dev';
async function mount() {
const { embed_url } = await fetch('/api/emban-session', { method: 'POST' })
.then((r) => r.json());
const iframe = document.createElement('iframe');
iframe.src = embed_url;
iframe.style.cssText = 'width:100%;height:640px;border:0;';
iframe.title = 'Emban dashboard';
document.getElementById('emban-mount').replaceChildren(iframe);
// Helpers to talk to the dashboard
const post = (msg) => {
if (!iframe.contentWindow) return;
iframe.contentWindow.postMessage({ _emban: true, ...msg }, EMBAN_ORIGIN);
};
// Listen for messages from the dashboard
window.addEventListener('message', (event) => {
if (event.origin !== EMBAN_ORIGIN) return;
let msg;
try { msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; }
catch { return; }
if (!msg || !msg._emban) return;
switch (msg.type) {
case 'ready':
console.log('Dashboard mounted:', msg.dashboardId);
break;
case 'resize':
iframe.style.height = msg.height + 'px';
break;
case 'filter':
// User interacted: sync to URL, update other widgets on the page, etc.
console.log('Filters now:', msg);
break;
}
});
// Drive the dashboard from host UI
let dark = false;
document.getElementById('theme-toggle').addEventListener('click', () => {
dark = !dark;
post({
type: 'setTheme',
theme: dark
? { backgroundColor: '#0c0c0c', textColor: '#f0f0f0', cardBackground: '#181818', cardBorder: '#2a2a2a', mutedColor: '#9a9a9a' }
: { backgroundColor: '#ffffff', textColor: '#111827', cardBackground: '#f9fafb', cardBorder: '#e5e7eb', mutedColor: '#6b7280' },
});
});
document.getElementById('reload-btn').addEventListener('click', () => {
post({ type: 'reload' });
});
}
mount();
</script>
Session expiry and re-mint
Embed tokens expire (default 1 hour, maximum 24). For long-running sessions, re-mint before expiration and swap the src:
async function refreshEmbed(iframe) {
const { embed_url, expires_in } = await fetch('/api/emban-session', { method: 'POST' })
.then((r) => r.json());
iframe.src = embed_url;
// Re-mint 60 seconds before expiry
setTimeout(() => refreshEmbed(iframe), (expires_in - 60) * 1000);
}
The simplest approach: mint with a 24-hour TTL and refresh the whole page on expiry. That covers single-session dashboards used in a customer portal. Only worry about mid-session refresh if you know users stay on the page for hours.
Responsive layout
Emban dashboards are responsive down to ~480px wide. Give the iframe a full-width container and Emban handles the rest:
<style>
.emban-wrap {
width: 100%;
max-width: 1200px; /* pick a ceiling that fits your layout */
margin: 0 auto;
}
.emban-wrap iframe {
width: 100%;
border: 0;
display: block;
}
</style>
<div class="emban-wrap">
<iframe id="emban" title="Dashboard"></iframe>
</div>
Combined with the resize listener above, this gives you a dashboard that fills the available width and adjusts its height to its content on every render.
Error handling
viz.remove_branding on the plan spec. See Pricing.
message listener is probably filtering out messages from Emban because event.origin doesn't match. Double-check you're comparing to the exact Emban deploy URL (including protocol and no trailing slash).
When to reach for a framework
This vanilla approach is enough for most integrations — product analytics dashboards, customer portals, embedded reports. Reach for the framework guides when:
- Your app is React or Next.js — use the React or Next.js guides to get hooks, SSR session minting, and typed message helpers.
- You want a component abstraction — wrap the snippet above in a single
<emban-dashboard>custom element (Web Components) so the rest of your app just uses<emban-dashboard dashboard-id="dash_..." tenant="acme">. - Multiple dashboards on one page — you can mount as many iframes as you want. Just make sure each one has a distinct
data-*attribute so your message routing knows which one posted aresize.
tenant_id scopes the customer's view, and Permissions for locked filters and hidden widgets on embeds.