πŸ§©β€‚Embedding a replay

How do I embed a recorded demo of my app in my React app, my Astro site, or any other page?

16 min readView as markdown

A recording made with narro 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.

<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

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

That writes a directory of ordinary files:

FileWhat it is
recording.narrocastthe recording: DOM, mutations, and the bytes the page drew with
assets/fonts and images the recording refers to
narro-player.global.jsthe player, for a page with no build step
embed.htmlthe snippet to paste, with your paths already in it
EMBEDDING.mdthe same demo written for each framework
PROMPT.mdthe 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

<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

npm install @getnarro/replay
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.

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

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

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:

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

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:

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

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:

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:

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:

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:

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

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

narro record https://your.app --auto 20 --chapters chapters.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:

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

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

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.

WrittenMeans
{.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,300Point at a coordinate in recording pixels.
at=740,300,120,44Point at a rectangle.
at=#publishPoint at an element. #id, .class and [attr=value] resolve.
for=1.5sHow long it stays. Leave it out and it runs to the next annotation.
pauseHold 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:

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

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

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:

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

Every option is listed under Replays.

Which import is which

ImportFor
@getnarro/replay/elementany page with a build step β€” defines the tag, nothing else
@getnarro/replay/reactReact and preact/compat β€” adds the lazy player, the poster and server rendering
narro-player.global.jsa <script src> tag on a page with no build step
@getnarro/replayNode: 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.

IntroductionWhat is Narro, and should I be writing markdown or React?
InstallationWhat do I install, and what does the project look like afterwards?
First DeckWhat does a working React deck look like, end to end?
Markdown ModeHow do I split a slide, style one word, or reveal a line β€” without leaving the markdown file?
Writing AI PromptsWhat do I tell an AI so the deck it writes actually builds?
Rules for AI AgentsWhat do I paste into my repo so an agent stops writing decks that build wrong?
React APIWhich component or hook do I import, and what does it take?
CLIWhich command do I run, and what are its flags?
Markdown APIHow do I read, edit, or validate a deck file from my own code instead of by hand?
Component ReferenceWhat props does this component take, and which package do I import it from?
Verifying a deckHow do I know my deck is actually correct?
AnimationHow do I reveal a list one line at a time, or move between slides with something other than a cut?
NavigationHow does the audience move through the deck, and how do I present it?
Canvas & PositioningHow do I put something at an exact position instead of in the flow?
Images & MediaHow do I use an image as a background, tint it, or embed a video?
ArchitectureWhich package owns what, and why is the seam where it is?
Zooming canvasHow do I zoom and pan across one big canvas instead of cutting between slides?
Transform ModeHow do I zoom and pan across one big canvas instead of cutting between slides?
Import & ExportHow do I get this deck out as PPTX, PDF, or one file I can email?
VideoHow do I turn a story into a video, and keep it right when the words, the product or the source changes?
Recording a web appHow do I put a demo of my product in a deck or on a landing page without shipping a video of it?
Embedding a replayHow do I embed a recorded demo of my app in my React app, my Astro site, or any other page?
TerminalHow do I put a terminal demo in a deck, a README or a video, and find out when the tool it demonstrates stops saying what the demo says?
AI IntegrationHow do I wire an AI assistant up to Narro so it can write and build decks?
ThemingHow do I change the colours, fonts, and overall look of a deck?
Deck TemplatesHow do I define one house style with named layouts my slides can reference, like a PowerPoint master?
TroubleshootingSomething is wrong with my deck. What is it, and how do I fix it?
LimitationsWhat can't Narro do, and what do I do instead?