# Recording a web app

> Record your product into a cast that replays as real DOM — sharp at any size, selectable, embeddable in a slide or a website, and kilobytes rather than megabytes

A product demo in a deck is almost always an MP4, and an MP4 is the wrong
format for a user interface. It is lossy on exactly the content it carries —
small type, hairlines, flat colour — it is a fixed grid of pixels in a layout
that is not, and it weighs megabytes a minute.

`narro record` captures the *sources* of the frame instead: the DOM as it stood,
every mutation and input that followed, and the bytes of everything the page
drew with. The result replays as real DOM.

```bash
npx @getnarro/cli record https://your.app --html demo.html
```

A window opens, you drive the flow, and pressing Enter writes the file. Open it
and the recording plays: text you can select, sharp at any zoom, and the whole
thing works offline.

## Stopping it

The recording ends when you say so, and there are three ways to say it. All
three save:

| Do this | And |
| --- | --- |
| press **Enter** in the terminal | the browser closes and the file is written |
| press **Ctrl+C** in the terminal | the same thing happens. It is not an abort |
| **close the browser window** | the same thing happens |

The terminal says so while it is recording, so there is nothing to remember:

```
  ● recording — drive the flow in the window
    press Enter here to stop and save
    Ctrl+C or closing the window save it too — nothing is lost
```

Only a *second* Ctrl+C, after the first one has been acknowledged, abandons the
run — by then the file is usually already written.

## Recording the browser you already have

The window `record` opens is a browser with a fresh profile: signed out of
everything, no extensions, none of the state a demo of a real product is made
of. `--connect` records the Chrome you are already using instead.

Start Chrome once with remote debugging on:

```bash
# macOS
open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.narro-chrome"

# Linux
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.narro-chrome"

# Windows
chrome.exe --remote-debugging-port=9222 --user-data-dir="%TEMP%\narro-chrome"
```

Then record it:

```bash
npx @getnarro/cli record https://your.app --connect --html demo.html
```

`--connect` on its own means `http://127.0.0.1:9222`. A port, a host and port,
or a full URL all work if you moved it: `--connect 9333`.

Two things worth knowing about that Chrome flag, because neither is guessable:

- **The separate `--user-data-dir` is not optional.** Since Chrome 136 the
  default profile ignores `--remote-debugging-port` — a security fix — so a
  Chrome started without it accepts no connection and `--connect` reports that
  nothing is listening. The directory you name is a real profile that keeps its
  cookies: sign in once and every later recording is already signed in.
- **It has to be started with the flag.** A Chrome that is already open cannot
  be given the port afterwards.

What attaching does, once connected:

- If a tab is **already showing the URL**, that tab is recorded where it stands —
  no reload, so the half-filled form or the state you spent five minutes getting
  into is still there when recording starts. rrweb opens with a full snapshot of
  whatever is on screen, so a recording can begin in the middle of a session.
- Otherwise a **new tab** opens on the URL. Your other tabs are not touched.
- When the recording stops, narro **disconnects**. Your browser, and every tab
  in it, keeps running.
- The recording viewport is whatever that window is, so `--size` does not apply.
  Resize the window before you start.

`--connect` and `--auto` are opposites — one records the browser you are
driving, the other tours a page in a browser of its own — so the two cannot be
combined.

## The window it opens, and its size

`--size` is the size of the *page*, and the window is sized to match: a
`--size 1440x900` recording gets a window with 1440x900 of page in it, plus
whatever height the tab strip and omnibox need on your platform.

```bash
npx @getnarro/cli record https://your.app --size 1280x800
```

If the screen cannot fit that — 1440x900 on a 1366x768 laptop wants a window
with its foot below the display — the window is shrunk to fit and the terminal
says what it recorded instead:

```
  1366x681
  (1440x900 would not fit on this screen)
```

