Video Script Basics
ScreenCI videos use the same syntax, guides, and general best practices as Playwright tests. The main difference is that video scripts usually do not need assertions, and instead focus on viewer-facing behavior such as narration and camera movement.
Need a faster way to get a first draft? Generate one with Playwright codegen (or let a coding agent do it, see Agent integration), then come back here to refine the script structure and ScreenCI-specific APIs.
You will learn
- how to generate a first draft with codegen
- how a ScreenCI video differs from a Playwright test
- how to configure ScreenCI
- how to mock API requests safely in SPAs
- which core ScreenCI APIs to use
Generate a first draft with codegen
Writing a script from scratch is optional. Playwright’s test generator (codegen) lets you click through the exact flow in a browser and have the actions written for you. Run:
npx playwright codegen https://your-app.example.comThis opens a browser window and the Playwright Inspector. As you click, type, and navigate, it generates Playwright actions for the flow. The output is not a ScreenCI video yet. To turn it into one:
- copy the generated code into a
recordings/<flow>.screenci.tsfile - change
test(...)tovideo.narration(...)(...) - add narration through
video.narration({...})(see Core ScreenCI APIs)
Then follow the usual screenci test and screenci record flow. If you only
have a deployed URL and want this automated, point a coding agent at it with the
playwright-cli skill, see Agent integration.
ScreenCI video vs Playwright test
The generated starter video keeps the same Playwright-style structure, but it
uses a ScreenCI-instrumented page and locators so visible interactions are
captured with the right metadata for recording. See Animated
Interactions. Use it as the baseline shape for
most ScreenCI videos, then adjust the visible flow, narration cues (via
video.narration), and zoom
behavior for your specific walkthrough.
import { autoZoom, hide, video } from 'screenci'
video .overlays({ logo: { path: './assets/logo.png', duration: 2000, overMouse: true }, }) .narration({ docs: 'Here is where to find ScreenCI [pronounce: screen see eye] docs.', })('How to find docs', async ({ page, narration, overlays }) => { // Run setup without showing these actions in the final recording. await hide(async () => { await page.setContent(landingPageHtml()) })
// Open with a brief brand intro card before the walkthrough begins. await overlays.logo.for(2000)
// Play the narration line for this step. await narration.docs()
// Automatically zoom into interactions so they are easier to follow. await autoZoom(async () => { await page.getByRole('link', { name: 'View Documentation' }).click() })})
function landingPageHtml(): string { return `<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <title>ScreenCI smoke page</title> <style> body { margin: 0; font-family: Inter, system-ui, sans-serif; background: #111827; color: white; } main { min-height: 100vh; display: grid; place-items: center; text-align: center; } a { color: #111827; background: #fbbf24; padding: 14px 18px; border-radius: 8px; text-decoration: none; font-weight: 700; } </style> </head> <body> <main> <div> <h1>ScreenCI</h1> <p>Record docs, onboarding, and changelog walkthroughs from code.</p> <a href="https://screenci.com/docs">View Documentation</a> </div> </main> </body></html>`}You can define multiple video() calls in the same file, or create multiple
.screenci.ts files under recordings/.
Configure ScreenCI
Project-wide defaults such as projectName, recordingDir, baseURL, and shared
recording or rendering options live in screenci.config.ts.
import { defineConfig } from 'screenci'
export default defineConfig({ // Used to identify this project in ScreenCI. projectName: 'my-product',})See Configuration.
Mock API requests in SPAs
When a video mocks API calls with page.route(), prefer exact API URLs or
absolute URL prefixes instead of broad globs. In Vite and other SPA dev servers,
module files are also normal browser requests; a broad route can accidentally
match /src/... module URLs and fulfill them with JSON, leaving React with an
empty root and little browser output.
const apiBase = 'http://localhost:5173/api'
await page.route(`${apiBase}/recipes`, async (route) => { await route.fulfill({ json: [{ id: 'pasta', name: 'Pasta' }], })})If you must use a broad matcher, let non-API browser resources continue:
await page.route('**/*recipes*', async (route, request) => { if (request.resourceType() !== 'fetch' && request.resourceType() !== 'xhr') { await route.fallback() return }
await route.fulfill({ json: [{ id: 'pasta', name: 'Pasta' }] })})ScreenCI also fails fast if a route fulfills a document, stylesheet, or script request with the wrong content type, which usually means a mock intended for an API endpoint intercepted an app asset.
Core ScreenCI APIs
hide(), speed(), and time()
Timeline helpers that decide whether a step is removed from the final video or
just retimed. Not every automated step belongs in the recording: use hide()
for non-viewer setup the viewer does not need to watch, such as signing in,
accepting cookies, waiting for app state, or opening the right screen before the
visible flow begins.
// hide(): run the step but remove it from the output.// Use for navigation, sign-in, waiting, or dismissing banners.await hide(async () => { await page.goto('/reports') await page.getByRole('button', { name: 'Accept cookies' }).click()})
// speed(): keep the step visible, but play it faster or slower.// 1 = real time, 0.5 = half-speed (2x longer), 2 = 2x speed (half as long).await speed(0.5, async () => { await page.getByRole('button', { name: 'Preview invoice' }).click()})
// time(): keep the step visible and make it occupy an exact duration.// Here the visible block lasts exactly 1000ms in the output.await time(1000, async () => { await page.getByRole('tab', { name: 'Analytics' }).click() await page.waitForLoadState('networkidle')})
// Nesting rules:// - hide() may sit inside speed() or time(), but not inside another hide().// - speed() and time() may not be nested inside each other or themselves.// - Narration cue audio is not retimed; these only remap the recording timeline.API reference: hide(). See also Animated Interactions for how visible actions are captured.
timestamp()
Marks a named point in the recording. It has no effect on the rendered output: it only labels a moment so the web editor can surface it on its own timeline row, making it easy to find the exact spot a step happened when reviewing or editing.
await timestamp('checkout-opened')await page.getByRole('button', { name: 'Pay' }).click()await timestamp('payment-done')The name can be reused; each call is a distinct marker. Markers are read-only in the editor (they come from code).
Positions: holding narration and overlays until a point in the video
Narration cues and overlays can take a string position so they land at an
absolute point in the finished video, instead of a relative duration. This is
handy in long stretches (for example a recorded playback) where hand-computing
page.waitForTimeout deltas is brittle.
// Narration: start the line and hold its window until the position.await narration.intro.until('0:10') // until 10 seconds inawait narration.outro.until('56%') // until 56% through the video
// Overlays: keep the (static) overlay on screen until the position.await overlays.tip.until('0:10') // until 10 seconds inawait overlays.tip.until('2s') // seconds (fractions allowed: '5.51s')Accepted forms for .until(...): '<n>s' seconds, 'm:ss(.f)' /
'h:mm:ss(.f)' timecodes, and '<n>%' percentages. For a relative length
instead, use .for('<n>s') or .for(<ms>). Positions are resolved against the
finished render, so they are correct against the actual video, and narration
audio is never cut (the window extends to let a line finish). Percentages are
not supported on .mp4 or animated overlays, whose length is fixed. See
Narration and Overlays.
autoZoom()
Use autoZoom() when the camera should follow a visible interaction
automatically. See Camera and Zooming.
Common options:
durationto control how fast the zoom moveseasingto control motion feelamountto control how tightly ScreenCI zooms inpaddingto keep more space around the target areacenteringto bias framing within the viewportpreZoomDelayandpostZoomDelayto add breathing room before or after the zoomed sequence
await autoZoom(async () => { await page.getByRole('button', { name: 'Create project' }).click()})API reference: autoZoom()
zoomTo()
Use zoomTo() when you want exact manual framing, and resetZoom() when you
want to return to the default view afterward. See Camera and
Zooming.
zoomTo() accepts either:
- a locator, when you want framing tied to a real UI element
{ x, y }, when you want to frame an exact point manually
Common options for zoomTo() and resetZoom():
durationeasingamountpaddingpreZoomDelaypostZoomDelay
await zoomTo(page.getByTestId('pricing-card-pro'))await page.getByRole('button', { name: 'Upgrade' }).click()await resetZoom()API reference: zoomTo(), resetZoom()
video.narration(), video.values(), and video.overlays()
Use the per-feature builders to attach narration cues, localized strings, and
overlay controllers to a video. Each builder returns the same chainable video,
so you can combine them and end with a (title, body) call. The body receives
the fixtures matching the features you declared: narration markers (timing
only), values field values, overlays controllers, and the active language. See
Narration.
video.narration({...}) accepts either form:
- an object keyed by language (
en,es,fi, …) of cue name to text = per-language narration - a flat object of cue name to text (for example
{ intro: 'Hi' }) = shared across all languages - a bare array of cue names (for example
['intro']) = name-only cues where Editor (the web editor) owns the text. Object forms supply code values that stay editable in Editor; an Editor edit wins over the code value.
Other parts of the spec:
- chain
video.values({...})for localized strings injected into the page - chain
video.overlays({...})to declare overlay controllers (see below) - short, sentence-sized cues instead of paragraph-sized narration blocks
Voice is configured separately as a render option in renderOptions.narration
(via video.renderOptions(...) or screenci.config.ts), with a default voice
and per-language voices overrides.
import { video, voices } from 'screenci'
// Voice is a render option (how narration is spoken).video .renderOptions({ narration: { voice: { name: voices.Ava } } }) // Localized narration cues by language. .narration({ en: { intro: 'Open settings and review the billing details.' }, es: { intro: 'Abre la configuracion y revisa los detalles de facturacion.', }, })('Billing walkthrough', async ({ page, narration }) => { // Play the full cue before continuing. await narration.intro()
// Or use start/end when narration should overlap with the visible actions. await narration.intro.start() await page.goto('/settings') await page.getByRole('button', { name: 'Open billing' }).click() await narration.intro.end()})Prefer one sentence per cue. Split longer narration into separate named cues and place them where they belong in the flow. That gives you cleaner overlap control, makes revisions less brittle, and should save API cost when a TTS provider such as ElevenLabs only needs to regenerate one changed sentence.
To control which languages are recorded, chain video.languages(...) (call it
with no argument for a fully web-owned set, a plain array of language codes as
a seed, or { languages, mode }). The recorded set is the union of the web
app’s selection, the code seed, and per-feature language keys. For example,
video.narration({...}).languages({ mode: 'shared' }) records a single shared
narration track instead of one per language. A video with no .languages(...)
call records one language-agnostic round pinned to the en-US browser locale.
Overlays
Use video.overlays({...}) to declare overlay controllers for a video. The
controllers are exposed through the overlays fixture in the body:
import { video } from 'screenci'
video.overlays({ logo: { image: 'logo.png', position: 'top-right' },})('Branded intro', async ({ page, overlays }) => { await overlays.logo.show() await page.goto('/dashboard') await overlays.logo.hide()})For Editor-owned overlays (declared by name, with the web editor owning their
content), pass a bare array of names: video.overlays(['logo']). You can
combine this with the bare-array form of narration, for example
video.narration(['intro']).overlays(['logo']). Object forms supply code
values that stay editable in Editor; an Editor edit wins over the code value.
Render options work the same way: values declared through
use({ renderOptions: {...} }) or per video with video.renderOptions(...)
are the starting point, and web edits override them. Every recording also
tracks which option values its Playwright actions used (for example
move.duration or position) and whether each was explicit in code or a
default, so the web editor can present and override them; see
Action parameter tracking and overrides.
API reference: voices