Skip to main content
@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

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

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

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

chrome in the config still decides what the player shows: header, controls mode, the badge, autoplay. See 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.
The component fetches demo.config.json from the URL and uses the same URL as the media base:
It shows a .demo-loading placeholder while the request is in flight, and a .demo-error card with role="alert" if the fetch fails.
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:
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. fields is [{ id, label, value }], in the authored field order.
The package emits and you decide. Nothing is sent anywhere unless a form widget has a submitTo URL. A demo inside an iframe relays the same events to the page that frames it. See 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.
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:
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:
It appears when the player mounts, so poll for it rather than reading it on load:

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: Sharing and 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. 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:
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.

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

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.