# Embedding a replay

> Put a recording of your app into a React, Preact, Astro, Vue, Svelte or plain HTML page — one tag, one import, and a component that renders on a server

A recording made with [`narro record`](/docs/record) is a `.narrocast` file, and
playing one takes two things: something that defines the `<narro-replay>`
element, and a URL pointing at the cast.

That is the whole of it, in every framework. What changes between them is only
how you spell the import.

```html
<narro-replay src="/demo/recording.narrocast" controls></narro-replay>
```

The replay takes the width it is given and the height that follows from the
recording's own shape, the way an `<img>` does. There is no aspect ratio to
guess, no wrapper with a fixed height, and no resize handler.

## Put the files where the site serves them

```bash
npx @getnarro/cli record https://your.app --embed ./public/demo --poster ./public/demo/poster.png
```

That writes a directory of ordinary files:

| File | What it is |
| --- | --- |
| `recording.narrocast` | the recording: DOM, mutations, and the bytes the page drew with |
| `assets/` | fonts and images the recording refers to |
| `narro-player.global.js` | the player, for a page with no build step |
| `embed.html` | the snippet to paste, with your paths already in it |
| `EMBEDDING.md` | the same demo written for each framework |
| `PROMPT.md` | the same thing addressed to a coding agent |

Two things about that directory:

- **Keep it together.** A cast names its assets relative to *itself*, so the
  cast and its `assets/` folder move as one. Copy the directory into
  `public/`, `static/` or whatever your framework serves at the root — do not
  `import` the cast through a bundler, which would move the file and leave the
  fonts behind.
- **The snippets know where it landed.** `--embed ./public/demo` writes
  snippets pointing at `/demo/…`, because that is the URL those files will
  have.

## Letting an assistant wire it up

The recording is the part you do; putting it on a page is the part most people
hand to a coding agent, and an agent given only a `.narrocast` file guesses —
it has not seen the format, and the two things it gets wrong are always the
same two. So when a recording finishes, `record` offers you the prompt:

```
  Print a prompt to paste into a coding agent? [y/N]
```

Answer `y` and it prints a page of instructions with your own paths already in
it, ready to copy into whatever assistant you use. Answer anything else — Enter
included — and nothing is printed. The default is deliberately *no*: Enter is
also how you just stopped the recording, and pressing it twice should not be
read as a choice.

`--prompt` prints it without asking, for a scripted run. `--embed` writes the
same text to `PROMPT.md` beside the files, so it is there whether or not you
took it in the terminal.

## A page with no build step

```html
<script src="/demo/narro-player.global.js"></script>
<narro-replay src="/demo/recording.narrocast" controls autoplay loop></narro-replay>
```

One `<script>` tag, wherever it suits you — the element upgrades whenever the
player arrives, so `defer` and a tag at the end of the body are both fine. The
player is the same bytes for every recording on the site, so it is cached once
however many demos the site has.

## React

```bash
npm install @getnarro/replay
```

```tsx
import { Replay } from "@getnarro/replay/react";

export function Demo() {
  return <Replay src="/demo/recording.narrocast" poster="/demo/poster.png" controls />;
}
```

Three things that component does which the bare tag cannot:

- **It does not put the player in your main bundle.** The player is 200 KB, and
  the component imports it dynamically — so it becomes a chunk your app fetches
  when a replay renders, and a route without one never pays.
- **It renders on a server.** Next.js, Remix, Astro's React islands and any Vite
  SSR build import it without a DOM. The `poster` is what the server-rendered
  HTML carries, and the recording replaces it in the browser.
