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

# Authoring

> The project layout and what goes in a demo: steps, hotspots, captions, voiceover, covers, and the theme.

A demo is a `demo.config.json` and the media it references. This page explains the concepts. The [config reference](/open-source/config-reference) lists every field.

## Project layout

```text theme={"dark"}
my-demos/
  interactive-demo.json        { "name", "tokens"?, "brand"?, "demos"? }
  package.json                 scripts: dev, validate, build
  demos/
    onboarding/
      demo.config.json         the demo
      assets/                  screenshots, recordings, audio
    billing/
      …
```

`interactive-demo.json` needs only a `name`. Demos are discovered by walking `demos/`, so a folder is enough. The optional fields:

* `demos` fixes the order `dev` lists demos in.
* `tokens` sets the [theme](#theme) for every demo.
* `brand` fills the [page header](#brand-and-the-page-header).

To add a demo, [capture](/open-source/capture) one, scaffold a placeholder with `interactive-demo init --demo <slug>`, or import a folder or a capture zip with `init --from <dir|zip>`. The [editor](/open-source/editor) edits `demo.config.json` in place.

## The demo config

```json theme={"dark"}
{
  "$schema": "https://cdn.jsdelivr.net/npm/@inkly-org/interactive-demo/dist/schema/demo.config.json",
  "id": "tourExample0",
  "version": 1,
  "title": "Onboarding",
  "subtitle": "From sign-up to first project",
  "theme": { "tokens": { "primary": "#5b6cff" } },
  "chrome": { "controls": "full", "autoplay": false, "branding": true },
  "chapters": [{ "id": "setup", "title": "Setup", "stepIds": ["s1", "s2"] }],
  "steps": [ … ]
}
```

`id` is a 12-character URL-safe string that identifies the demo for its whole life. `init` and `capture` mint one. Keep it when you rename the folder, because the published URL is keyed on it.

The `$schema` line gives your text editor autocompletion and inline validation.

## Steps

A step is either `content` (a screen) or `cover` (a full-screen card built from widgets).

### Content steps

```json theme={"dark"}
{
  "kind": "content",
  "id": "s1",
  "label": "Dashboard",
  "background": {
    "type": "image",
    "src": "assets/screen-001.png",
    "naturalWidth": 1440,
    "naturalHeight": 900,
    "alt": "The dashboard after sign-in"
  },
  "script": "What the narrator says on this screen.",
  "annotations": [
    { "id": "a1", "type": "message", "variant": "pointer", "x": 0.72, "y": 0.18,
      "text": "Create a project from here." }
  ],
  "captions": [{ "id": "c1", "start": 0, "end": 2500, "text": "Create a project" }],
  "voiceover": { "src": "assets/screen-001.mp3" },
  "transform": { "zoom": 1.6, "x": 0.72, "y": 0.18 },
  "advance": { "trigger": "click" }
}
```

**Background.** `background.type` is `image` or `video`. `naturalWidth` and `naturalHeight` are the media's pixel size and drive the player's aspect ratio. A video background adds `posterSrc`, `autoplay`, and `muted`. Hotspots and zoom appear once the clip has played.

**Hotspots.** `annotations` are the hotspots. `x` and `y` (and `w` and `h` for an area) are fractions of the screen, from 0 to 1. A `message` comes in four variants:

| Variant | What it is |
| - | - |
| `cursor` | A simulated mouse pointer that glides over from the previous step. `capture` writes these. |
| `pointer` | A pulsing dot with a bubble. |
| `callout` | A pinned card. The default. |
| `area` | A highlighted region. |

A message with `advancesStep: true`, the default, moves to the next step when clicked. Two more annotation types sit beside messages: `text` overlays, and `blur` boxes to hide sensitive data.

**Captions.** `captions` are timed subtitles in milliseconds. On a step with a voiceover they follow the audio's clock. Otherwise they follow the time since the step started. `start` and `end` are optional. Leave both out and the caption shows for the whole step.

**Voiceover.** `voiceover` is an audio file played with the step. Record or attach one in the editor's [voiceover panel](/open-source/editor#voiceover).

**Zoom.** `transform` zooms the screen towards a point. The editor's **Zoom** sets it.

**Timing.** `duration`, in milliseconds, pins how long the step runs. Leave it out and the step lasts as long as its voiceover or its video, whichever is longer, or 5 seconds when it has neither.

`advance.trigger` is `click` (wait for the viewer) or `auto` (move on when the step's duration is up). It defaults to `auto` on a content step and `click` on a cover.

<Note>
  `chrome.autoplay` must be `true` for `auto` steps to run on their own. Without it, every step waits at its end.
</Note>

### Cover steps

```json theme={"dark"}
{
  "kind": "cover",
  "id": "intro",
  "widgets": [{
    "type": "headline",
    "id": "h1",
    "title": "Onboarding",
    "description": "Two minutes from sign-up to first project.",
    "textAlign": "middle",
    "cta": { "label": "Start", "action": { "type": "next" }, "animation": "shimmer" }
  }],
  "background": { "type": "color", "color": "#f7f7f5" },
  "advance": { "trigger": "click" }
}
```

The widgets:

| Widget | What it is |
| - | - |
| `headline` | Title, description, a button, and an optional logo and image. |
| `form` | Fields. The submit action can jump to a step. |
| `embed` | An iframe. |
| `custom` | Rendered by a component you register when you use the [React player](/open-source/runtime#custom-renderers). |

Button actions are `next`, `prev`, `restart`, `step`, `chapter`, and `url`. A closing cover with a `restart` button is the usual way to end a demo.

### Form submissions

A form's values go two places. The player always emits a `form_submit` event, which a React host receives through `onEvent` and an embedding page receives as a message. See [Listening for events](/open-source/embedding#listen-for-events-from-an-embedded-demo).

To store the values without writing code, give the widget a `submitTo` URL. The player POSTs the fields as JSON to it, then follows the submit button's action.

```json theme={"dark"}
{
  "type": "form",
  "id": "lead",
  "fields": [{ "id": "email", "label": "Email", "required": true }],
  "submitTo": "https://hooks.zapier.com/hooks/catch/…"
}
```

The body is `{ demoId, stepId, widgetId, fields: [{ id, label, value }], timestamp }`. The endpoint must accept a cross-origin POST. Webhook services and form backends do. Nothing is sent unless `submitTo` is set.

### Video steps by hand

Put the recording in `assets/` (WebM or MP4) with a poster frame next to it, then add a content step with a video background:

```json theme={"dark"}
{
  "kind": "content",
  "id": "s3",
  "background": {
    "type": "video",
    "src": "assets/clip-001.webm",
    "posterSrc": "assets/clip-001-poster.png",
    "naturalWidth": 1440,
    "naturalHeight": 900,
    "autoplay": true,
    "muted": true
  },
  "advance": { "trigger": "auto" }
}
```

With `chrome.autoplay` on and `advance.trigger: "auto"`, the step advances when the clip ends. Otherwise it holds on the last frame.

## Chapters and chrome

`chapters` group step ids under titles. The default player layout has no chapter menu. Chapters are what a `chapter` button action jumps to, what `controls.seekToChapter(id)` seeks to, and what `Demo.Chapters` lists in a [custom layout](/open-source/runtime#whats-in-the-package).

`chrome` controls the frame around the screen:

| Field | What it does |
| - | - |
| `hideHeader` | Hides the player's header. |
| `controls` | `full`, `minimal`, or `hidden`. |
| `mobileFooterMessage` | On small screens, moves hotspot messages into a footer bar with step navigation. Default `true`. Set `false` to keep them on the screen at every size. |
| `autoplay` | Lets `auto` steps advance on their own. |
| `branding` | The "Built with Inkly" badge. Default `true`. Set `false` to hide it. |

## Assets

A config references its media by path, relative to the demo folder:

```json theme={"dark"}
"background": { "type": "image", "src": "assets/screen-001.png" }
```

Anything under `assets/` works, as does an absolute URL for a file hosted elsewhere. There is no manifest.

* `validate` reports a path with no file behind it.
* `dev` and `build` serve the folder next to the page.
* `publish` uploads each referenced file once and rewrites the paths to the hosted URLs in the copy it sends. Your file on disk does not change.

The editor's asset panel writes the path for you. By hand, copy the file into `assets/` and reference it.

## Theme

There is one theme: an indigo accent, a dotted canvas, a macOS-style frame, and a watercolor behind cover steps. Give a cover its own `background` to replace the watercolor.

Customise the theme with four tokens. Set them project-wide in the project file's `tokens`, or per demo in `theme.tokens`. The demo's win.

| Token | Default | Sets |
| - | - | - |
| `primary` | `#5b6cff` | The accent: buttons, hotspots, the page-bar buttons |
| `secondary` | `#ebebeb` | Secondary surfaces and borders |
| `font` | `Inter, ui-sans-serif, system-ui, …` | The UI font stack |
| `radius` | `10px` | Corner radius |

<Note>
  Theme presets were removed. A demo's `theme.preset` and the project file's `theme` still load and are ignored. `validate` warns about them, so `validate --strict` fails until you delete them.
</Note>

## Brand and the page header

`dev` and `build` put a bar above the player: the brand mark and name on the left, the demo title, and up to two buttons on the right. All of `brand` is optional. With nothing set, the bar shows only the demo title.

```json theme={"dark"}
{
  "name": "Acme demos",
  "brand": {
    "logo": "brand/logo.svg",
    "name": "Acme",
    "logoHref": "https://www.example.com",
    "cta": { "label": "Try Acme", "href": "https://www.example.com/signup" },
    "secondaryCta": { "label": "Docs", "href": "https://docs.example.com" }
  }
}
```

* `logo` is an absolute URL or a path relative to the project root. `validate` checks that a relative file exists and stays inside the project. `build` copies it to `dist/<slug>/brand/`.
* Leave `name` out when the logo image already carries the wordmark.
* `logoHref` turns the mark into a link that opens in a new tab. Without it the mark links to `/`.
* Button and `logoHref` URLs must be `http(s)` or `mailto`. The buttons take their colour from the `primary` token.

<Warning>
  `publish` does not upload a project-relative `logo`. The hosted page shows the rest of the brand and the command warns. Use an absolute URL for a logo that should appear there.
</Warning>

The bar is plain HTML around the player, not part of `player.js`. An iframe of a built page shows it unless you add `?embed=inline`, and a page you assemble from the [page contract](/open-source/runtime#the-static-page-contract) does not have it. Under `dev` the bar also has an **Edit** button that opens the demo in the editor.

A demo can also carry its own `theme.brand` (`logo`, `name`, `logoHref`) for the header inside the player. That is the logo the editor's demo settings set. It travels with the demo wherever the player runs, including `<Demo>` in a React app.

## Check your work

```sh theme={"dark"}
interactive-demo validate    # schema, media paths, slugs
interactive-demo dev         # watch it
```


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