π§©βEmbedding a replay
How do I embed a recorded demo of my app in my React app, my Astro site, or any other page?
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:
| 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 intopublic/,static/or whatever your framework serves at the root β do notimportthe cast through a bundler, which would move the file and leave the fonts behind. - The snippets know where it landed.
--embed ./public/demowrites 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
posteris 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_SCHEMAon 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.
| 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:
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
| 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: hiddenand a fixed height, usually. Give the replayfilland 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 recordprints 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.