> ## Documentation Index
> Fetch the complete documentation index at: https://docs.inklyai.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Runtime and React API

> The player package: the Demo component and its props, DemoModal, events, the static page contract, and the theme.

`@inkly-org/interactive-demo` is the player. It comes in two shapes that render the same demo:

* a React component, `<Demo>`, for a site that is already React
* `player.js`, a self-contained bundle with React inside it, for a static page. This is what the CLI's `build` writes.

Both take the same `demo.config.json`. Nothing in the package talks to a server. It loads the config and the media you point it at, and that is all.

## Install

```sh theme={"dark"}
npm install @inkly-org/interactive-demo react react-dom
```

React and React DOM are peer dependencies (`>=18`). Zod is a real dependency, because the package validates every config it renders.

## What's in the package

| Import path | What you get |
| - | - |
| `@inkly-org/interactive-demo` | `Demo`, `DemoModal`, the primitives, the engine hooks, and the schema re-exported |
| `@inkly-org/interactive-demo/schema` | `DemoSchema`, `parseDemo`, every sub-schema and its inferred type, `DEMO_CONFIG_SCHEMA_URL` |
| `@inkly-org/interactive-demo/themes` | `resolveDemoTheme`, `demoThemeDefaultTokens`, `DEFAULT_DEMO_THEME_ID` |
| `@inkly-org/interactive-demo/styles.css` | The player stylesheet. Required, because the components ship no inline styles |
| `@inkly-org/interactive-demo/fonts.css` | Optional `@font-face` rules and the default theme's cover backdrop |
| `@inkly-org/interactive-demo/player.js` | The standalone bundle for a static page |
| `@inkly-org/interactive-demo/embed.js` | The pop-up loader for a non-React host page |
| `@inkly-org/interactive-demo/schema/demo.config.json` | The generated JSON Schema |

The root entry is ESM with a CJS build alongside it, and it ships types. Beyond `Demo` and `DemoModal`, it exports:

* **The player primitives**, also hung off `Demo` itself: `Demo.Root`, `Demo.Stage`, `Demo.Header`, `Demo.Controls`, `Demo.Chapters`, `Demo.Captions`, `Demo.ProgressBar`, `Demo.StepIndicator`, `Demo.MobileFooter`, `Demo.Widgets`, `Demo.Button`. Compose these when you want a player that is not the default layout.
* **`useDemoPlayerContext()`** and **`useAssetUrl()`**, for a component rendered inside the player.
* **`usePlayerController()`**, the engine behind `Demo.Root`, if you want the state machine without the UI.
* **`joinBaseUrl(base, path)`**, the one-slash join the player uses for media.
* **The schema:** `DemoSchema`, `parseDemo`, and the types (`DemoConfig`, `Step`, `Annotation`, `Widget`, `DemoEvent`, `ThemeTokens`, and more).

## The Demo component

```tsx theme={"dark"}
import { Demo } from '@inkly-org/interactive-demo';
import '@inkly-org/interactive-demo/styles.css';

export function ProductTour() {
  return <Demo src="/demos/onboarding/" />;
}
```

The component validates the config with `DemoSchema` before rendering. A config that fails validation renders no step rather than a half-built player, and the parse errors sit on the player context as `errors`.

### Props

