Overlays
Overlays let you place additional media on top of the recording timeline. Use them for intros, transitions, corner branding, callouts, or short contextual clips that would be awkward to build inside the browser automation itself.
An overlay’s content comes from exactly one source: a file path, an inline React element, inline jsx/solidJsx component source, or an inline html fragment.
For a file path, the extension selects what it is:
.tsx: a React component module rendered client-side in the browser (hooks, effects, class lifecycle all run). See Component page overlays..solid.tsx: a Solid component module, same treatment with Solid’s compiler..vue: a Vue single-file component (its<style>block included)..svelte: a Svelte component (its<style>block included)..html: a full standalone HTML document, loaded as-is (its own<style>/<script>run)..svg/.png: an image..mp4: a video.
The inline variants keep small overlays in the recording file itself (see Inline overlays):
element: a React element (element: <Badge label="New" />), rendered in-process to static markup.jsx/solidJsx: the source of a component module as a string, bundled and mounted client-side like a.tsx/.solid.tsxfile.html: an HTML fragment placed in the overlay root.
Every rendered variant (everything except image/video files) is rendered to a transparent PNG at recording time and then behaves exactly like an image overlay, or, with animate: true, is captured as a transparent animated clip (see Animated overlays).
Overlays can be owned by code or handed to Editor (the web app where non-developers swap the assets); see the two ways to declare overlays below.
You will learn
- how to declare overlays (code values or Editor-owned)
- how to make overlays look like the product
- the built-in overlay kit (rings, callouts, steps, badges, title cards)
- how to define overlays
- how to position and size overlays
- how blocking and start/end timing work
- how to organize files for maintainable projects
Two ways to declare overlays
There are two ways to declare overlays, and both are editable in the web app. The same two forms apply to narration.
1. Code values. You point each overlay at a file, element, or config. The code values are used until the overlay is edited in Editor, and from then on the Editor value wins.
video.overlays({ logo: { path: 'assets/logo.png', x: 96, y: 96, width: 288 } })2. Editor-owned (blank). Pass a bare array of overlay names: the names exist in code (so the body can call overlays.logo), but Editor owns the files and display options.
import { video } from 'screenci'
video.overlays(['intro', 'logo'])You can also mark a single overlay as editor-owned inside a map with { editor: '<name>' }. The overlay is an explicit part of the video (unlike a whole-array declaration), but its uploaded file lives in the ScreenCI backend, not in a local file. The editor string names the backend asset (conventionally the same as the key). Upload the file in the editor, then reference it from the script with this form.
video.overlays({ logo: { editor: 'logo' }, // backend-hosted, uploaded in the editor hint: { path: 'assets/callout.html' }, // local file})3. A shared branding asset. Pass { branding: '<name>' } to use an image or
video from the Branding page. The name is a live reference: the
export resolves it to whatever file the Branding page holds then, so replacing
the logo there updates every video that uses it on its next export. Nothing is
uploaded from your machine.
video.overlays({ logo: { branding: 'logo', x: 1560, y: 960, width: 288 }, intro: { branding: 'intro-clip', fill: 'screen' },})A branding image needs a length (duration, .for(), or a live
start()/end() window); a branding video plays its own length and takes
the video options (volume, speed, time, start, end). over is not
available: a stored file has no live element to size against. A name that is not
on the Branding page fails the record before the upload, and an export whose
asset was deleted fails with the same message rather than quietly dropping the
overlay.
Designing overlays
An overlay is rendered by a real browser and burned in as pixels, so anything CSS can draw, an overlay can draw. The overlays that look professional follow a few rules, and coding agents that write videos for you follow the same ones (they are part of the ScreenCI skill).
Build shapes with HTML/CSS or React, not hand-drawn SVG. Rings, pills, pointers, and dimmed backdrops are border, border-radius, box-shadow, gradients, and a rotated square for an arrow head. Hand-written <svg> path data is hard to keep consistent and rarely matches the product. If the app ships an icon library the overlay can import, use it; otherwise use text.
Take colours, radius, and font from the recorded app. Read its CSS variables, Tailwind config, or theme file and put the values in one shared file, recordings/assets/theme.ts. Every overlay imports it, so a rebrand is one edit. (A .html page overlay is loaded as a standalone document with no base URL, so it cannot link a stylesheet: without React, keep the same values as a :root variables block pasted into each page.) Leave font-family as inherit unless the app’s font is installed on the recording machine: the overlay host page already carries a clean sans-serif stack.
// recordings/assets/theme.ts (values from the recorded app's own theme)export const theme = { accent: '#2563eb', accentSoft: 'rgba(37, 99, 235, 0.18)', surface: '#0f172a', text: '#f8fafc', radius: 12, ringWidth: 3, fontFamily: 'inherit',} as constOne shared set of overlay files per project. Keep components in recordings/assets/ and parameterise them with props (Callout.tsx with a text prop) instead of copying a file per video. Every video in the project then shares the same ring width, radius, margin, fade, and label size, and recurring elements sit in the same place.
Start with the kit. Rings, callouts, steps, spotlights, badges, shortcuts and title cards are built in (see Overlay kit), themed from the app and placed for you. Custom components are for everything else.
Respect what the renderer captures. With over, the page is sized to the element’s box and only that box is captured, so the content fills it (width: 100%; height: 100%) and a label cannot hang outside (add bleed for a glow). For a label beside an element, use anchor: the content keeps its natural size, is placed 1:1 (so text stays crisp), flips away from the viewport edge and stays in frame:
import { theme } from './theme'
export default function Callout({ text }: { text: string }) { return ( <div style={{ // Anchored placement captures the content at this natural size and // places it 1:1, so it is never rescaled. maxWidth: 320, boxSizing: 'border-box', padding: '10px 16px', borderRadius: theme.radius, background: theme.surface, color: theme.text, fontFamily: theme.fontFamily, fontSize: 20, fontWeight: 600, boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)', }} > {text} </div> )}import type { Locator } from '@playwright/test'import { video } from 'screenci'
video.overlays({ callout: (p: { target: Locator; text: string }) => ({ path: './assets/Callout.tsx', props: { text: p.text }, anchor: p.target, side: 'bottom', align: 'start', gap: 16, bleed: 24, // room for the shadow fadeIn: 200, fadeOut: 200, }),})('Invite a teammate', async ({ page, overlays }) => { const email = page.getByLabel('Email address') const hint = overlays.callout({ target: email, text: 'Work email only' }) await hint.start() await hint.end()})Use them sparingly. One overlay visible at a time, one per step, each fading in and out over 150 to 250 ms. The camera (zoomTo, autoZoom) directs attention first; an overlay is for the moment zoom is not enough.
Overlay kit
Most overlays are one of a few things: a ring around a control, a callout beside a field, a step number, a spotlight, a badge, a shortcut, a title card. The kit provides those as ready-made primitives that already look like your product and are placed for you. Declare one with { kit: '<name>', ... } instead of a file:
import type { Locator } from '@playwright/test'import { video } from 'screenci'
video.overlays({ ring: (t: Locator) => ({ kit: 'ring', anchor: t }), hint: (p: { target: Locator; text: string }) => ({ kit: 'callout', anchor: p.target, text: p.text, }), step: (p: { n: number; target: Locator }) => ({ kit: 'step', number: p.n, anchor: p.target, }), focus: (t: Locator) => ({ kit: 'spotlight', anchor: t }), beta: { kit: 'badge', text: 'Beta', x: 1560, y: 64, pinToScreen: true }, shortcut: { kit: 'keys', keys: ['Cmd', 'K'], x: 96, y: 96 }, intro: { kit: 'title', title: 'Invite your team', subtitle: 'Settings > Members', },})('Invite a teammate', async ({ page, overlays }) => { await overlays.intro.for(1800) const email = page.getByLabel('Email address') const hint = overlays.hint({ target: email, text: 'Work email only' }) await hint.start() await hint.end()})A primitive with an anchor needs a locator, which only exists while the test runs, so it is declared as a factory and called with the locator (see Programmatic overlays). Kit overlays are ordinary overlays afterwards: they show on the timeline under their name, can be dragged in the web app, zoom with the recording unless pinToScreen is set, and take the same duration, fadeIn / fadeOut (default 180 ms), pinToScreen and overMouse options.
Primitives
| Kit | Props | Placement |
|---|---|---|
ring |
margin (default 6) |
anchor: covers the element |
spotlight |
margin (default 8), dim (above 0 up to 1, default 0.55) |
anchor: dims the recording except a hole around the element |
callout |
text, maxWidth (default 360) |
anchor + side (default bottom), align, gap |
step |
number |
anchor + side (default left), align (default start), gap |
badge |
text, tone (accent or neutral) |
anchor + side/align/gap, or x/y (+ relativeTo) |
keys |
keys (['Cmd', 'K']) |
anchor + side/align/gap, or x/y (+ relativeTo) |
title |
title, subtitle, align (center or start) |
fills the recording |
Anchored primitives use anchored placement: the content is captured at its natural size and placed gap px from the element, flips to the opposite side when it would leave the viewport, slides along the edge to stay in frame, and a callout’s pointer always faces the element. A badge or shortcut at x/y is placed with its top-left at that point, content-sized.
Theme
The kit reads its theme from the recorded app the first time an overlay is prepared on a page: the primary button’s background, text colour and radius, the root CSS variables --primary, --primary-foreground, --accent, --ring, --radius, --background and --foreground (plain colours or shadcn-style HSL triplets), the body font and background, and color-scheme. From those it derives:
| Token | Used for | Derived from |
|---|---|---|
accent |
rings, step markers, accent badges | --primary, else the primary button, else --accent, --ring; must contrast with the page (3:1) |
accentForeground |
text on accent |
--primary-foreground or the button’s text colour when it reads on the accent, else white or near-black |
accentSoft |
glows | accent at 18% alpha |
surface, onSurface |
callouts, neutral badges, keycaps | the app’s foreground on its background (inverted) when they contrast 4.5:1, else a dark or light pair by scheme |
radius |
corners | the button’s radius or --radius, clamped to 4 to 16 px |
fontFamily |
text | the body font when its stack can fall through to a system family, else the overlay host’s stack |
scheme |
scrims | the page background’s luminance |
When nothing usable is found, a curated default (blue accent, dark surface) is used. Override any token for one overlay with theme, or for a whole project by spreading a shared object into every kit config:
export const kitTheme = { accent: '#7c3aed', radius: 8 } as const
video.overlays({ ring: (t: Locator) => ({ kit: 'ring', anchor: t, theme: kitTheme }),})An override that fails the contrast check is kept, with a warning in the log.
Define overlays
video.overlays(...) takes a map. Each value is one of:
- a file path string (
.tsx,.solid.tsx,.vue,.svelte,.html,.svg,.png,.mp4), - a React element (
badge: <Badge label="New" />, shorthand for{ element: ... }), - a config object (
{ path, ...placement }, or one of the inline sourceselement/jsx/solidJsx/html), { editor: '<name>' }for a backend-hosted (editor-uploaded) overlay,{ branding: '<name>' }for a shared branding asset (resolved at export), or- a factory
(props) => config(see Programmatic overlays), or
A config draws its content from exactly one source. Component overlays (.tsx/.solid.tsx/.vue/.svelte files, jsx/solidJsx source) accept serializable props; only .mp4/image files accept the video/crop fields.
Every config object also declares exactly one placement (see Positioning): fill: 'recording' | 'screen', an over locator, or an explicit box with exactly one of width/height (plus optional x/y/relativeTo/aspectRatio). The variants’ fields cannot be mixed, and a config with no placement is an error. Only the bare shorthands (a path string like hint: 'assets/callout.html' or a bare React element) carry an implicit fill: 'recording'.
import { video } from 'screenci'
video.overlays({ intro: { path: 'assets/intro.mp4', fill: 'screen' }, // full-frame video hint: 'assets/callout.html', // full HTML page (shorthand: fills the recording) badge: { path: 'overlays/Badge.tsx', props: { label: 'New' }, x: 1340, y: 110, width: 288, }, // React component page note: { html: '<div class="note">Tip</div>', x: 1340, y: 320, width: 380 }, // inline fragment stamp: <Stamp label="Beta" />, // inline React element logo: { path: 'assets/logo.png', x: 96, y: 96, width: 288 }, // image})('Overview', async ({ page, overlays }) => { await overlays.intro() await page.goto('/dashboard') await overlays.logo.for(1200)})video.overlays({...}) returns a builder you call with the usual
(title, body) arguments. Each key becomes a callable overlay controller,
delivered to the body through the injected overlays fixture. The same pattern
works for screenshots:
screenshot.overlays({...})('Title', async ({ page, crop, overlays }) => {...}).
You can also declare overlay names alone with a bare array and upload the files
plus display options on the Editor page instead of keeping them in the
repository: video.overlays(['intro', 'logo']). This form leaves the file and
placement for each name configured in the ScreenCI web app. Overlays declared
with code values stay editable too: the code values are used until the overlay
is edited in Editor, and from then on the Editor value wins.
Overlays are shared across every language (they do not take a language-major form). For a language-specific asset (e.g. a translated badge image), swap the overlay file per language in the web editor.
Overlay files (.png, .mp4, .svg) are uploaded the first time you record
with them present and reused on later runs, so you do not have to commit the
files. If a file is missing locally, ScreenCI reuses the version uploaded for
this video (matched by the overlay’s name). See
Asset files do not need to be committed.
Rules:
- every overlay except
.mp4needs a length: give it.for(1200)(milliseconds), adurationin the config (milliseconds), or drive it withstart()/end(). A bareoverlays.logo()is invalid for these (it only works for a video or render dependency, which holds for its natural length). - rendered page and image overlays do not support
volume. .mp4overlays may providevolume(a linear gain).1(the default) plays the source at its natural level,0mutes it, and values above1boost it (e.g.2is twice as loud, up to4)..mp4overlays use the file’s natural duration and must not provide aduration..mp4overlays may providespeedortimeto play the clip (and its audio) faster or slower.speedis a multiplier (2plays it twice as fast,0.5at half speed);timeis a target playback duration in ms (the clip is sped up or slowed down to play over exactly that long). Set at most one. This sets how long the (sped) clip plays for, since both a blocking call (await overlays.intro()) and a livestart()/end()window play the clip out to its end; later content shifts to make room. Use it (or trimming) to make a clip run shorter. Rendered page and image overlays do not supportspeed/time.
Cropping a file overlay
Image (.svg/.png) and video (.mp4) overlays accept a crop rectangle that selects a region of the source file, in the source’s own pixels (top-left origin), just like Playwright’s page.screenshot({ clip }). The cropped region is then placed and scaled like any other overlay. crop is not supported for rendered page overlays.
video.overlays({ // Show only the left panel of a wide screen recording. panel: { path: 'demo.mp4', fill: 'recording', crop: { x: 0, y: 0, width: 960, height: 1080 }, }, // Crop a badge out of a sprite sheet and place it top-right. badge: { path: 'sprites.png', crop: { x: 128, y: 0, width: 96, height: 96 }, x: 1740, y: 64, width: 96, },})x, y must be >= 0 and width, height > 0.
Trimming a video overlay (start / end)
.mp4 overlays accept start and end time strings to play only a slice of the source: a late start and/or an early end. Both are absolute positions in the source clip, expressed as a time string: a '0:02'/'0:02.5' timecode, or '50%' of the source duration. start must come before end. Trimming shortens how long the overlay occupies the timeline (before any speed/time).
video.overlays({ // Play seconds 2 through the halfway point of the source clip. clip: { path: 'demo.mp4', fill: 'recording', start: '0:02', end: '50%' },})start/end apply to .mp4 overlays only (images have no timeline).
Component and .html page overlays
A custom overlay is a full page you author, in one of two forms:
- A component page (
pathends in.tsx,.solid.tsx,.vue, or.svelte): a module that default-exports a component, rendered client-side in the browser during capture. The full framework runtime runs, so hooks/effects/signals, lifecycle and state, styles, and class bindings all work, and Vue/Svelte<style>blocks are carried into the overlay. Component overlays accept serializableprops(declare them withdefinePropsin Vue,$props()in Svelte). screenci bundles them withvite(an optional peer dependency, resolved from your project together with the framework itself:react/react-domfor.tsx,solid-js+vite-plugin-solidfor.solid.tsx,vue+@vitejs/plugin-vuefor.vue, andsvelte(v5+) +@sveltejs/vite-plugin-sveltefor.svelte). - A
.htmlpage (pathends in.html): a complete standalone HTML document, loaded as-is. Its own<style>and<script>run (the script is advanced by the virtual clock whenanimate: true).
Both render to a transparent PNG (or, with animate: true, an animated clip),
then place exactly like an image overlay.
video.overlays({ // A React page, parameterized by props. badge: { path: './overlays/Badge.tsx', props: { label: 'New' }, x: 1340, y: 96, width: 240, }, // A full HTML page. hint: { path: './overlays/callout.html', x: 768, y: 864, width: 384 },})('Overview', async ({ overlays }) => { await overlays.badge.for(1500) await overlays.hint.for(1200)})Transparent background. A page overlay owns its whole document, so make its background transparent (otherwise it paints an opaque rectangle over the recording):
<!doctype html><html> <head> <style> html, body { margin: 0; background: transparent; } .callout { /* ... */ } </style> </head> <body> <div class="callout">Saved</div> </body></html>A .tsx page’s generated host document is already transparent; just avoid an
opaque root element.
Sizing. The overlay is captured content-sized: screenci screenshots the
element with id="screenci-overlay-root" if the page has one, else the document
<body>. A .tsx page mounts into that root for you. For a .html page, wrap
your content in <div id="screenci-overlay-root">…</div> for tight sizing (or
let it fall back to the body box). The width/height placement then scales that
captured image onto the frame, so the output size never has to be known when you
author.
A .tsx page is a full React component. Author it exactly as you would an app
component: import React, hooks, helpers, child components. Only serializable
props cross into it (they are JSON-encoded), so pass plain data, not functions
or elements.
// overlays/Counter.tsx: hooks and effects run during captureimport { useEffect, useState } from 'react'
export default function Counter({ to }: { to: number }) { const [n, setN] = useState(0) useEffect(() => { const start = Date.now() let raf = 0 const tick = () => { const t = Math.min(1, (Date.now() - start) / 2000) setN(Math.round(t * to)) if (t < 1) raf = requestAnimationFrame(tick) } tick() return () => cancelAnimationFrame(raf) }, [to]) return <div className="counter">{n}</div>}// the recordingvideo.overlays({ counter: { path: './overlays/Counter.tsx', props: { to: 100 }, animate: true, duration: 2000, x: 1340, y: 110, width: 288, },})('Overview', async ({ overlays }) => { await overlays.counter() // counts 0 -> 100 over 2s})Class components work identically (this.props, this.state,
componentDidMount, etc.). A .tsx page needs react, react-dom, and
vite installed in your project.
The same pattern works in the other frameworks; only the module changes:
<script setup>defineProps({ label: { type: String, default: 'New' } })</script><template> <div class="badge">{{ label }}</div></template><style>.badge { /* carried into the overlay automatically */}</style>A .solid.tsx module default-exports a Solid component (the compound
extension keeps Solid’s JSX compiler away from React .tsx files), and a
.svelte module is a regular Svelte 5 component using $props().
Inline overlays
Small overlays do not need their own file. Three inline sources keep the content in the recording file itself:
element takes a React element and renders it in-process to static
markup. Because it renders in your recording process, props are baked into the
JSX and you can close over any test-scope value directly; there is no separate
props field. No client JS runs (hooks and effects do not fire), but CSS
animations still play under the virtual clock with animate: true. A bare
element is shorthand for the config form:
const label = await page.textContent('#plan') // close over runtime values
video.overlays({ badge: <Badge label="New" />, // shorthand plan: { element: <PlanCard name={label} />, x: 1340, y: 110, width: 288 },})jsx (React) and solidJsx (Solid) take the source of a component
module as a string, bundled and mounted client-side exactly like a
.tsx/.solid.tsx file, so the full runtime runs (hooks, effects,
requestAnimationFrame). The source is compiled in isolation for the browser:
it cannot close over test variables; pass data through the serializable
props instead. Imports inside the string resolve relative to the recording
file’s directory.
video.overlays({ counter: { jsx: ` import { useEffect, useState } from 'react' export default function Counter({ to }) { const [n, setN] = useState(0) useEffect(() => { /* animate n towards to */ }, [to]) return <div className="counter">{n}</div> } `, props: { to: 100 }, animate: true, duration: 2000, x: 1340, y: 110, width: 288, },})html takes an HTML fragment and places it in the overlay root. Include a
<style> tag in the fragment for styling; scripts are not executed (use a
.html file overlay for a page that owns its own scripts).
video.overlays({ note: { html: '<div class="note"><style>.note{padding:8px}</style>Tip</div>', x: 1340, y: 110, width: 380, },})Which variant runs client JS:
| Variant | Client JS (hooks/effects) | Closes over test scope | props |
|---|---|---|---|
.tsx / .solid.tsx / .vue / .svelte files |
yes | no | yes |
jsx / solidJsx source |
yes | no | yes |
element |
no (static render) | yes | no |
html fragment |
no (styles only) | yes (via template) | no |
.html file |
yes (its own scripts) | no | no |
Programmatic overlays (props)
Instead of a static value, an overlay can be a factory (props) => config.
The factory runs each time you call the overlay, so its path, props, and
placement can depend on values you only know at runtime. Call overlays.name(...)
to get a controller, then drive it the usual way (.for(...), start(),
end()):
video.overlays({ // Props select the .tsx page's content and its placement. badge: (p: { label: string; x: number }) => ({ path: './overlays/Badge.tsx', props: { label: p.label }, x: p.x, y: 110, width: 288, }),})('Overview', async ({ page, overlays }) => { await overlays.badge({ label: 'Saved', x: 1340 }).for(1200)
// For start()/end(), capture the controller so the props appear once. const badge = overlays.badge({ label: 'New', x: 1340 }) await badge.start() await page.click('#next') await badge.end()})To position an overlay over a live element, combine a factory with an over
locator (see Positioning over a live element):
screenci reads the element’s box at recording time and sizes the overlay to it.
Page overlays are rasterized after the test body finishes, not inline while it runs. The resolved page (and, for a component overlay, its bundle and props) is captured during the test; overlays with identical content are then rasterized just once (and unchanged overlays are served from a cross-run cache). You do not need to record the same overlay repeatedly.
Animated overlays
By default a page overlay is captured as a single still frame. Set animate: true
to play its animation back in the video, with the transparent background
preserved.
video.overlays({ // A .tsx page whose effect/CSS animates in. intro: { path: './overlays/Intro.tsx', animate: true, duration: 1500, fill: 'screen', }, // An animated .html page, captured at 60fps. hint: { path: './overlays/callout.html', animate: true, duration: 1200, fps: 60, x: 768, y: 864, width: 384, },})('Overview', async ({ overlays }) => { await overlays.intro() // plays the 1.5s animation over a frozen frame})How the animation is driven and how long it runs:
- Deterministic clock. Capture advances a virtual clock one frame at a time,
so everything time-based is reproducible: CSS animations/transitions, and a
page’s
setTimeout/setInterval,requestAnimationFrame,Date.now()/performance.now(), and the Web Animations API (including React effects in a.tsxpage). Schedule an edit attms and it lands on the matching frame, every run. - Length. You set the length explicitly: use
.for(2000)on the call or setdurationin the config. When you drive an animated overlay withstart()/end(),durationis required in the config (the capture length is otherwise unknown). If the live window outlasts the clip, its last frame is held; if the clip outlasts the window,end()plays the remaining animation out over a frozen frame before the timeline continues. fpssets the capture frame rate (defaults to30) and only applies withanimate: true.- Reserve room for growth. The overlay is captured at its initial layout box.
If the animation grows the content (adds lines, expands a panel) or moves beyond
its box (a
transformthat translates/scales out), size the page’s root to its final dimensions up front (or add your own padding), so nothing is clipped. Animatingtransform/opacitywithin the box is free.
Capturing many frames is heavier than a single screenshot, so prefer short durations for full-screen animations.
Each animated overlay is encoded twice at capture time: a render clip for the video pipeline, plus a browser-playable preview clip that keeps the transparent background, so the web editor’s live preview shows the animation over your app instead of a black card. Both are uploaded with the recording automatically.
Positioning
Placement is mandatory on every config object, and a config picks exactly one of three variants:
fill: 'recording' | 'screen'fills a frame:'recording'fills the recording area,'screen'fills the entire output frame, including any padding around the recording.over: <locator>(optionalmargin,bleed) sizes and positions the overlay over a live element; see Positioning over a live element.anchor: <locator>(optionalside,align,gap,bleed) places the overlay beside a live element at its natural size, flipping and sliding to stay in view; see Positioning beside a live element.- An explicit box with exactly one of
width/height, plus optionalx/y/relativeTo/aspectRatio.
The variants’ fields cannot be mixed: fill combined with x/y/width/
height is an error, as is a box with both width and height, or a config
with no placement at all. The only exception is the bare shorthands, a path
string (logo: './logo.png') or a bare React element, which mean
fill: 'recording'.
Box placement fields are flat on the config and each defaults independently.
Coordinates are CSS pixels of the recording viewport, the same space
Playwright’s boundingBox(), page.mouse, and viewportSize() use, with the
overlay anchored at its top-left corner. The renderer maps these recording-viewport
pixels into the final output frame, so the output size never has to be known when
you author: the same placement renders correctly at 720p, 1080p, 4K, or vertical.
video.overlays({ // Top-left badge, 288 px wide (on a 1920x1080 recording). badge: { path: 'assets/badge.png', duration: 1500, x: 96, y: 96, width: 288, }, // A banner across the full output frame, sized by height. label: { path: 'assets/label.svg', duration: 1500, relativeTo: 'screen', x: 192, y: 864, height: 108, },})('Overview', async ({ page, overlays }) => { // ...})relativeTo: 'recording'(the default) positions against the composited recording area (which may be inset whenrenderOptions.recording.size < 1). Pixels are measured in the recording viewport, so the box stays correct whatever output size you settle on later in the studio.relativeTo: 'screen'positions against the full output frame, using the same recording-pixel scale measured from the output’s top-left.xandyare the top-left corner in CSS px. Both default to0.- Provide exactly one of
widthorheight(in CSS px). The other is derived from the overlay’s intrinsic aspect ratio (or fromaspectRatio, given aswidth / height) so it is never distorted.
To fill a frame instead of placing a box, set fill: 'recording' fills the recording area and 'screen' fills the entire output frame. fill cannot be combined with the box fields, and a config object with no placement at all is rejected. Only the bare shorthands (a path string or a bare React element) fill the recording implicitly.
Overlays and zoom (pinToScreen)
By default an overlay is “burned” into the scene: when the camera zooms or pans (a zoomTo or an auto-zoom), the overlay moves and scales with the recording underneath it. So a ring placed over an element stays glued to that element as you zoom into it, and a badge anchored to part of the recording tracks that part.
Set pinToScreen: true to keep an overlay stuck to the screen instead: it holds a fixed position and size in the output frame, unaffected by zoom. Use it for HUD-style elements that should stay put, such as a persistent corner logo or a watermark-like badge:
video.overlays({ // Stays in the corner at a fixed size, even while the recording zooms. cornerLogo: (p: { x: number; y: number }) => ({ path: 'assets/logo.png', relativeTo: 'screen', x: p.x, y: p.y, width: 120, pinToScreen: true, }),})('Overview', async ({ page, overlays }) => { // ...})pinToScreen is orthogonal to placement: it works with relativeTo: 'recording' / 'screen', a fill overlay, or an over element. It only affects how the overlay behaves under zoom (whether it is burned into the scene or fixed to the screen).
Drawing above the cursor (overMouse)
Set overMouse: true to draw an overlay above the mouse cursor, so the cursor passes underneath it instead of on top. The cursor stays visible everywhere else in the frame; only where the overlay sits does the overlay cover it. This is handy for full-screen intro or outro cards (the cursor disappears behind the card) and for HUD elements like a corner logo the cursor should slide under:
video.overlays({ logo: { path: 'assets/logo.png', fill: 'screen', duration: 2000, overMouse: true, },})('Product demo', async ({ page, overlays }) => { // The cursor slides under the 2s card, then sits on top again for the walkthrough. await overlays.logo.for(2000) // ...})overMouse works for both blocking overlays (.for()) and live overlays driven with start() / end(). It is placement-agnostic, so it applies to every overlay variant. A few details:
- The overlay keeps its placement: a
pinToScreenoverlay stays fixed in screen space, a burned overlay still moves and scales with the camera during zoom.overMouseonly changes its stacking order relative to the cursor. - Overlapping
overMouseoverlays each draw above the cursor. - It has no effect on screenshots, whose cursor is hidden by default (see
renderOptions.screenshot.mouse.show).
Positioning over a live element
To frame or circle a real element, give a programmatic overlay
an over locator (and an optional margin in CSS px). screenci reads the
element’s box at recording time, sizes the overlay to it (plus the margin), and
positions it over the element. Your overlay content fills that box, so a ring
lands exactly around the element:
import type { Locator } from '@playwright/test'import { video } from 'screenci'
video.overlays({ // A component whose content fills the element's box (width/height: 100%). ring: (target: Locator) => ({ path: './assets/Ring.tsx', over: target, margin: 8, // optional breathing room around the element fadeIn: 200, fadeOut: 200, }),})('Overview', async ({ page, overlays }) => { const save = page.getByRole('button', { name: 'Save' }) const ring = overlays.ring(save) // capture the controller, then drive it await ring.start() await save.click() await ring.end()})// recordings/assets/Ring.tsx: the app's accent colour from the shared themeimport { theme } from './theme'
export default function Ring() { return ( <div style={{ width: '100%', height: '100%', boxSizing: 'border-box', border: `${theme.ringWidth}px solid ${theme.accent}`, borderRadius: theme.radius, boxShadow: `inset 0 0 0 4px ${theme.accentSoft}`, }} /> )}The same ring as a plain .html page (no React) carries the theme values as
CSS variables. A page overlay owns its whole document, so its background must
be transparent:
<!doctype html><html> <head> <style> /* Theme values from the recorded app, identical in every page overlay. */ :root { --accent: #2563eb; --accent-soft: rgba(37, 99, 235, 0.18); --radius: 12px; --ring-width: 3px; } html, body { margin: 0; background: transparent; } .ring { width: 100%; height: 100%; box-sizing: border-box; border: var(--ring-width) solid var(--accent); border-radius: var(--radius); box-shadow: inset 0 0 0 4px var(--accent-soft); } </style> </head> <body> <div class="ring"></div> </body></html>over works with every rendered page overlay (component files, .html,
element, jsx/solidJsx, and inline html). It is always
recording-relative and is a placement of its own: combining over with
fill or the box fields (x/y/width/height/relativeTo) is an error.
Make the content fill its box (width:100%;height:100%). Repeated calls with the
same element box rasterize only once.
Add animate: true and the ring plays its CSS animation
back in the video while the page keeps being driven underneath. Here is that same
margin ring, pulsing around a live element (the
Screenshots guide shows the
still version):
Show source
import type { Locator } from '@playwright/test'import { video } from 'screenci'
// Animates the same ring as the Screenshots still: it pulses while the page is// driven underneath, then closes before the recording stops. An opacity-only// pulse keeps the ring landed exactly on the element (no scale, so no capture// padding is needed).video.overlays({ // A full .html page whose CSS pulses the ring; `over` sizes it to the element. ring: (target: Locator) => ({ path: './assets/ring-animated.html', over: target, margin: 6, animate: true, duration: 2400, }),})('Locator highlight (animated)', async ({ page, overlays }) => { await page.goto('https://screenci.com/')
const cta = page.getByRole('link', { name: 'Read the docs' }) await cta.scrollIntoViewIfNeeded()
// The ring pulses while the page stays live. Every start() must be ended // before the video function returns. const ring = overlays.ring(cta) await ring.start() await cta.hover() await ring.end()})overlayRect (lower-level)
If you need the element’s coordinates yourself (for example to pass into a
component that draws relative to the element), call overlayRect(locator, opts?).
It returns the box in CSS px of the recording viewport:
const rect = await overlayRect(page.getByRole('button', { name: 'Save' }), { margin: 8,})// rect.x / rect.y / rect.width are CSS px; rect.pixels -> the full CSS-px box.// Top-level relativeTo/x/y/width spread into a placement: { ...rect }Options: margin (px around the element), dimension ('width' by default, or
'height' to expose that axis at the top level), and relativeTo ('recording'
by default; 'screen' is only meaningful when the recording fills the output
frame). Make sure the element is visible first; it throws if the locator has no
box.
Keeping a glow or shadow (bleed). An over overlay is captured at
exactly the element’s box (plus margin), so anything drawn outside that box,
an outer glow, a drop shadow, a pointer, is cut off. Set bleed (CSS px) to
capture that much transparent padding around the box as well: the content still
fills the element box, and the recorded placement grows by the bleed on every
side so the overlay lands in the same place.
video.overlays({ ring: (target: Locator) => ({ path: './assets/Ring.tsx', over: target, margin: 6, bleed: 16, // room for an outer glow of up to 16px }),})Positioning beside a live element (anchor)
A label, callout, or step marker that sits next to an element is placed with
anchor. Unlike over, the content keeps its natural size (a callout is as
wide as its text), and screenci computes the box for you:
side:'top','bottom'(default),'left','right', or'over'(cover the element, likeover+margin).align: how the content lines up along the shared edge.'start'aligns the leading edges (left edges for top/bottom, top edges for left/right),'center'(default) centers on the element,'end'aligns the trailing edges.gap: distance (CSS px) between the element and the content. Default 12.flip: when the content would leave the viewport on its side (a callout below a button at the bottom of the page), it moves to the opposite side. Defaulttrue.keepInViewport: slides the content along the shared edge so it stays inside the viewport. It never moves on the side axis, so it never covers the element. Defaulttrue.bleed: transparent capture padding (CSS px) around the content for shadows and pointers, as forover. Default 0.margin: only withside: 'over', inflates the element box.
The overlay is captured content-sized and placed 1:1 (the recorded width is
the captured width), so text is never rescaled and stays crisp. As with
over, the locator is only known at recording time, so an anchored overlay is
always a factory:
import type { Locator } from '@playwright/test'import { video } from 'screenci'
video.overlays({ hint: (p: { target: Locator; text: string }) => ({ path: './assets/Callout.tsx', props: { text: p.text }, anchor: p.target, side: 'bottom', align: 'start', gap: 14, bleed: 24, fadeIn: 200, fadeOut: 200, }),})('Invite a teammate', async ({ page, overlays }) => { const email = page.getByLabel('Email address') const hint = overlays.hint({ target: email, text: 'Work email only' }) await hint.start() await hint.end()})The recorded placement is an ordinary box (relativeTo: 'recording', x,
y, width, aspectRatio) plus anchorSide, anchorAlign, anchorGapPx
and bleedPx, so the renderer, the live preview and the web app treat it
like any other overlay: it can be dragged in the app (which drops the anchor),
and it zooms with the recording unless pinToScreen is set.
A component that draws a pointer towards the element needs to know the side it
ended up on (after a flip). The overlay document carries it as
data-screenci-anchor-side="top|bottom|left|right" on <html>, so plain CSS
can place the pointer in any framework:
.pointer { position: absolute; left: 50%; bottom: -7px;}html[data-screenci-anchor-side='bottom'] .pointer { bottom: auto; top: -7px;}A flipped overlay is rasterized again with the side it landed on, so the pointer always faces the element. The built-in overlay kit handles this for its callouts and step markers.
Position overlays in the web app
Overlay placement is also editable visually in the editor. Placement edits are stored in the editor and applied when the video renders, so your script stays as you wrote it:
- Drag in the live preview. Drag an overlay on the video to move it, or drag a corner to resize. For an element-locked overlay, dragging adjusts its margin instead.
- Fill select. The overlay panel’s Fill control switches between Screen, Recording, and Custom (drag in preview) placement.
- Files, image duration, and video audio/speed/time controls are editable in the same panel.
Timing and control flow
Every controller supports two timing styles.
Blocking holds the overlay over a frozen frame for a fixed duration, then the script continues:
await overlays.badge() // uses the config durationawait overlays.badge.for(2000) // or override the durationstart() / end() keeps the overlay on screen while the page keeps being
driven underneath, so it stays live over your real interactions:
await overlays.badge.start()await page.click('#next')await overlays.badge.end()start() accepts a delay (milliseconds) that offsets the recorded start
into the future, so an overlay written before an interaction can appear during
it: await overlays.badge.start({ delay: 500 }). Delayed overlay starts must
stay in time order; see
Mid-Video Overlay Updates.
For an overlay with an intrinsic length (a .mp4 video, an embedded video
dependency, or an animated HTML/React clip), end() lets
the clip finish: if the media is longer than the live window, the remainder plays
out over a frozen frame before the timeline continues, so the clip is never cut
short by ending early. To show less of such a clip, trim it (start/end or
speed/time) instead of calling end() sooner.
Length-less overlays (images and non-animated rendered pages) end exactly at end().
Overlays can overlap. Several can be live at the same time, and a blocking overlay can run while others stay live, so you can layer them freely:
await overlays.badge.start()await overlays.logo.start() // both live nowawait page.click('#next')await overlays.tip.for(1500) // blocking overlay, badge and logo stay compositedawait overlays.badge.end() // end each one independently, in any orderawait overlays.logo.end()Every overlay you start() must be end()ed before the video function returns,
and the same overlay cannot be started twice without ending it in between.
Leaving an overlay open, or restarting one that is already live, raises an error.
Narration runs independently of overlays, so you can narrate while an overlay is on screen.
Other timing notes:
- video overlays (
.mp4) use the media file’s natural duration and play at their natural level (volumedefaults to1); setvolumeto mute (0) or boost (above1, up to4) - full-screen overlays take over the output frame
- overlays stay on top of the recording while the underlying screen continues
That means you do not need separate timing math just to line an intro clip up with the next step.
Fade in and out
Any overlay fades in or out with fadeIn / fadeOut (milliseconds) in its
config. Omitted fades keep the instant appearance.
const overlays = video.overlays({ logo: { path: './logo.png', width: 200, x: 24, y: 24, fadeIn: 250, fadeOut: 250, },})File organization
A simple structure is usually enough:
recordings/ assets/ theme.ts # colours, radius, font: the one place styling lives Ring.tsx # highlight around an element (over + margin) Callout.tsx # label beside an element (overlayRect), text via props intro.mp4 logo.png signup.screenci.ts billing.screenci.tsEvery video imports the same components, so the project’s overlays stay identical from video to video. Keep reusable brand assets separate from throwaway experiment files so the project stays readable.
Authoring advice
- Use overlays sparingly: one visible at a time, one per step, and let the camera do most of the pointing.
- Build them with HTML/CSS or React from the shared theme, never hand-drawn SVG or ad hoc colours (see Designing overlays).
- Reuse the project’s existing overlay components with different
propsbefore adding a new file, and never copy a component to change its colours. - Fade everything in and out (150 to 250 ms) and keep
.for(...)(orduration) values short so image overlays do not stall the timeline. - Mute overlays that should not compete with narration, and keep intros and transitions short.
- Keep placement and sizing identical across videos in the same project: same margins, same corner, same label size.