Prediction Widget Developer Guide

v1
Get started →

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:

  1. A <div class="prediction-widget"> placeholder with data-* attributes that describe what to load and how it should look.
  2. 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.
  3. Optionally, a small bit of glue code that pushes the user's accountId into 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:

  1. Finds every .prediction-widget container on the page.
  2. Reads its data-src, data-settings, and data-style-overrides attributes.
  3. Creates an <iframe> inside the container, points it at data-src.
  4. Stores a reference to the iframe on the container as container._predictionIframe so your code can find it later.
  5. Once the iframe fires its load event, posts the parsed settings and style overrides into it via window.postMessage.
  6. Listens for return messages from the iframe (prediction:resize, prediction:confirm, prediction:alert) and reacts in the parent page.
Tip

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.

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:

HTML
<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.

Note

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.

HTML
<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 markets browser only — live events, charts, prices. No order panel, no balance, no positions. Perfect for landing pages, marketing sites, or any context where the user isn't logged in yet.

HTML
<div class="prediction-widget"
     data-src="https://widgets.example.com/explore.html"
     data-settings='{"business":"PMKT"}'></div>
Tip

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).

AttributeRequiredTypePurpose
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.

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.

KeyTypeDefaultDescription
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.

Example

JSON
{
  "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.

Tip

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:

JSON
{
  "--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:

PrefixSurfaceCommon properties
--pm-card-*Event cards & generic cardsbg, border-color, border-width, border-radius
--pm-text / --pm-text-secondaryBody / muted text(value only)
--pm-surfaceSubtle elevated surface (drawer, tooltip)(value only)
--pm-borderDefault border color(value only)
--pm-money-card-*Balance / Equity stripbg, border-*, label-color, value-color
--pm-active-positions-btn-*Top "Positions" buttonbg, text, border-*, *-hover
--pm-positions-tabs-*Positions tab bar container (Active / Closed)bg, border-*, padding, border-radius
--pm-positions-tab-*Positions tab buttonsbg, text, bg-hover, text-hover, bg-active, text-active, padding-x, padding-y, border-radius, font-size, font-weight
--pm-positions-pager-*Positions table paginationbg, text, text-active, text-hover
--pm-live-price-box-*Live crypto price tilesbg, border-*, border-radius
--pm-live-chart-toggle-*Chances ↔ Price chart togglebg, border-*, btn-bg, btn-bg-active, btn-text, btn-text-active
--pm-live-go-btn-*"Go to live market" call-to-actionbg, 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 buttonbg, text, border-*, radius
--pm-filter-btn-*Top filter pillsbg, text, bg-hover, text-hover, bg-active, text-active
--pm-order-*Order panel surfacesbg, border-*, label-color, value-color
Discover them visually

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 two 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.

JS
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:

JS
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.

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

MessagePayloadEffect
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.

Widget → Host

MessagePayloadHandled by
prediction:resize{ height: number }Loader resizes the container + iframe.
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.
Note

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.

HTML + JS
<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:

JS
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:

HTML
<!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

index.html
<!-- public/index.html (CRA / Vite) -->
<body>
  <div id="root"></div>
  <script src="https://widgets.example.com/loader.js"></script>
</body>
PredictionWidget.jsx
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)}
    />
  );
}
Usage
<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):

JSX
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+)

app/layout.tsx
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>
  );
}
app/dashboard/PredictionWidget.tsx
'use client';

import { useEffect, useRef } from 'react';

declare global {
  interface Window {
    PMWidgetFrame?: {
      setAccountId(id: string): void;
      setStyleOverrides(o: Record<string, string>): 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:

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"
      />
    </>
  );
}
SSR

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:

CSS
.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.

JS
// 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:

JS
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.

HTTP Header
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-src with wss:: 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-overrides doesn'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": true in data-settings) to confirm which variable controls the surface.
  • Widget shows blank in dashboard mode — you haven't pushed an accountId yet. The explore mode renders without one; the dashboard mode renders empty placeholders.
  • 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 uses PMWidgetFrame.
  • Theme changes do nothing — theming is via data-style-overrides (the --pm-* variables), not a high-level palette attribute. A stray data-theme is 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:

TS
// global.d.ts
declare global {
  interface Window {
    PMWidgetFrame?: {
      setAccountId(id: string): void;
      setStyleOverrides(o: Record<string, string>): 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.