import { autoZoom, hide, resetZoom, video } from 'screenci'
// Auto-zoom follows the action: the camera zooms and pans to whatever you touch,
// with no manual zoom points. The controls are spread across two cards, so this
// one block zig-zags corner to corner and the camera makes big jumps between
// each interaction.
video('Auto-zoom', async ({ page }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/customers/new')
await page.waitForLoadState('networkidle')
})
// A tight `amount` frames each control closely, so the camera makes a big pan
// across the screen to follow each next interaction.
await autoZoom(
async () => {
// Top-left: type into the work email field.
await page
.getByLabel('Work email')
.pressSequentially('[email protected]')
// Jump top-right: pick a plan from the segmented control.
await page.getByRole('button', { name: 'Scale' }).click()
// Jump back left: type into the full-name field.
await page.getByLabel('Full name').pressSequentially('Emma Carter')
// Jump right: drag the seat-count slider up to ~62 of 100. dragTo targets a
// point along the slider root, and the camera follows the thumb across it.
const thumb = page.locator('[data-slot="slider-thumb"]').first()
const track = thumb.locator('xpath=ancestor::*[@data-slot="slider"][1]')
const trackBox = (await track.boundingBox())!
await thumb.dragTo(track, {
targetPosition: {
x: (trackBox.width * (62 - 1)) / (100 - 1),
y: trackBox.height / 2,
},
moveDuration: 900,
dragDuration: 1000,
dragSteps: 32,
})
// Bottom corners: open a dropdown on each side and pick.
await page.getByRole('combobox', { name: 'Account type' }).click()
await page.getByRole('option', { name: 'Customer' }).click()
await page.getByRole('combobox', { name: 'Region' }).click()
await page.getByRole('option', { name: 'Asia Pacific' }).click()
},
{ amount: 0.45 }
)
await resetZoom()
})Ship a polished product video. Don't record one.
Describe the video you want, paste one prompt into your coding agent, and get back a narrated, auto-zoomed walkthrough of your real app. Change the wording later in the browser, without touching the repository.
Free to try, no account needed. See paid plans.
Let your coding agent make it
Paste this into Claude Code, Codex, Cursor, or another coding agent. It adds ScreenCI and records your first video for you.
Free to try: record up to three videos with no account and no payment.
This is what your agent hands back
Every clip here is a real ScreenCI render of a real app. Auto-zoom, narration synced to each step, redacted secrets, your own components as overlays, branded screenshots, 79 languages.
Auto-zoom that follows the action
The camera jumps to follow every interaction.
Embed images and video
File assets on the timeline.
import { hide, video } from 'screenci'
// Embed media files directly: images/SVGs and MP4 clips are assets on the
// timeline. Programmatic overlays are React/HTML components; these are files.
video.overlays({
logo: { path: './assets/logo.png', fill: 'recording', duration: 2100 },
preview: {
path: './assets/1-sample-code.mp4',
fill: 'recording',
volume: 0,
time: 3200,
},
})('Embed media', async ({ page, overlays }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/analytics')
await page.waitForLoadState('networkidle')
})
await overlays.logo()
await page.waitForTimeout(900)
await overlays.preview()
await page.waitForTimeout(1400)
})Speaks 79 languages
Every line in a different language.
import { hide, video } from 'screenci'
// One recording, every language: each cue overrides its own `language`, so a
// single render greets you in a different tongue on every line.
video.narration({
hello: { cue: 'Hello from ScreenCI.', language: 'en' },
spanish: { cue: '¡Compatible con el español!', language: 'es' },
french: { cue: 'Compatible avec le français !', language: 'fr' },
german: { cue: 'Unterstützt Deutsch!', language: 'de' },
chinese: { cue: '支持中文!', language: 'cmn' },
japanese: { cue: '日本語に対応しています!', language: 'ja' },
hindi: { cue: 'हिंदी भी चलती है!', language: 'hi' },
})('Multilanguage narration', async ({ page, narration }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/')
await page.waitForLoadState('networkidle')
})
await narration.hello()
await narration.spanish()
await narration.french()
await narration.german()
await narration.chinese()
await narration.japanese()
await narration.hindi()
})Localize the videos
One recording, every language.
import { hide, video } from 'screenci'
// Localize the videos: record the /welcome page once, and ScreenCI renders it in
// every language you list, each with its own narration. `browserLocale` (on by
// default) sets the browser locale per render, so the page localizes itself and
// the narration matches.
// Fill the whole output frame (no gradient margin) so the localized content is
// full-bleed, and stays that way when embedded by the compose showcase. Keeps
// the shared marketing background + framing, but overrides size to 1 (full
// bleed) so this clip has no gradient margin.
video
.renderOptions({
output: {
background: {
backgroundCss:
'radial-gradient(circle at top left, #dbeafe 0%, #eff6ff 28%, #f8fafc 58%, #e0f2fe 100%)',
},
},
recording: { size: 1, roundness: 0.04, dropShadow: 1 },
})
// The target: the localized /welcome page, recorded in en/es/fi.
.narration({
en: { intro: 'Welcome to Northstar. Let’s set up your workspace.' },
es: { intro: 'Bienvenido a Northstar. Vamos a configurar tu espacio.' },
fi: { intro: 'Tervetuloa Northstariin. Otetaan työtila käyttöön.' },
})('Localized welcome', async ({ page, narration }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/welcome')
await page.waitForLoadState('networkidle')
})
await narration.intro()
await page.waitForTimeout(800)
})Keep secrets out of the recording
Mask secrets before the frame is captured.
import { hide, resetZoom, video, zoomTo } from 'screenci'
// True redaction: the mask is applied in the browser before any frame is
// captured, so the secret never enters the uploaded recording.
video('Redact secrets', async ({ page }) => {
const visibleSecret = 'sk_live_51ExistingValue8f1c2a'
const redactedSecret = 'sk_live_51P0J8vX2f7r9d3cH'
await hide(async () => {
await page.goto('https://demo.screenci.com/settings/company')
await page.waitForLoadState('networkidle')
await page.getByLabel('Tax ID').fill(visibleSecret)
})
const taxId = page.getByLabel('Tax ID')
await zoomTo(taxId, { amount: 0.5, duration: 300 })
await page.waitForTimeout(700)
await taxId.fill(redactedSecret, {
duration: 900,
redact: true,
})
await resetZoom()
})Styled backgrounds
Frame clips on branded gradients.
import { hide, video, zoomTo } from 'screenci'
// Frame every recording with rounded corners. Set it in code (below), or slide
// through the same controls in Editor.
video.use({
renderOptions: {
recording: {
size: 0.82,
shape: 'rounded',
roundness: 0.05,
},
},
})
video('Styled backgrounds', async ({ page }) => {
await hide(async () => {
await page.goto(studioVideoUrl)
await page.getByRole('button', { name: 'Create one-off version' }).click()
const renderOptions = page.getByTestId('section-render-options')
await renderOptions.evaluate((element) => {
element.scrollIntoView({ block: 'center', inline: 'nearest' })
})
await zoomTo(renderOptions, {
amount: 0.64,
duration: 0,
postZoomDelay: 0,
})
await page.waitForTimeout(1800)
})
// Keep the zoom locked while changing the visible render controls.
for (const preset of ['ocean', 'sunset', 'coral', 'aurora']) {
await page.getByTestId(`background-preset-${preset}`).click()
}
})Speed ramps
Fast-forward the repetitive steps.
import { hide, resetZoom, speed, time, video, zoomTo } from 'screenci'
// Speed ramps two ways. Explicit `speed` or `time`, both work:
// speed(n): a RELATIVE multiplier on playback. n > 1 fast-forwards, n < 1 slows
// down. Best for compressing (or stretching) repetitive stretches.
// time(ms): an ABSOLUTE target. The block is retimed to occupy exactly this
// long, however long it really took, so you can pin one beat to a
// precise duration.
// Shared marketing render styling (project-wide look): light radial-gradient
// background, framed recording tile with soft corners and a drop shadow.
video.renderOptions({
output: {
background: {
backgroundCss:
'radial-gradient(circle at top left, #dbeafe 0%, #eff6ff 28%, #f8fafc 58%, #e0f2fe 100%)',
},
},
recording: { size: 0.9, roundness: 0.04, dropShadow: 1 },
})('Speed ramps', async ({ page }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/settings/company')
await page.waitForLoadState('networkidle')
})
await zoomTo(page.locator('form').first())
// speed(): fast-forward the repetitive fields into a quick montage (3x).
await speed(3, async () => {
await page
.getByLabel('Legal name')
.fill('Aperture Bio Inc', { editId: 'fill1' })
await page.getByLabel('Tax ID').fill('47-2819001', { editId: 'fill11' })
await page.getByLabel('Phone').fill('(512) 555-0142', { editId: 'fill10' })
await page
.getByLabel('Address')
.fill('2200 Congress Ave', { editId: 'fill9' })
await page.getByLabel('City').fill('Austin', { editId: 'fill2' })
await page.getByLabel('Postal code').fill('78701', { editId: 'fill3' })
await page.getByLabel('Country').fill('United States', { editId: 'fill8' })
})
// time(): slow one deliberate beat down to an exact three seconds, so the last
// field types out in emphasized slow motion.
await time(3000, async () => {
await page
.getByLabel('Billing email')
.pressSequentially('[email protected]', {
delay: 40,
editId: 'pressSequentially2',
})
})
await page.waitForTimeout(500)
await resetZoom()
})Cinematic motion blur
Natural motion blur on render.
import { autoZoom, hide, resetZoom, video } from 'screenci'
// Own intent (strong zoom + cursor motion blur) plus the shared marketing
// render styling: light radial-gradient background and framed recording tile.
video.renderOptions({
zoom: { motionBlur: 1 },
mouse: { motionBlur: 1 },
output: {
background: {
backgroundCss:
'radial-gradient(circle at top left, #dbeafe 0%, #eff6ff 28%, #f8fafc 58%, #e0f2fe 100%)',
},
},
recording: { size: 0.9, roundness: 0.04, dropShadow: 1 },
})('Motion blur', async ({ page }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/analytics')
await page.waitForLoadState('networkidle')
})
// Zoom in close, then sweep the cursor across a longer horizontal span so the
// motion blur reads as broad side-to-side movement instead of twitchy hops.
await autoZoom(
async () => {
const overview = page.getByRole('button', { name: 'Overview' })
const cohorts = page.getByRole('button', { name: 'Cohorts' })
await overview.waitFor({ state: 'visible' })
const overviewBox = (await overview.boundingBox())!
const cohortsBox = (await cohorts.boundingBox())!
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 }
const left = Math.max(80, overviewBox.x - 240)
const right = Math.min(
viewport.width - 80,
cohortsBox.x + cohortsBox.width + 240,
)
const top = Math.max(80, overviewBox.y - 160)
const middle = overviewBox.y + overviewBox.height / 2
const bottom = Math.min(
viewport.height - 80,
cohortsBox.y + cohortsBox.height + 180,
)
await page.mouse.move(left, top, { duration: 350, easing: 'ease-in-out' })
await page.mouse.move(right, middle - 40, {
duration: 1100,
easing: 'ease-in-out',
})
await page.mouse.move(right - 120, bottom, {
duration: 900,
easing: 'ease-in-out',
})
await page.mouse.move(left + 80, middle + 80, {
duration: 900,
easing: 'ease-in-out',
})
await page.getByRole('button', { name: 'Retention' }).click()
await page.waitForTimeout(500)
await page.getByRole('button', { name: 'Cohorts' }).click()
},
{ amount: 0.5, duration: 450, zoomOutDuration: 350, easing: 'ease-in-out' },
)
await page.waitForTimeout(500)
await resetZoom({ zoomOutDuration: 350 })
})Branded, up-to-date screenshots
Framed images from the same script.
import { screenshot } from 'screenci'
const BACKGROUND_CSS =
'radial-gradient(circle at 14% 10%, oklch(0.93 0.07 78) 0%, transparent 42%),' +
'linear-gradient(313deg, oklch(0.74 0.18 50) 0%, oklch(0.69 0.21 45) 38%, oklch(0.62 0.21 41.116) 100%)'
// A framed still of a live page, dressed with two code-built overlays (a status
// pill and a lower-third caption). Because it comes from the same script as the
// videos, it re-renders and stays current on every run, no manual re-capture.
const shot = screenshot.overlays({
statusPill: {
html: '<div class="shot-pill"><span class="shot-dot"></span>LIVE - auto-updated</div>',
css: `.shot-pill { padding: 12px 20px; border-radius: 999px; color: #fff;
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%); /* ... */ }`,
x: 1340,
y: 838,
width: 420,
},
caption: {
html: '<div class="shot-cap">Framed, branded, always current</div>',
css: `.shot-cap { padding: 20px 26px; border-radius: 18px; color: #fff; /* ... */ }`,
x: 96,
y: 824,
width: 680,
},
})
screenshot.use({
renderOptions: {
screenshot: { aspectRatio: '16:9', margin: 96, mouse: { show: false } },
recording: {
roundness: 0.05,
dropShadow: 'drop-shadow(0 22px 49px rgba(0,0,0,0.28))',
},
output: {
background: { backgroundCss: BACKGROUND_CSS },
},
},
})
shot('Branded screenshot', async ({ page, overlays }) => {
await page.goto('https://screenci.com')
await page.getByRole('heading', { level: 1 }).first().waitFor()
// A still has no timeline: start each overlay and leave it open so it stays
// baked into the captured image.
await overlays.statusPill.start()
await overlays.caption.start()
})Programmatic overlays
Your own React, Vue, or Svelte components on top.
import { hide, video } from 'screenci'
import { Callout } from './overlays' // a small React component, built in code
// Programmatic overlays: place callouts, badges, and captions from code, timed to
// the action. The same idea two ways at once: a React card, and an inline-HTML
// chip. Both render to the same transparent, deterministic overlay.
video.overlays({
// A React card, lower-left over the recording.
card: {
element: Callout({
kicker: 'Programmatic overlays',
title: 'Built in React, timed in code',
}),
x: 96, y: 720, width: 640, duration: 3400,
},
// The same feature in plain inline HTML + CSS, top-right.
htmlChip: {
html: '<div class="ov-chip">…or plain HTML & CSS</div>',
css: `.ov-chip { padding: 12px 20px; border-radius: 999px;
background: rgba(255,255,255,0.94); color: #0f172a; /* ... */ }`,
x: 1430, y: 96, width: 420, duration: 3400,
},
})('Programmatic overlays', async ({ page, overlays }) => {
await hide(async () => {
await page.goto('https://demo.screenci.com/pipeline')
await page.waitForLoadState('networkidle')
})
await overlays.card.start()
await overlays.htmlChip.start()
await page.waitForTimeout(3000)
await overlays.htmlChip.end()
await overlays.card.end()
})Vertical video, one config line
Freely change the aspect ratio.
import { hide, video } from 'screenci'
video
.renderOptions({
recording: { size: 0.96, roundness: 0.04 },
output: {
aspectRatio: '9:16',
quality: '1080p',
background: { backgroundCss: '#ffffff' },
},
})
.recordOptions({ aspectRatio: '9:16', quality: '720p' })(
'Vertical video',
async ({ page }) => {
await hide(async () => {
await page.goto('https://screenci.com/', {
waitUntil: 'domcontentloaded',
})
await page.getByRole('heading', { level: 1 }).first().waitFor()
})
await page.waitForTimeout(4500)
}
)Promptable speaking style
Direct tone and delivery per cue.
import { video, voices } from 'screenci'
// Each cue carries its own style prompt, so the same voice adapts its
// delivery per line. Write the direction in plain English.
video.narration({
hook: {
cue: 'Every narration line can have its own speaking style.',
voice: {
name: voices.Nora,
style: 'Warm and intriguing, drawing the viewer in.',
},
},
pirate: {
cue: 'Arr! Even a swashbuckling pirate can guide the tour, if that be the style ye prompt.',
voice: {
name: voices.Nora,
style: 'A boisterous pirate captain, gravelly and full of swagger.',
},
},
whisper: {
cue: 'Or lean right in, because the best part is a little secret.',
voice: {
name: voices.Nora,
style: 'A hushed, conspiratorial whisper, close to the mic.',
},
},
outro: {
cue: 'Write the style in plain English. The voice does the rest.',
voice: {
name: voices.Nora,
style: 'Confident and conclusive, landing the final thought.',
},
},
})('Per-cue speaking style', async ({ page, narration }) => {
await page.goto('https://screenci.com/docs/guides/narration')
await narration.hook()
await narration.pirate()
await narration.whisper()
await narration.outro()
})Make it once. It maintains itself.
Prompt the agent again when the flow changes and the video is re-recorded against the new UI. Anyone on the team can rewrite the narration in the browser without waiting for a re-shoot.
One prompt, one video
Paste one prompt into Claude Code, Codex, or Cursor. The agent installs ScreenCI, explores your app or codebase, writes the script, and records. You review a finished video, not a blank file.
Agent integration guideEdit in the browser, together
Teammates who never touch code can refine narration, swap voices, and tune render options in the web editor, then ship a new version themselves. Edits sync back into the script, so code stays the source of truth.
The web editorPublish to a permanent URL
Push approved videos to fast, embeddable CDN links that always resolve to the latest version. Embed once and never touch the link again.
Public URLs and embedsExport them as E2E tests
Ask the agent to commit the videos into your repository and each one becomes a Playwright E2E test. Your own CI can then re-record them on every release, and a video that stops passing tells you the flow broke before your users find out.
CI setupOverlay your own components
Float React, Vue, Svelte, or Solid components over the recording: intro cards, logos, callouts, with real props, hooks, and CSS animations. Your design system, live in the video.
Overlays guideLocalize into 79 languages
Turn one source video into 79 languages with natural AI narration and subtitles. ScreenCI can record each language on the localized page, so the narration matches the content your users actually see. Update the source once and every version re-renders in sync.
Languages guideCommon questions
Do I need to know how to code?
No. You describe the video in a sentence and your coding agent writes and runs everything else. Under the hood the video is a script, but you never have to open it: narration, voices, overlays, and languages are all editable in the web editor.
Can teammates who do not code edit the videos?
Yes. The web editor lets anyone on your team rewrite narration, swap voices, adjust overlays, add languages, and ship a new version without touching the repo.
Why not just use Playwright's built-in video recording?
Playwright's recordings are raw debug captures. ScreenCI turns the same script into a finished product video: AI narration synced to each step, an auto-zoom camera, animated cursor and typing, overlays from your own React/Vue/Svelte components, subtitles, translations, and a permanent CDN URL. See what a script looks like.
Does my app or source code leave my machine?
Recording runs locally against your own app. The only things uploaded are the raw screen recording and its timing data, never your source code. The CLI is open source, so you can verify that yourself.
What happens when my UI changes?
Rerun the script (locally or in CI) and the video re-renders against the new UI. If the flow itself broke, the run fails like an E2E test, so you find out before your users do.
What does it cost?
Trying it is free and needs no account: three trial recordings with live previews, straight from the CLI. See pricing for paid plans.
Make your first video. No signup.
Describe the video, copy the prompt, and paste it into your coding agent. It installs ScreenCI, records your app, and hands back a narrated walkthrough you can edit in the browser. Free to try: record up to three videos with no account and no payment.
Let your coding agent make it
Paste this into Claude Code, Codex, Cursor, or another coding agent. It adds ScreenCI and records your first video for you.
Free to try, no account needed. See pricing.