| Prop | Type | What it does |
| - | - | - |
| `src` | `string \| object` | The demo. A string is the URL of its folder. An object is the config itself. |
| `config` | `object` | The config object. Same as an object `src`, kept for hosts that already pass it. |
| `baseUrl` | `string` | Where relative media paths are served from. Trailing slash optional. |
| `resolveAssetUrl` | `(path: string) => string` | Your own rule for turning a relative path into a URL. Wins over `baseUrl`. |
| `onEvent` | `(event: DemoEvent) => void` | The runtime [event stream](#events). |
| `onReady` | `({ demo, controls }) => void` | Fires once after the config parses and the player mounts. `controls` is stable, so you can keep it. |
| `themeTokens` | `Partial<ThemeTokens>` | Host-level token overrides, applied over the theme's defaults and under the demo's own tokens. |
| `size` | `'sm' \| 'md' \| 'lg'` | Player size class. Default `'md'`. |
| `controls` | `'auto' \| 'always'` | `'auto'` (default) fades the controls bar in on hover or focus. `'always'` keeps it up. |
| `layout` | `'default' \| (props) => ReactNode` | The player composition. Pass a function to build your own out of the primitives. |
| `components` | `AnnotationRendererMap` | Replace a built-in annotation renderer (`message`, `text`, `blur`). |
| `attribution` | `ReactNode` | A host watermark rendered on cover screens. Not authored by the demo. |
| `shareUrl` | `string \| null` | The demo's public URL, for the minimal controls' copy-link button. Omit it and the button is hidden. |
| `className` | `string` | Replaces the root class, which is `demo-root` by default. Most hosts want `style` instead. |
| `style` | `CSSProperties` | Merged over the theme's CSS custom properties on the root element. |
| `themeId` | `string` | Deprecated and ignored. There is one theme. |

`chrome` in the config still decides what the player shows: header, controls mode, the badge, autoplay. See [Chapters and chrome](/open-source/authoring#chapters-and-chrome).

The player takes the arrow keys, space, and `m`, but only after a click or a focus inside it. A host page's own shortcuts keep working elsewhere.

### Where the media comes from

A config references its media by a path relative to the demo folder, such as `assets/screen-001.png`. There are three ways to tell the player where that folder is.

<Tabs>
  <Tab title="Point at the folder">
    The component fetches `demo.config.json` from the URL and uses the same URL as the media base:

    ```tsx theme={"dark"}
    <Demo src="/demos/onboarding/" />
    ```

    It shows a `.demo-loading` placeholder while the request is in flight, and a `.demo-error` card with `role="alert"` if the fetch fails.
  </Tab>

  <Tab title="Import the config">
    Useful when you want the config in the bundle and no extra request. Say where its folder is served from:

    ```tsx theme={"dark"}
    import config from './demos/onboarding/demo.config.json';

    <Demo src={config} baseUrl="/demos/onboarding/" />
    ```
  </Tab>

  <Tab title="Resolve each path">
    `resolveAssetUrl` gets every relative path and returns the URL to fetch. Use it for a CDN, a signed URL, or a bundler that hashes the files. It takes precedence over `baseUrl`.

    ```tsx theme={"dark"}
    <Demo
      src={config}
      resolveAssetUrl={(path) => `https://cdn.example.com/onboarding/${path}`}
    />
    ```
  </Tab>
</Tabs>

The rules the resolver follows:

* An absolute URL in the config (`https://`, `data:`, `blob:`, or a site-root `/…` path) is used as-is. `baseUrl` and `resolveAssetUrl` never touch it.
* A relative path is joined onto `baseUrl`, with one slash between them and a leading `./` dropped.
* With no `baseUrl` and no `resolveAssetUrl`, a relative path is passed through and the browser resolves it against the page. The static build relies on this.

### Drive the player

`onReady` hands you the validated config and the controls:

```tsx theme={"dark"}
<Demo
  src="/demos/onboarding/"
  onReady={({ demo, controls }) => {
    console.log(demo.steps.length);
    controls.seekToStep('s3');
  }}
/>
```

`controls` is `play`, `pause`, `toggle`, `next`, `prev`, `seekToStep(id)`, `seekToChapter(id)`, `restart`, `setMuted(bool)`, `toggleMute`, `setCaptionsEnabled(bool)`, and `toggleCaptions`. `next` and `prev` are no-ops at the ends, so you do not have to bounds-check.

### Captions and voiceover

A step's `voiceover` plays with the step. Its `captions` show over the stage, timed against the audio when there is some. Captions are on by default.

In the full controls bar, a captions toggle appears on a step that has captions, and a mute button appears when any step in the demo has audio. The minimal bar shows mute on a step with a voiceover.

### Custom renderers

Annotations and widgets are separate things, so there are two maps:

* **Annotation renderers** go on `<Demo components={…}>`, keyed by annotation type: `message`, `text`, `blur`.
* **Widget renderers** are a prop of `Demo.Stage`, `widgetComponents`, keyed `widget.headline`, `widget.form`, `widget.embed`, and `widget.custom.<name>` for a `custom` widget's `name`. `<Demo>` does not forward this one, so registering a custom widget means composing the player yourself through the `layout` prop.

A `custom` widget with no matching renderer renders nothing and warns in development.

## Events

Every event carries `demoId` and `timestamp`.

| `type` | Also carries | When |
| - | - | - |
| `ready` | `stepIds` | Once, after the config parses and the player mounts. |
| `step_view` | `stepId`, `stepIndex` | On mount and on every step change. |
| `complete` | — | The demo reaches its end. |
| `cta_click` | `stepId`, `action`, `widgetId?`, `annotationId?` | A viewer clicks an authored button. |
| `form_submit` | `stepId`, `widgetId`, `fields` | A form widget is submitted. |
| `embed_message` | `stepId`, `widgetId`, `data` | An `embed` widget's iframe posts a message. |
| `custom` | `name`, `payload?`, `stepId?`, `widgetId?`, `annotationId?` | Emitted by a renderer you registered. |

`fields` is `[{ id, label, value }]`, in the authored field order.

```tsx theme={"dark"}
<Demo
  src="/demos/onboarding/"
  onEvent={(event) => {
    if (event.type === 'form_submit') {
      analytics.track('demo_lead', { fields: event.fields });
    }
  }}
/>
```

The package emits and you decide. Nothing is sent anywhere unless a form widget has a [`submitTo` URL](/open-source/authoring#form-submissions).

A demo inside an iframe relays the same events to the page that frames it. See [Listen for events from an embedded demo](/open-source/embedding#listen-for-events-from-an-embedded-demo).

## DemoModal

For a pop-up in a React app, do not load `embed.js`. `DemoModal` draws the same overlay but renders the player in-process through a portal, so there is no iframe and no second copy of the runtime.

```tsx theme={"dark"}
import { useState } from 'react';
import { Demo, DemoModal } from '@inkly-org/interactive-demo';

function TryTheDemo() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Try the demo</button>
      <DemoModal open={open} onClose={() => setOpen(false)} label="Onboarding demo">
        <Demo src="/demos/onboarding/" />
      </DemoModal>
    </>
  );
}
```

| Prop | What it does |
| - | - |
| `open` | Whether the modal is showing. |
| `onClose` | Called on Escape and on a click on the scrim. |
| `label` | The dialog's accessible name. Default `'Demo'`. |
| `children` | The player. Mounted only while open, so the demo starts from the beginning each time. |

The page stops scrolling while the modal is open, and focus goes back where it was on close.

## The static page contract

`player.js` is the whole player with React bundled in. It mounts itself from the page, so a page needs four things:

```html theme={"dark"}
<link rel="stylesheet" href="./player.css" />
<script id="demo-config" type="application/json">{ /* demo.config.json */ }</script>
<div id="root"></div>
<script src="./player.js"></script>
```

`player.css` is this package's `styles.css` under another name. The script tag must have `id="demo-config"` and `type="application/json"`.

There is no fetch and no manifest. The config is inline, media paths stay relative, and the browser resolves them against the page. That is why the page lives in the demo folder next to `assets/`.

With no `#demo-config` element, or a config that fails validation, the player renders an error card naming what went wrong instead of a blank page.

The player also applies the demo-level canvas background (`background` or `backgroundColor` in the config) to `#root`, and injects the theme's scoped CSS into `<head>`.

Two query parameters are read from the page URL:

* `?autoplay=1` starts playback as soon as the player is ready.
* `?render=1` also starts playback, and that is all it does today. It is reserved for an exporter driving the page through `window.__demo`.

### window.\_\_demo

Once mounted, the player publishes a small contract so a host page or an exporter can detect readiness, drive the player, and wait for the end:

```ts theme={"dark"}
window.__demo = {
  ready: true,       // set when the player has parsed the config and mounted
  complete: false,   // flips to true when the demo reaches its end
  stepIds: string[], // every step id, in order
  controls,          // play(), pause(), next(), prev(), seekToStep(id), …
  demo,              // the validated config
};
```

It appears when the player mounts, so poll for it rather than reading it on load:

```js theme={"dark"}
await new Promise((resolve) => {
  const tick = () => (window.__demo?.ready ? resolve() : setTimeout(tick, 50));
  tick();
});
window.__demo.controls.seekToStep('s3');
```

### Embed mode

The page the CLI writes reads `?embed=inline` (or its alias `?embed=1`) and renders the player alone: no page bar, no canvas, a transparent background, and the player filling the frame. That is the page's own CSS, not `player.js`. A page you assemble by hand from the four lines above gets the plain layout only.

When it is framed, the page talks to its parent with `postMessage`:

| Message | When |
| - | - |
| `{ type: "interactive-demo:close" }` | On Escape. The pop-up loader takes it as a request to close. |
| `{ type: "interactive-demo:size", width, height }` | In embed mode, with the player's rendered size whenever it changes. The pop-up loader sizes its frame to that ratio. |
| `{ type: "interactive-demo:event", event }` | For every runtime event. This relay is in `player.js`, not the page, so a hand-assembled page sends it too. |

[Sharing and embedding](/open-source/embedding) has the iframe, its sizing, and the pop-up loader.

## Theme and fonts

There is one theme: four tokens, plus CSS scoped to `[data-demo-theme="default"]`, which the root always carries. The tokens are what you customise.

| Token | Default |
| - | - |
| `primary` | `#5b6cff` |
| `secondary` | `#ebebeb` |
| `font` | `Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif` |
| `radius` | `10px` |

They cascade, each layer winning over the one before it:

1. the theme's defaults (`demoThemeDefaultTokens`)
2. the `themeTokens` prop
3. `config.theme.tokens`

The result lands on the root element as `--demo-primary`, `--demo-secondary`, `--demo-font`, and `--demo-radius`, with `--demo-primary-fg` computed for readable text on the primary colour. Everything else is a CSS-level default in `styles.css`, so host CSS can override it.

The theme's CSS is a separate string. `player.js` injects it for you on a static page. A React host injects it itself, once:

```ts theme={"dark"}
import { resolveDemoTheme } from '@inkly-org/interactive-demo/themes';

const { css } = resolveDemoTheme(); // put it in a <style> tag
```

<Note>
  Theme presets were removed. `config.theme.preset` and the `themeId` prop are still accepted and ignored. `demoThemePresets`, `demoThemePresetsById`, and the `DemoThemePreset` type are gone. Use `resolveDemoTheme()` or `demoThemeDefaultTokens`.
</Note>

### Fonts

`styles.css` loads no fonts. Without `fonts.css` the UI falls back to the system sans and mono, and cover steps get a flat fill. Opt in with one more import:

```ts theme={"dark"}
import '@inkly-org/interactive-demo/styles.css';
import '@inkly-org/interactive-demo/fonts.css';
```

`fonts.css` declares two faces, Inter and Geist Mono, both variable weight under the SIL Open Font License. It also sets the default theme's watercolor cover backdrop. Both point at files inside the package by URLs relative to the stylesheet, so nothing is fetched from a third party.

The CLI includes the same file as `player-fonts.css` next to `player.css`, with `fonts/` and `backgrounds/` beside it.

## The schema

```ts theme={"dark"}
import { DemoSchema, parseDemo, DEMO_CONFIG_SCHEMA_URL } from '@inkly-org/interactive-demo/schema';

const demo = parseDemo(JSON.parse(raw)); // throws a ZodError on a bad config
```

`parseDemo` is `DemoSchema.parse`. Every sub-schema is exported next to it (`StepSchema`, `AnnotationSchema`, `WidgetSchema`, `ChromeSchema`, and more) along with the inferred types, so you can build a config in TypeScript and have the compiler check it.

`annotations[]` and `widgets[]` are deliberately tolerant. A known `type` is validated strictly. An unknown one parses and is skipped by the player, so a config written for a newer runtime still plays.

The field-by-field list is the [config reference](/open-source/config-reference).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.