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.
One prompt to your coding agent writes the script. ScreenCI records your real app locally with Playwright, adds AI narration and an auto-zoom camera, and renders a share-ready video in the cloud. When your UI changes, rerun the script and the video updates itself.
Free to try, no account needed. See paid plans.
This video is a .screenci.ts file, rendered by ScreenCI. View the source of every clip
Everything here was rendered from a script
Every tile below is a real ScreenCI render, each from its own Playwright-driven .screenci.ts file, exactly how you would ship yours. Auto-zoom, synced narration, redacted secrets, programmatic overlays, branded screenshots, and more, all written in code.
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.
Every video is a .screenci.ts file driven by Playwright, recorded locally or in CI. Video as code: your videos live in the repo and never go stale.
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 embedsStale videos fail the build
Every video runs as a Playwright E2E test. When your product changes and the run stops passing, that failed test is your signal to re-render, so your walkthroughs ship with the release instead of trailing weeks behind. No silent drift.
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 Playwright or E2E testing?
No. Your coding agent can write the whole script for you, and the API itself stays at the page.goto and page.click level. If you have ever written or read an E2E test, you already know enough. See the script basics.
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.
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.
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 watermarked renders straight from the CLI. See pricing for paid plans.
Your first video in two commands. No signup.
Pick the path that fits how you work: let your coding agent do it in one paste, or run two commands by hand. The anonymous trial includes three watermarked renders, straight from your terminal. Free to try: record up to three videos with no account and no payment.
Let an AI agent do it
Paste this into Claude Code, Codex, Cursor, or other coding agent. It adds ScreenCI and creates your first video for you.
You can also add @files or a URL in the prompt so the agent has more context to work from.
Set it up yourself
Run the command in your terminal to scaffold ScreenCI into the current directory with a starter video and an optional GitHub Actions workflow.
No account or setup token needed: the scaffolded project records immediately. Check the link below for more.
Read the docs to record your first videoFree to try, no account needed. See pricing.