- **It can be driven by your app** rather than by the viewer — see
  [Driving it from your app](#driving-it-from-your-app).

In **Next.js**, `<Replay>` uses state, so it belongs in a client component:

```tsx
"use client";

import { Replay } from "@getnarro/replay/react";

export function Demo() {
  return <Replay src="/demo/recording.narrocast" poster="/demo/poster.png" lazy controls />;
}
```

`lazy` waits until the replay is within a screen of the viewport before fetching
anything, which is what a demo below the fold on a landing page wants. Leave it
off for one on a slide or in a tab: an element inside `display: none` never
intersects anything, so it would never load.

## Preact

The element works as it is:

```tsx
import "@getnarro/replay/element";

export function Demo() {
  return <narro-replay src="/demo/recording.narrocast" controls />;
}
```

If the app already aliases `preact/compat` to `react`, which most do, the React
component above works too and brings the dynamic import with it.

## Astro

A custom element is browser code, so the import goes in a `<script>` block. Astro
bundles it and ships it with the page:

```astro
---
// src/pages/index.astro
---

<narro-replay src="/demo/recording.narrocast" controls autoplay loop></narro-replay>

<script>
  import "@getnarro/replay/element";
</script>
```

The tag renders in Astro's HTML output with no JavaScript at all; the import is
what makes it play. Add a `poster` attribute and there is something to look at
before the script lands, which is also what a reader with JavaScript off sees.

## Vue, Svelte, Solid, Lit, Angular

The same import, once, anywhere in the app's entry:

```ts
import "@getnarro/replay/element";
```

Then the tag, in whatever that framework's templates look like. Two frameworks
want a word first:

- **Vue** warns about unknown elements unless it is told this one is custom:
  `compilerOptions.isCustomElement: (tag) => tag.startsWith("narro-")` in your
  Vite config.
- **Angular** needs `CUSTOM_ELEMENTS_SCHEMA` on the module or component that
  contains the tag.

## Sizing

By default the replay is shaped like the recording: full width, and as tall as
that width implies. That is what a page wants.

A slide, a modal or a fixed panel has already decided how much room the
recording gets, and there `fill` letterboxes the recording into that box
instead:

```tsx
<div style={{ height: "60vh" }}>
  <Replay src="/demo/recording.narrocast" fill />
</div>
```

Whatever the box, the recording is **scaled, not reflowed**. It was laid out
once, at the viewport it was recorded at, and every scroll offset in the stream
assumes that width — so give it a box near the recording's own proportions, or
record a second cast at the size you need.

## Driving it from your app

A recording usually has more in it than one screenful of narrative. Rather than
leaving a viewer to scrub, move it yourself:

```tsx
const [step, setStep] = useState(0);

<Replay src="/demo/recording.narrocast" chapter={step === 1 ? "publish" : "open the editor"} />
```

Changing `chapter` seeks. For anything finer, take a `ref`:

```tsx
import { useRef } from "react";
import { Replay, type ReplayHandle } from "@getnarro/replay/react";

const replay = useRef<ReplayHandle>(null);

<Replay ref={replay} src="/demo/recording.narrocast" onFinish={() => replay.current?.play(0)} />
<button type="button" onClick={() => replay.current?.seek("publish")}>Skip to publishing</button>
```

Outside React the element itself carries the same three methods — `play(ms?)`,
`pause(ms?)` and `seek(chapterOrMs)` — and fires `narro-replay:ready`,
`narro-replay:finish` and `timeupdate`:

```ts
const replay = document.querySelector("narro-replay");
replay.addEventListener("narro-replay:finish", () => replay.play(0));
```

### Following the clock

`timeupdate` fires as the recording plays, and once on every seek. It carries
nothing: read `currentTime` and `duration` off the element, in milliseconds, the
way you would off a `<video>`.

That is the point of the name. `<narro-terminal>` emits the same event and
carries the same two getters, so a site with a transport of its own writes one
handler and drives its terminal, its video and its screen recording with it
instead of three:

```ts
const replay = document.querySelector("narro-replay");
replay.addEventListener("timeupdate", () => {
  bar.style.setProperty("--played", replay.currentTime / replay.duration);
});
```

In React it arrives as `onTime`, with the two numbers already read off the
element — which is what anything drawn *over* a replay needs: a caption that
appears at four seconds, a chapter list that marks where the viewer has got to,
a step that advances with the recording rather than with a click:

```tsx
<Replay
  src="/demo/recording.narrocast"
  onTime={({ at }) => setReached(chapters.filter((chapter) => chapter.atMs <= at).length)}
/>
```

It fires at frame rate while playing, so keep the handler cheap — set state only
when the value it derives has actually changed.

## Chapters

`chapter`, `start` and `seek()` all take a **chapter name**, and a name that is
not in the cast does not fail: `seek` falls back to the beginning, so the demo
plays from the top and looks like a timing bug rather than a typo. Chapters come
from the recording, and there are two ways to put them there.

**Type them while recording.** The prompt that waits for you to press Enter also
takes names, and a name marks a chapter at the moment you started typing it:

```console
$ narro record https://your.app
  ● recording — drive the flow in the window
    press Enter here to stop and save
    type a name and press Enter to mark a chapter there

the editor
    ◆ the editor at 2.2s
publish
    ◆ publish at 6.4s
```

**Or hand over a file**, for the recordings nobody sits through — `--auto`, or a
run in CI:

```bash
narro record https://your.app --auto 20 --chapters chapters.json
```

```json
[
  { "name": "the editor", "atMs": 1500 },
  { "name": "publish", "atMs": 4200 }
]
```

Either way, `narro cast` reads them back — which is how you check a name before
writing it into a deck:

```console
$ narro cast demo.narrocast

  Your app
  https://your.app

  1440×900 · 8.4s · 312 events · 6 assets · recorded 2026-09-10T10:00:00.000Z

  chapters
    1.5s  the editor
    4.2s  publish
```

## Annotations

A recording shows what happened. An annotation says what it meant — a caption
under the flow, a callout on the button before it is clicked, a spotlight that
dims everything else.

They are written as a **notes track**: a markdown file beside the recording,
which is what a person edits, what a model writes with no browser to drive, and
what a pull request reviews.

```markdown
<!-- demo.notes.md -->
---
cast: demo.narrocast
---

## 0:02 — the editor

Markdown in, **slides** out. {.caption}

## 0:06 — publish

Hit `publish` and it deploys. {.spotlight at=#publish}

## 0:09

Everything is a static file. {.callout at=740,300 for=2s}
```

A `##` heading opens a moment and every paragraph under it is an annotation
there. Anything after a dash in the heading is a label for whoever reads the
file; only the time has to parse, and it can be written `0:06`, `6.5s` or
`6500ms`. Prose before the first heading is the file explaining itself.

Bake it in, and the annotations travel inside the recording:

```bash
narro annotate demo.narrocast --notes demo.notes.md
```

`narro record --notes demo.notes.md` does the same at record time, and
`narro cast demo.narrocast` lists the track that a recording carries.
Re-running `annotate` replaces the track rather than adding to it, so the notes
file stays the source of truth; `--clear` removes it.

### The attribute block

The same `{.class key=value}` syntax as a deck.

| Written | Means |
| --- | --- |
| `{.caption}` | A line under the recording. The default when nothing points anywhere. |
| `{.callout}` | A card beside what it names, with a ring around it. |
| `{.spotlight}` | A callout that also dims the rest of the frame. |
| `{.pill}` | A callout in a rounded chip, for one or two words. |
| `at=740,300` | Point at a coordinate in **recording pixels**. |
| `at=740,300,120,44` | Point at a rectangle. |
| `at=#publish` | Point at an element. `#id`, `.class` and `[attr=value]` resolve. |
| `for=1.5s` | How long it stays. Leave it out and it runs to the next annotation. |
| `pause` | Hold the replay here until the viewer clicks — a guided tour. |
| `{#name}` | Name the annotation, so a diff of the track stays readable. |

An annotation with no `for=` runs until the *next one without a `for=`* starts,
so a track of captions reads as one continuous line and a timed callout laid
over it does not cut the sentence short.

Coordinates are in recording pixels and never move: a cast is not responsive, so
the layout it was recorded at is the layout forever. An `at=#publish` element is
measured on every frame instead, so an annotation on something the recording
scrolls past follows it — and hides itself once the target leaves the frame,
rather than pointing off the edge.

An annotation whose selector matches nothing is kept as a caption and reported.
The words are the part worth keeping; a stale `at=` should not delete the
sentence.

### Drafting a track from a transcript

`narro notes` turns what someone said into a track, and resolves what the
narration points at:

```bash
narro notes demo.narrocast --from talk.vtt
```

It reads WebVTT, SRT or Whisper's JSON, joins subtitle-length cues into
sentences, and — the part a video cannot do — looks up what each line names. "And
then you hit publish" finds the button whose label is `Publish` and writes
`at=#publish`, because the label is in the recording as text rather than as
pixels. Every anchor it guesses carries a comment saying what it matched, so the
guess can be checked at a glance.

What comes out is a **draft**. Speech is repetitive, and a transcript baked in
verbatim reads badly even when every word is right — cut it before
`narro annotate`.

`narro record --mic` captures the narration while you drive, and
`--transcribe` runs a Whisper-shaped binary over it. Both need tools on your
machine — ffmpeg, and `whisper-cli` or similar — and both fail softly: a
recording is never lost to a microphone that would not open. The offset between
the two clocks is printed, because `narro notes --offset` can correct a known
error and cannot correct one nobody was told about.

### Editing a track by hand, visually

```bash
narro annotate demo.narrocast --ui
```

Opens the track the recording is paired with: scrub, add a line at the moment
you are looking at, click the recording to point at something, and save. It
writes the same `.notes.md` a person edits, so a hand-written track can be
opened, nudged and saved without losing its comments, its ordering, or the
anchors it already had.

## Reviewing a recording

```bash
narro review demo.narrocast
```

Every rule is a pure function over the event stream, so a finding is a fact
about the file rather than an opinion about the demo: a click that produced no
visible change, three still seconds, a scroll nobody could read, type that will
be six pixels tall once embedded, chapters too far apart to drive a slide with.

**Run it before you publish one.** A cast is text that travels, and an email
address, card number or API token typed into a form during the recording is in
that text. That finding is an error rather than a warning, so the command fails
on it — `--mask` when recording replaces input values.

`--out` writes the briefing: the same findings, with the reason each rule exists
and the fix for each, addressed to whoever will act on them. That reader may be
a coding agent, so it opens by saying the thing such a reader is most likely to
get wrong — a recording is not source, and anything wrong with the flow is a
re-record rather than a patch.

### What annotations do not do

They never take the pointer. `allowSelection()` hands it to the recording so a
reader can select the text in a replay, which is the whole claim of DOM over
video, and a caption drawn on top must not take that back. The one exception is
`pause`, because a held replay is not being read for its text.

## On a slide

A markdown deck needs none of the above — a fence does it, and the deck's own
build wires up the player:

````markdown
```replay
src: /demo/recording.narrocast
poster: /demo/poster.png
cursor: trail
```
````

Every option is listed under [Replays](/docs/markdown-mode#replays).

## Which import is which

| Import | For |
| --- | --- |
| `@getnarro/replay/element` | any page with a build step — defines the tag, nothing else |
| `@getnarro/replay/react` | React and `preact/compat` — adds the lazy player, the poster and server rendering |
| `narro-player.global.js` | a `<script src>` tag on a page with no build step |
| `@getnarro/replay` | Node: reading, writing and vendoring casts. No DOM code |

Importing more than one is harmless: the element registers once.

## When it does not appear

- **An empty box that is the right shape.** The cast or its assets 404ed. Open
  the network tab: the cast's assets are fetched relative to the cast's own URL,
  so `assets/` has to sit beside it.
- **An empty box with no height at all.** Something in your CSS gave the element
  a resolved height of zero — a parent with `overflow: hidden` and a fixed
  height, usually. Give the replay `fill` and the parent a height, or take the
  height off the parent.
- **Nothing at all, and a console error about a custom element.** Two different
  copies of the player were loaded and one lost the race to define the tag.
  Import it in one place.
- **A demo whose text is in the wrong font.** The cast was recorded before the
  fonts finished loading, or the font's licence forbade embedding — `narro
  record` prints a line when it has to leave one out.

## How big it is, and why it is not smaller

A cast is JSON, gzipped at the maximum level zlib offers, and the page fetches
it and decompresses it with `DecompressionStream`. Measured on a recording of a
real console UI: 381 KB of JSON, 27.5 KB on the wire.

Brotli would make that 13.6 KB — a cast is one document with the same markup
repeated down its length, and gzip's window is 32 KB, so the sixth copy of a
table cannot see the first. It is still gzip, because no shipping browser will
decode brotli from `DecompressionStream`, and carrying a brotli decoder in the
player would cost every page more than it saves any cast.

What is done instead is to give gzip less to repeat. Assets are stored once
under the hash of their bytes rather than at every use site — including images
the page had already inlined as `data:` URIs, which is worth about a fifth of a
cast on a page that repeats one far enough apart for gzip to miss it. Images
are re-encoded to WebP unless you pass `--no-optimize`, and anything the
recording did not actually fetch is never carried at all.