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()
})Product videos that never go stale
Prompt your AI agent to write a product video, grounded in a real E2E run of your app. Because every video is code, you can re-render automatically when your product changes, so what customers watch always matches what you ship. No manual re-recording or editing.
Created with ScreenCI
What you can build in code
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.
video
.renderOptions({ recording: { size: 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.
video('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')
await page.getByLabel('Tax ID').fill('47-2819001')
await page.getByLabel('Phone').fill('(512) 555-0142')
await page.getByLabel('Address').fill('2200 Congress Ave')
await page.getByLabel('City').fill('Austin')
await page.getByLabel('Postal code').fill('78701')
await page.getByLabel('Country').fill('United States')
})
// 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 })
})
await page.waitForTimeout(500)
await resetZoom()
})Cinematic motion blur
Natural motion blur on render.
import { autoZoom, hide, resetZoom, video } from 'screenci'
video.use({
renderOptions: {
zoom: { motionBlur: 1 },
mouse: { motionBlur: 1 },
},
})
video('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
Logos, badges, and captions from code.
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()
})Everything you need to ship and maintain product videos
Every video is a .screenci.ts file driven by Playwright, recorded locally or in CI. ScreenCI renders
a polished walkthrough, publishes it to a permanent link, and re-renders
it whenever your product changes.
Stale videos fail the build
Video as code: every video is a .screenci.ts file driven by Playwright. When your product changes and the E2E run stops passing, that failed test is your signal to update the video, so you catch an out-of-date walkthrough before your users ever see it. No silent drift.
Docs that match every release
Your walkthroughs ship with the product instead of trailing weeks behind. Customers always see the current UI, so they trust what they watch and open fewer support tickets.
Localize into 79 languages
Turn one source video into 79 languages with natural AI narration. ScreenCI can record each language on the localized page, so the narration matches the content your users actually see. Each version includes subtitles for accessibility. Update the source once and every version re-renders in sync.
Let your agent write the first draft
Point your own coding agent at your codebase or a crawl of your product. The ScreenCI skill drafts the walkthrough script, so you start from a working file instead of a blank one.
Edit 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.
Publish 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.
Set up your first video
Pick the path that fits how you work. Let your coding agent do it in one paste, or wire it up by hand. 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 video