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.

ScreenCI product pitch

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.

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()
})
Auto-zoom that follows the action

The camera zooms in and pans across the screen to follow each interaction: typing a field, a segmented control, a slider drag, a dropdown pick. No need to set manual zoom points, unless you want to be explicit.

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()
})

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)
})
Embed images and video

Place PNG/SVG images and MP4 clips as timed file overlays. This is different from programmatic overlays: the media is a real asset file, not a React or HTML overlay.

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()
})
Speaks 79 languages

One recording, with each narration line spoken in a different language via a per-cue language override. AI voices and subtitles, all from the same script.

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)
})
Localize the videos

Record the page once and render it in every language you list, each with its own AI narration and subtitles. Update the source and all languages re-render in sync.

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()
})
Keep secrets out of the recording

Redact API keys, tokens, and PII with redact(). The pixels are masked in the page before the frame is captured, so the secret never enters the recording that gets uploaded. Not a cosmetic overlay: true redaction.

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()
  }
})
Styled backgrounds

Pick a render background in the web editor to frame every recording on a branded gradient with rounded corners and a soft shadow.

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()
})
Speed ramps

Fast-forward the repetitive steps with speed() so every walkthrough stays tight.

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 })
})
Cinematic motion blur

Zooms and cursor moves get 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()
})
Branded, up-to-date screenshots

Capture any page as a framed image with screenshot(), dressed with code overlays and auto-updating on every re-render.

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 &amp; 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()
})
Programmatic overlays

Place logos, badges, callouts, and captions, built with your own React, Vue, Svelte, or Solid components, or plain HTML and CSS. Positioned, sized, and timed in your .screenci.ts file.

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 &amp; 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)
  }
)
Vertical video, one config line

Switch aspectRatio to 9:16 and the recorder opens a portrait viewport automatically. No cropping, no reframing.

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()
})
Promptable speaking style

Direct tone and delivery per cue with a plain-English style prompt, from a calm guide to a full pirate. Expressive voice models unlock even more languages. Available on every plan.

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 guide

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. Edits sync back into the script, so code stays the source of truth.

The web editor

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.

Public URLs and embeds

Stale 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 setup

Overlay 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 guide

Localize 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 guide

Common 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.

Copy this prompt and paste it into your coding agent
Integrate ScreenCI by fetching https://screenci.com/integrate.md and following its steps, then add a screenci video about <feature>

You can also add @files or a URL in the prompt so the agent has more context to work from.

How the agent integration works

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.

Copy this command and paste it into your terminal
npm init screenci@latest

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

Free to try, no account needed. See pricing.