The number printed is measured from the window rather than assumed, and it is
what the cast records as its viewport. That matters more than it sounds:
[a cast is not responsive](#what-a-cast-is-not), and the replay scales the
layout it captured, so a cast whose stated viewport was not the one the page
laid itself out at replays at the wrong shape.

## Two ways to embed it

They want different files, so ask for the one you need.

**A website** should serve the parts separately, because a site already knows
how to serve and cache files:

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

That writes the player, the cast, and the assets as ordinary files, plus the
snippet to paste:

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

The player is the same bytes for every demo on the site, so it caches once. The
page itself pays for the event stream — tens of kilobytes for a short flow.

Beside those files it also writes `EMBEDDING.md` — the same demo written out for
React, Astro, Preact, Vue and Svelte, with your own paths already in it — and
`PROMPT.md`, which is that written as an instruction to hand a coding agent.
`record` offers you the same prompt when it finishes, so the next step is one
keystroke away rather than a file you have to go and find.
[Embedding a replay](/docs/embedding) is the full version.

**Anything you hand someone** — an email, a deck that must work on a plane, a
file on a stick — wants `--html`: one file, no server, no network.

## On a slide

In a markdown deck, a fence — no import, no project:

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

Put the recording in the deck's `public/` directory, exactly as you would an
image. Every option is listed under
[Replays](/docs/markdown-mode#replays).

In a React deck — or any React app — the typed wrapper lets the page drive the
recording rather than the viewer, and keeps the 200 KB player out of the bundle
until a replay is on screen:

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

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

Because `narro export --pdf` and `--pptx` produce files that cannot run
JavaScript, record a still for them at the same time:

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

## The cursor

A replay with no cursor is a page that changes for no visible reason — the
viewer sees the result of a click and never the click. `--cursor pointer` draws
one; `--cursor trail` also draws the path it took, which is what a flow through
several screens usually needs; `--cursor none` hides it.

Unattended recordings (`--auto 8`) move the pointer as well as scrolling, for
the same reason. They scroll whatever the page actually scrolls: an app shell —
a full-height layout whose main pane moves while the document does not — is
toured by its pane, so a tour of one is not a still frame with a cursor on it.

## Selecting the text in a replay

A reader can drag across the words in a replay and highlight them, which is the
one thing no video of an interface can offer, and worth pointing at in the
caption. Three things follow from a live frame, and the player handles all
three: a click cannot navigate away from the recording, the wheel scrolls the
page around the demo rather than the demo, and a selection holds the replay
where it is — text that scrolls out from under the pointer cannot be selected —
until nothing is selected again. On a touch screen the frame stays inert, so a
thumb pans the page.

## What a cast is not

- **Not responsive.** Layout was computed once, at the recording viewport, and
  every scroll offset in the stream assumes it. The player scales rather than
  reflows. Record a second cast at `--size 390x844` for a phone.
- **Not the application.** The recording carries the DOM your app produced,
  never its code — nothing runs on replay, so anything that was not recorded
  does not exist. Interactivity is a re-enactment, not a live product.
- **Blind to canvas and WebGL.** A chart library, a map or a 3D viewer records
  as an empty element. Those demos still want a video.

## Before you record something real

**A cast is text, and `grep` works on it.** It carries what was on screen and a
great deal that was not: content behind `display: none`, options in a select
nobody opened, everything below the fold, and — the part that surprises people —
every attribute and URL, including `title`, `alt`, `aria-label`, `data-*` and
any token sitting in an `href`.

`--mask` applies rrweb's privacy profile, which masks input values and drops
scripts and comments. It is worth using and it is **not sufficient**: it does
not touch attributes or URLs at any setting. Recording a page of real customer
data is a decision to publish that data, so record a demo account.

## Why it is small

Measured on a real page, the portable single file is 414 KB where the obvious
implementation produces 1.5 MB, and a website pays 60 KB for the cast:

- the player carries rrweb's **replay half only** — a page showing a recording
  has no reason to ship a recorder;
- assets are stored **once, under the hash of their bytes**, rather than inlined
  wherever they appear, which otherwise ships the same font twice — and that
  includes images the page had *already* inlined as `data:` URIs, worth about a
  fifth of a cast on a page that repeats one;
- images are re-encoded to WebP at the same dimensions;
- and both halves travel gzipped, at the most zlib will give. Not brotli, which
  would be worth twice as much and which no browser will decompress from
  `DecompressionStream` — see
  [how big it is](/docs/embedding#how-big-it-is-and-why-it-is-not-smaller).

Fonts are the one thing a cast must carry rather than link. A font file is
fetched in CORS mode no matter whose stylesheet asked for it, and a self-hosted
face ships no `Access-Control-Allow-Origin` header — because same-origin never
needed one. On any other origin, every one of them is refused.