Embed the Prediction Widget
The Prediction Widget is an iframe-based component that lets you drop the full PropAccount prediction-markets dashboard (live charts, order panel, positions, leaderboard) into any web app — vanilla HTML, React, Next.js, Vue, anything that can render an HTML element. You theme it from the outside, you push an accountId in via postMessage, and it does the rest.
This guide covers the minimum embed, all configurable attributes, the JS bridge, and copy-paste examples for the common frameworks.
Overview
Everything you embed boils down to three things:
- A
<div class="prediction-widget">placeholder withdata-*attributes that describe what to load and how it should look. - A single
<script>tag that loads the widget loader — it scans the page, replaces every placeholder with an<iframe>, and wires up the postMessage bridge. - Optionally, a small bit of glue code that pushes the user's
accountIdinto the widget so it can render account-specific data (balance, equity, positions, PnL).
The widget itself runs inside the iframe so its CSS, JS, and dependencies never collide with your host page. Your host page only ever talks to the iframe through the loader's bridge.
How it works
At DOMContentLoaded the loader script runs:
- Finds every
.prediction-widgetcontainer on the page. - Reads its
data-src,data-settings, anddata-style-overridesattributes. - Creates an
<iframe>inside the container, points it atdata-src. - Stores a reference to the iframe on the container as
container._predictionIframeso your code can find it later. - Once the iframe fires its
loadevent, posts the parsed settings and style overrides into it viawindow.postMessage. - Listens for return messages from the iframe (
prediction:resize,prediction:confirm,prediction:alert,prediction:stateChange) and reacts in the parent page.
Because the loader hangs the iframe off container._predictionIframe, you can address it any time after init — useful for SPAs that mount the widget after navigation.
The widget remembers where the user was. As they navigate (open a market, pick a market/outcome, switch the Buy/Sell and Market/Limit/Modify tabs, filter the list, switch to Positions and its Active/Closed/Pending tab), it (a) saves the route to its own sessionStorage so a host-page refresh returns to the same view, and (b) mirrors a single opaque ?pmw=… query param onto your page URL so the route is directly addressable and shareable — paste that link into a new tab and the widget boots straight into that market. This is automatic; no configuration needed. The loader only ever touches the pmw param and leaves the rest of your URL untouched. Only public navigation (market / category / tab) is encoded — never an account or positions data, which always arrive separately via setAccountId. With multiple widgets on one page, only the first drives the shared URL; every widget still restores on refresh.
Back / Forward. Structural navigations (opening a market, changing category/outcome/tab, switching to Positions) push a browser history entry via history.pushState, so the user can step back and forward through the widget just like page navigation; the loader listens for popstate and re-syncs the widget to match. Search-as-you-type and the sort/frequency filters instead replaceState in place so they don't flood history. Because this shares the browser history with your page, be aware of it if your host app also drives history — the loader only reacts to popstate events that actually change the pmw param, and never disturbs your other params.
Requirements
- A page that can serve over HTTPS (the iframe streams WebSocket data over wss; mixed-content rules block plain HTTP hosts).
- No dependencies on your page — the widget runs entirely inside the iframe. The loader and the account-injection snippet are plain JS; you don't need jQuery or any framework.
- One CSP rule: allow the iframe's host as a frame source (and a script source, if you point at a non-default loader). See CSP / iframe permissions.
Quick start
The minimum embed is two lines — a container and a script:
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"business":"PMKT"}'></div>
<script src="https://widgets.example.com/loader.js"></script>
Drop that into any page and you'll see the widget render. With no accountId pushed in, account-specific surfaces (balance, positions, PnL) stay empty — see Setting accountId for the next step.
Replace widgets.example.com with your deployed host. Both the page (/widget.html or /explore.html) and the loader (/loader.js) are served from the same origin, so only the host changes between environments.
The two modes
The widget ships two iframe entry points. Pick one by changing data-src.
1. Dashboard mode — for logged-in users
data-src="…/widget.html" renders the full trading dashboard: balance/equity strip, order panel, live charts, positions table, leaderboard. Requires an accountId to be pushed in (see Setting accountId) — otherwise account-specific rows render empty.
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"business":"PMKT","customizer":false}'></div>
2. Explore mode — public, no account required
data-src="…/explore.html" renders the public markets browser — live events, charts, prices — with no account-aware features (balance, positions). The order panel stays interactive so visitors can explore outcomes, pricing, and payouts; the moment they click Place Order, it swaps to a Purchase Challenge call-to-action that navigates the host page to your sign-up / funding page (defaults to /challenges, override with explore_redirect_to — see Settings JSON). Perfect for landing pages, marketing sites, or any context where the user isn't logged in yet.
<div class="prediction-widget"
data-src="https://widgets.example.com/explore.html"
data-settings='{"business":"PMKT"}'></div>
You can render both on the same page — say, an explore widget on the homepage and a dashboard widget on the trading page. Each is independent.
Container attributes
Everything you configure lives on the <div class="prediction-widget"> as a data-* attribute. All JSON-valued attributes must be valid JSON — single quotes around the attribute, double quotes inside is the easiest pattern (and what the examples here use).
| Attribute | Required | Type | Purpose |
|---|---|---|---|
class="prediction-widget" |
Yes | Class | The loader looks for this selector. Add other classes freely. |
data-src |
Yes | URL | The iframe target — pick dashboard or explore mode (see above). |
data-settings |
Yes | JSON | Per-widget configuration. At minimum business. See Settings JSON. |
data-style-overrides |
No | JSON | Raw --pm-* CSS variable overrides — the way to theme the widget. See Style overrides. |
data-color-scheme |
No | light | dark | auto |
Pins the iframe's color scheme. Defaults to light (omit the attribute) so a transparent --pm-bg stays transparent even on host pages that set color-scheme: dark. Use dark for a dark-themed widget, or auto to inherit the host page. |
Invalid JSON in any of the JSON-valued attributes is logged as a warning in the browser console ([Prediction Widget] Invalid JSON in data-… attribute) and the widget proceeds with empty defaults for that attribute — it won't crash the page.
Settings JSON (data-settings)
Per-widget configuration. Posted into the iframe at startup.
| Key | Type | Default | Description |
|---|---|---|---|
business |
string | — | Your prop firm's business identifier issued by PropAccount (e.g. "PMKT"). Routes the widget to your tenant's data and branding. Required. |
customizer |
boolean | false |
Shows the in-iframe runtime Customizer panel — a floating launcher that lets a designer tweak every --pm-* variable live and copy the result back as JSON. Use during theming, leave off in production. |
explore_redirect_to |
string | /challenges |
The path the Purchase Challenge call-to-action sends the visitor to. The loader navigates the host page (the page embedding the widget) to this path, so it's a route on your own site — e.g. "/funding". The CTA appears whenever there's no active account: explore mode, and dashboard mode before an accountId is pushed. |
Example
{
"business": "PMKT",
"customizer": false
}
Theming
The widget is themed entirely through data-style-overrides — a flat map of --pm-* CSS variables. There is no separate high-level palette attribute; every surface (backgrounds, text, buttons, borders, hover states) is a variable you can set directly.
The easiest way to build a theme is the admin builder: it lists every --pm-* variable grouped by surface, gives you live preview, optional AI suggestions, and generates the exact data-style-overrides JSON to paste here.
Style overrides (data-style-overrides)
Raw CSS-variable overrides. The widget exposes a wide catalog of --pm-* variables — backgrounds, borders, text colors, padding, border-radius — for every surface. Any variable you pass here is applied as the highest-priority override inside the iframe, winning over the plugin's stylesheet defaults without rebuilding any CSS.
Shape
A flat JSON object — keys are CSS variable names ("--pm-…"), values are CSS values:
{
"--pm-positions-pager-bg": "#FFFFFF00",
"--pm-positions-pager-text": "#48FFCE",
"--pm-positions-pager-text-active": "#000000",
"--pm-positions-pager-text-hover": "#000000",
"--pm-crypto-sidebar-border-color": "#131d19",
"--pm-live-chart-toggle-border-color": "#131d19",
"--pm-live-chart-toggle-border-width": "2px",
"--pm-live-chart-toggle-btn-bg-active": "#112725",
"--pm-live-chart-toggle-btn-text": "#7f8583",
"--pm-live-go-btn-bg": "#1a1e19",
"--pm-live-go-btn-bg-hover": "#0e231f",
"--pm-live-go-btn-text": "#ffffff",
"--pm-live-price-box-border-color": "#131d19",
"--pm-live-price-box-border-width": "2px"
}
Variables catalog
Variables follow a predictable --pm-{surface}-{property} pattern. The categories you'll touch most:
| Prefix | Surface | Common properties |
|---|---|---|
--pm-card-* | Event cards & generic cards | bg, border-color, border-width, border-radius |
--pm-text / --pm-text-secondary | Body / muted text | (value only) |
--pm-surface | Subtle elevated surface (drawer, tooltip) | (value only) |
--pm-border | Default border color | (value only) |
--pm-money-card-* | Balance / Equity strip | bg, border-*, label-color, value-color |
--pm-active-positions-btn-* | Top "Positions" button | bg, text, border-*, *-hover |
--pm-positions-tabs-* | Positions tab bar container (Active / Closed) | bg, border-*, padding, border-radius |
--pm-positions-tab-* | Positions tab buttons | bg, text, bg-hover, text-hover, bg-active, text-active, padding-x, padding-y, border-radius, font-size, font-weight |
--pm-positions-pager-* | Positions table pagination | bg, text, text-active, text-hover |
--pm-live-price-box-* | Live crypto price tiles | bg, border-*, border-radius |
--pm-live-chart-toggle-* | Chances ↔ Price chart toggle | bg, border-*, btn-bg, btn-bg-active, btn-text, btn-text-active |
--pm-live-go-btn-* | "Go to live market" call-to-action | bg, bg-hover, text, border-*, border-radius |
--pm-crypto-sidebar-* | Crypto vertical sidebar (desktop) | bg, border-*, item-bg, item-bg-hover, item-bg-active, item-text, item-text-hover, item-text-active, icon-color, icon-color-active |
--pm-trade-btn-* | Buy / Sell trade button | bg, text, border-*, radius |
--pm-filter-btn-* | Top filter pills | bg, text, bg-hover, text-hover, bg-active, text-active |
--pm-order-* | Order panel surfaces | bg, border-*, label-color, value-color |
Set "customizer": true in data-settings and reload — a floating launcher opens an in-iframe panel that lists every discovered --pm-* variable grouped by surface. Edit live, then click "Copy as JSON" and paste the result into data-style-overrides. This is the fastest way to find the variable that controls the surface you want to retheme without grepping CSS.
PMWidgetFrame API
The loader exposes a small global, PMWidgetFrame, with three methods. Use them any time after the loader script has run.
PMWidgetFrame.setAccountId(accountId)
Pushes the user's account login into every widget on the page. The dashboard mode renders nothing account-specific until this is called.
PMWidgetFrame.setAccountId('1234567');
Safe to call multiple times — re-issues the message to every widget. Safe to call before iframe load (the message will queue once the iframe is ready); but the conservative pattern is to also call it from the iframe's load handler. See Direct injection for the full pattern.
PMWidgetFrame.setStyleOverrides(overrides)
Runtime equivalent of data-style-overrides. Apply or change --pm-* variables live without remounting:
PMWidgetFrame.setStyleOverrides({
'--pm-card-bg': '#0b0f17',
'--pm-card-border-color': '#1f2937'
});
Useful for dark/light mode toggles, A/B branding tests, or any scenario where the host page's color scheme changes after first paint.
PMWidgetFrame.setMarketPlatform(platform)
Switches the widget's market-data platform at runtime. Accepts 'PM' (Polymarket) or 'KL' (Kalshi); any other value is ignored.
PMWidgetFrame.setMarketPlatform('KL'); // or 'PM'
Unlike setAccountId and setStyleOverrides — which update the running widget in place — the platform is wired at boot (menu, live-data WebSocket, events list, request routing). So this method reloads the iframe with the new platform to re-run that boot path cleanly. That means it drops in-iframe UI state (selected event, order-panel input) and reconnects the live feed. It no-ops when the widget is already on the requested platform, so calling setMarketPlatform('PM') while already on Polymarket does nothing.
postMessage protocol
Internally, host ↔ iframe uses window.postMessage. You normally don't touch this directly — PMWidgetFrame wraps it — but knowing the surface is useful for debugging and for advanced integrations.
Host → Widget
| Message | Payload | Effect |
|---|---|---|
prediction:setAccountId | { accountId: string } | Switches the active account; refetches balance, positions, etc. |
prediction:settings | { settings: {…} } | Sent once on init from data-settings. Carries business, customizer, etc. |
prediction:styleOverrides | { overrides: { "--pm-…": "…" } } | Applies a batch of CSS variable overrides. Replays on every call. |
prediction:applyRoute | { pmw: string } | Loader-internal. Sent on host Back/Forward (popstate) so the widget navigates to the URL's route. You never send this yourself. |
There is no prediction:setMarketPlatform message — setMarketPlatform reloads the iframe with a market_platform query param rather than posting a message, because the platform is resolved at boot. See the method above.
Widget → Host
| Message | Payload | Handled by |
|---|---|---|
prediction:resize | { height: number } | Loader resizes the container + iframe. |
prediction:platform | { platform: 'PM' | 'KL' } | Loader records the frame's resolved platform so setMarketPlatform can no-op when unchanged. |
prediction:confirm | { title, message, details, requestId, … } | Loader opens a centered modal in the parent viewport. Reply via prediction:confirmResult. |
prediction:alert | { title, message, confirmLabel } | Loader opens a single-OK alert modal in the parent viewport. |
prediction:stateChange | { pmw: string, replace: boolean } | The user navigated. Loader mirrors the opaque pmw token into a single ?pmw= param on the parent URL — pushState for a navigation (Back/Forward steps through it), or replaceState when replace is set (search/filter refinements). Empty string clears it. |
Outgoing messages are origin-checked against the iframe's data-src. If you load the widget from widgets.example.com but receive a message claiming to come from somewhere else, the loader drops it. This is enforced inside the loader and doesn't need any configuration on your side.
Setting accountId
In dashboard mode, the widget needs to know which trading account it belongs to. You push it in with PMWidgetFrame.setAccountId() (defined by the loader). It works with any host stack — vanilla, React, Next, your own auth — because you supply the id; there are no required globals or cookies.
Direct injection
Once you know the account (from your auth, your API, wherever), push it. The call is safe any time after the loader <script> tag has run — the loader queues the message and delivers it as soon as the iframe is ready.
<script>
// Push the account once the loader has parsed the page.
document.addEventListener('DOMContentLoaded', () => {
const accountId = getMyAccountId(); // from your auth
if (accountId) PMWidgetFrame.setAccountId(accountId);
});
</script>
Call it again whenever the active account changes — it re-issues the message to every widget on the page. For belt-and-suspenders coverage (the iframe could reload independently — back/forward cache, anchor change), also re-push from the iframe's load event:
const accountId = '1234567';
document.querySelectorAll('.prediction-widget').forEach((container) => {
const iframe = container._predictionIframe;
if (!iframe) return;
PMWidgetFrame.setAccountId(accountId);
iframe.addEventListener('load', () => PMWidgetFrame.setAccountId(accountId));
});
Vanilla JS / HTML
End-to-end working example you can paste into a standalone .html file:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>My App</title>
</head>
<body>
<div class="prediction-widget"
data-src="https://widgets.example.com/widget.html"
data-settings='{"business":"PMKT","customizer":false}'
data-style-overrides='{"--pm-card-bg":"#0b0f17","--pm-text":"#e6edf3"}'></div>
<script src="https://widgets.example.com/loader.js"></script>
<script>
// Push the account once the loader has parsed the page.
document.addEventListener('DOMContentLoaded', () => {
const accountId = getMyAccountId(); // from your auth
if (accountId) PMWidgetFrame.setAccountId(accountId);
});
</script>
</body>
</html>
React
Two patterns: (1) a script tag in index.html + a thin React component that renders the placeholder, or (2) dynamic injection from inside the component. Pattern 1 is simpler and works for ~all cases. Pattern 2 is for SPAs that need to mount/unmount the widget on route changes.
Pattern 1 — script tag in index.html
<!-- public/index.html (CRA / Vite) -->
<body>
<div id="root"></div>
<script src="https://widgets.example.com/loader.js"></script>
</body>
import { useEffect, useRef } from 'react';
export function PredictionWidget({
src = 'https://widgets.example.com/widget.html',
settings = { business: 'PMKT' },
styleOverrides,
accountId,
}) {
const ref = useRef(null);
// Push accountId whenever it changes. PMWidgetFrame is a global
// exposed by the loader script in index.html.
useEffect(() => {
if (!accountId || typeof window.PMWidgetFrame === 'undefined') return;
window.PMWidgetFrame.setAccountId(accountId);
// Also re-push on iframe load — covers full reloads of the iframe
// and React StrictMode double-mounting.
const iframe = ref.current?._predictionIframe;
if (!iframe) return;
const push = () => window.PMWidgetFrame.setAccountId(accountId);
iframe.addEventListener('load', push);
return () => iframe.removeEventListener('load', push);
}, [accountId]);
// Push style overrides whenever they change.
useEffect(() => {
if (!styleOverrides || typeof window.PMWidgetFrame === 'undefined') return;
window.PMWidgetFrame.setStyleOverrides(styleOverrides);
}, [styleOverrides]);
return (
<div
ref={ref}
className="prediction-widget"
data-src={src}
data-settings={JSON.stringify(settings)}
data-style-overrides={styleOverrides && JSON.stringify(styleOverrides)}
/>
);
}
<PredictionWidget
accountId={user.accountLogin}
settings={{ business: 'PMKT', customizer: false }}
styleOverrides={{ '--pm-card-bg': '#0b0f17' }}
/>
Pattern 2 — inject the loader from inside the component
Useful if you don't control index.html (libraries, embeds, multi-tenant apps where the loader URL varies):
import { useEffect } from 'react';
const LOADER_SRC =
'https://widgets.example.com/loader.js';
// Cached promise so the script is only injected once per page.
let loaderPromise = null;
function loadLoader() {
if (window.PMWidgetFrame) return Promise.resolve();
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = LOADER_SRC;
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
return loaderPromise;
}
export function PredictionWidget(props) {
useEffect(() => { loadLoader(); }, []);
// …same JSX + effects as Pattern 1…
}
Next.js
The widget renders client-side (it's an iframe + a postMessage bridge). In Next this means: (1) load the loader script with next/script in strategy="afterInteractive", and (2) mark the component 'use client'. Works the same in the App Router and Pages Router.
App Router (Next 13+)
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Script
src="https://widgets.example.com/loader.js"
strategy="afterInteractive"
/>
</body>
</html>
);
}
'use client';
import { useEffect, useRef } from 'react';
declare global {
interface Window {
PMWidgetFrame?: {
setAccountId(id: string): void;
setStyleOverrides(o: Record<string, string>): void;
setMarketPlatform(platform: 'PM' | 'KL'): void;
};
}
}
type Props = {
accountId?: string;
src?: string;
settings?: Record<string, unknown>;
styleOverrides?: Record<string, string>;
};
export function PredictionWidget({
accountId,
src = 'https://widgets.example.com/widget.html',
settings = { business: 'PMKT' },
styleOverrides,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!accountId) return;
const push = () => window.PMWidgetFrame?.setAccountId(accountId);
// Wait for the loader to be ready (Next loads it afterInteractive).
const iv = setInterval(() => {
if (window.PMWidgetFrame) {
push();
clearInterval(iv);
const iframe = (ref.current as any)?._predictionIframe;
if (iframe) iframe.addEventListener('load', push);
}
}, 50);
return () => clearInterval(iv);
}, [accountId]);
return (
<div
ref={ref}
className="prediction-widget"
data-src={src}
data-settings={JSON.stringify(settings)}
data-style-overrides={styleOverrides && JSON.stringify(styleOverrides)}
/>
);
}
Pages Router
Same component file. Mount it inside a page; use next/script with the same afterInteractive strategy in pages/_app.tsx:
import Script from 'next/script';
export default function App({ Component, pageProps }) {
return (
<>
<Component {...pageProps} />
<Script
src="https://widgets.example.com/loader.js"
strategy="afterInteractive"
/>
</>
);
}
The widget never renders during SSR — it's an iframe initialized from a client-side script. Don't try to useEffect against window.PMWidgetFrame until after hydration. The setInterval polling pattern in the example above is the safest portable approach across Next versions.
Auto-resize
The iframe measures its own content height and posts prediction:resize to the parent whenever the layout changes — switching between list and event detail, opening positions, mobile breakpoint flips. The loader resizes both the container and the iframe to match, so the widget never scrolls inside itself.
You don't need to do anything to opt in. If you need the widget to occupy a specific minimum height (e.g. above-the-fold layout), set CSS on the container:
.prediction-widget {
min-height: 600px;
}
The loader's resize messages will then grow the container beyond min-height as the inner content demands.
Modal forwarding
Some flows (e.g. close-position confirmation, hard error alerts) need a modal centered in the user's viewport — but a modal rendered inside the iframe can be clipped by the host page if the iframe is short. To avoid this, the widget asks the host to render the modal on its behalf via prediction:confirm and prediction:alert messages. The loader ships a self-contained, dependency-free modal that:
- Renders centered in the parent viewport.
- Uses inline styles so it's never broken by the host page's CSS.
- Supports keyboard (Enter confirms, Esc cancels).
- Routes the user's choice back to the iframe via
prediction:confirmResult.
No configuration on your side — it just works. If you want to override the modal with your own design system, you can capture the messages in your own window.addEventListener('message', …) handler and call event.stopImmediatePropagation() before the loader's handler runs (rare; mention it here for completeness).
Runtime theme updates
Use PMWidgetFrame.setStyleOverrides(…) to change --pm-* variables live. The iframe applies them as an injected <style> block — no reload, no remount.
// Example: flip to a light variant when the host app's theme toggle fires
document.querySelector('#theme-toggle').addEventListener('change', (e) => {
const isLight = e.target.checked;
PMWidgetFrame.setStyleOverrides(isLight
? { '--pm-card-bg': '#ffffff', '--pm-text': '#0b0f17' }
: { '--pm-card-bg': '#0b0f17', '--pm-text': '#e6edf3' }
);
});
Multiple widgets on one page
The loader scans every .prediction-widget container, so you can drop as many as you want on the same page. PMWidgetFrame.setAccountId(…) and .setStyleOverrides(…) address all of them at once. If you need to target a single instance, post directly to its iframe:
const container = document.getElementById('my-widget');
const iframe = container._predictionIframe;
iframe.contentWindow.postMessage({
type: 'prediction:setAccountId',
accountId: '1234567'
}, '*');
CSP / iframe permissions
If your host page sets a Content Security Policy, you need to allow the widget host as a frame source and a script source. Replace the host with whatever environment you've been issued.
Content-Security-Policy:
default-src 'self';
script-src 'self' https://widgets.example.com;
frame-src https://widgets.example.com;
connect-src 'self' https://widgets.example.com wss://widgets.example.com;
frame-src: allows the iframe to load.script-src: allows the loader script to execute.connect-srcwithwss:: the widget streams live prices over WebSocket. Without this, charts won't tick.
If your host page sets X-Frame-Options or Content-Security-Policy: frame-ancestors, those only constrain what can frame your page — they don't affect what your page can embed.
Common pitfalls
-
JSON in
data-*attributes — use single quotes around the attribute value and double quotes inside the JSON, exactly as in every example here. Don't escape inner double quotes; if the JSON renders in the browser inspector as readable JSON, it's right. -
data-style-overridesdoesn't seem to do anything — open the iframe's devtools (right-click → inspect inside the widget) and check the<style>block injected at the top of<head>. If your variables are there but nothing changed visually, the rule consuming the variable might already be more-specific elsewhere; switch the Customizer panel on ("customizer": trueindata-settings) to confirm which variable controls the surface. -
Dashboard mode looks like explore mode (no balance, no positions) — you haven't pushed an
accountIdyet. Until one arrives, dashboard mode intentionally mirrors explore: it hides the Balance/Equity toolbar and Positions tab, shows the full public markets list, and routes Place Order to the Purchase Challenge CTA. Push anaccountId(PMWidgetFrame.setAccountId(...)) and the account-aware UI and real trading appear automatically. -
PMWidgetFrame is not defined— the loader script hasn't executed yet. In Next.js this means it ran before your component mounted (the polling pattern in the recipe handles it). In plain HTML, make sure the loader's<script>tag is before any code that usesPMWidgetFrame. -
Theme changes do nothing — theming is via
data-style-overrides(the--pm-*variables), not a high-level palette attribute. A straydata-themeis ignored. Use the style overrides (or the admin builder) to restyle surfaces.
FAQ
Can I host the loader script myself instead of pointing at the widget host?
Yes — the loader is self-contained and has no runtime dependencies. Copy loader.js to your CDN and point the <script src> at it. The iframe itself (the data-src page) still has to come from the widget host, since that's where the widget actually runs.
Can I render multiple businesses on one page?
Yes — each widget's data-settings carries its own business. Two widgets on the same page can target two different tenants.
How do I get a list of all --pm-* variables?
Set "customizer": true in data-settings. A floating launcher appears in the bottom corner of the iframe; opening it discovers every variable declared in the widget's stylesheet, grouped by surface. Click "Copy as JSON" to grab your edits in the right shape for data-style-overrides.
Does the widget work on mobile?
Yes — the layout switches to a mobile-optimized view below ~1024px, including a full-screen order panel on small screens. No configuration needed.
What about TypeScript types?
The loader exposes a global PMWidgetFrame. Declare it in your project once:
// global.d.ts
declare global {
interface Window {
PMWidgetFrame?: {
setAccountId(id: string): void;
setStyleOverrides(o: Record<string, string>): void;
setMarketPlatform(platform: 'PM' | 'KL'): void;
};
}
}
export {};
How do I unmount the widget cleanly in a SPA?
Removing the container from the DOM is sufficient — the loader doesn't attach any global listeners that survive container removal (other than the page-level message listener, which is harmless and idempotent). React's unmount is enough; no manual cleanup required.