@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’sbuildwrites.
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
>=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
Demoitself: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()anduseAssetUrl(), for a component rendered inside the player.usePlayerController(), the engine behindDemo.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
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 asassets/screen-001.png. There are three ways to tell the player where that folder is.
- Point at the folder
- Import the config
- Resolve each path
The component fetches It shows a
demo.config.json from the URL and uses the same URL as the media base:.demo-loading placeholder while the request is in flight, and a .demo-error card with role="alert" if the fetch fails.- An absolute URL in the config (
https://,data:,blob:, or a site-root/…path) is used as-is.baseUrlandresolveAssetUrlnever touch it. - A relative path is joined onto
baseUrl, with one slash between them and a leading./dropped. - With no
baseUrland noresolveAssetUrl, 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’svoiceover 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, keyedwidget.headline,widget.form,widget.embed, andwidget.custom.<name>for acustomwidget’sname.<Demo>does not forward this one, so registering a custom widget means composing the player yourself through thelayoutprop.
custom widget with no matching renderer renders nothing and warns in development.
Events
Every event carriesdemoId and timestamp.
fields is [{ id, label, value }], in the authored field order.
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 loadembed.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=1starts playback as soon as the player is ready.?render=1also starts playback, and that is all it does today. It is reserved for an exporter driving the page throughwindow.__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: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:
- the theme’s defaults (
demoThemeDefaultTokens) - the
themeTokensprop config.theme.tokens
--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.
