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.

ScreenCI - Product videos that never go stale | Product Hunt
ScreenCI product pitch

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.

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

Zooms and cursor moves get 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()
})
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

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 &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 in React 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. Requires the Business tier.

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.

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