Automated screenshots and screencasts (GIF/video) with Playwright or Puppeteer — wait for selectors, configurable viewports, scroll positioning, action timelines, and downloads.
Also available on GitLab. Reusable CI integrations: GitHub Action (v1 branch), GitLab CI component.
pnpm add scrns
# or
npm install scrnsPlaywright is included by default; install its Chromium binary once per machine:
pnpm exec playwright install chromiumTo use Puppeteer instead (or alongside; it downloads Chrome automatically on install):
pnpm add puppeteerscrns [options]| Flag | Description |
|---|---|
-b, --browser-arg <arg> |
Additional browser launch arg (repeatable) |
-c, --config <path> |
Config file path (default: scrns.config.{ts,js,json}) |
-C, --color-scheme <scheme> |
Emulated prefers-color-scheme: light or dark (overrides top-level colorScheme; per-shot values still win) |
-d, --download-sleep <ms> |
Sleep while waiting for downloads (default: 1000) |
-D, --docker |
Run in Docker for reproducible output (see Docker mode) |
--docker-image <image> |
Docker image (default: auto-detect from Playwright version) |
--docker-platform <platform> |
Docker platform (default: linux/amd64) |
--docker-scrns <ver> |
scrns version for Docker (npm version, or source SHA / gh:<sha> / gl:<sha>, resolved to its dist build) |
-E, --engine <name> |
Browser engine: playwright or puppeteer (default: auto-detect, prefers Playwright) |
-h, --host <host> |
Hostname or port (numeric port maps to 127.0.0.1:port) |
-H, --headful |
Run browser in headful mode (override config headless) |
-i, --include <regex> |
Only generate screenshots matching this regex |
-l, --load-timeout <ms> |
Timeout waiting for selector (default: 30000) |
-o, --output <dir> |
Output directory (default: ./screenshots) |
-s, --selector <css> |
Default CSS selector to wait for |
-T, --screenshot-timeout <ms> |
Timeout per screenshot capture (default: 30000) |
--https |
Use HTTPS instead of HTTP |
# Auto-detect config, use localhost:3000
scrns
# Specify port and config
scrns -h 8080 -c my-config.ts
# Filter to specific screenshots
scrns -i "home|about"
# Use Puppeteer instead of Playwright
scrns -E puppeteer
# Same config, dark theme, separate output dir
scrns -C dark -o screenshots/darkscrns preview (aliased record) opens a headful browser for interactively composing screenshots. Adjust the view and resize the window, then capture with Enter (terminal) or Ctrl+Shift+S (browser):
# Preview a specific config entry
scrns preview <name>
# Preview an arbitrary URL (no config needed)
scrns preview --url 'http://localhost:3000/?view=map'On capture, prints the resulting state as a config snippet:
Captured "og":
query: '?view=map&zoom=12'
width: 1200
height: 800
Screenshots rendered on macOS differ from Linux CI output (fonts, antialiasing). scrns -D/--docker runs the capture inside a mcr.microsoft.com/playwright container matching the GHA runner environment, so locally-generated screenshots are byte-identical to CI:
scrns -D- The image defaults to
mcr.microsoft.com/playwright:v<version>-noble, matching the installed Playwright version; override with--docker-image. - When run from a scrns checkout (local dev), the local build is packed and mounted; otherwise the installed version is reused. Pin explicitly with
--docker-scrns <ver>: an npm version, or a source SHA (bare = GitHub, orgh:<sha>/gl:<sha>), which is resolved to thedist-branch commit CI built from it (source commits aren't installable:dist/is untracked). docker,dockerImage, anddockerPlatformcan also be set in the config file.- Env vars referenced by
headers($NAME) are passed into the container by name (docker run -e NAME), so their values stay off the command line.storageStatefiles aren't mounted.
Create scrns.config.ts (or .js/.json). The config can be a flat Screens map:
export default {
'home': {
query: '',
width: 1200,
height: 800,
},
'about': {
query: 'about',
selector: '.main-content',
},
'footer': {
query: '',
scrollTo: 'footer',
scrollOffset: 20,
},
}Or a Config with top-level options and a screenshots key, so that host, output, etc. live in the config file instead of CLI flags:
import { Config } from 'scrns'
const config: Config = {
engine: 'playwright', // or 'puppeteer' (default: auto-detect, prefers Playwright)
host: 3456,
output: 'public/img/screenshots',
selector: '.app',
screenshots: {
'home': { query: '' },
'about': { query: 'about' },
},
}
export default configTop-level config options (engine, host, https, output, selector, loadTimeout, downloadSleep, browserArgs, headless, screenshotTimeout, colorScheme, docker, dockerImage, dockerPlatform) are overridden by their corresponding CLI flags when both are specified. headers, scale, and storageState can also be set top-level (config-only; no CLI flags), as defaults for every screenshot.
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
{name}.png |
Output path (relative to outputDir, or absolute) |
query |
string |
'' |
URL path/query (appended to baseUrl) |
width |
number |
800 |
Viewport width |
height |
number |
560 |
Viewport height |
selector |
string |
- | CSS selector to wait for before capturing |
loadTimeout |
number |
30000 |
Timeout in ms for selector wait |
preScreenshotSleep |
number |
0 |
Sleep in ms before taking screenshot |
scrollY |
number |
0 |
Scroll Y pixels before screenshot |
scrollTo |
string |
- | CSS selector to scroll into view |
scrollOffset |
number |
0 |
Offset pixels above scrollTo element |
download |
boolean |
false |
Set download behavior instead of screenshot |
downloadSleep |
number |
1000 |
Sleep in ms while waiting for download |
browserArgs |
string[] |
- | Additional browser launch args for this screenshot |
headless |
boolean |
true |
Override headless mode for this screenshot |
screenshotTimeout |
number |
30000 |
Timeout in ms per screenshot capture |
headers |
Record<string, string> |
- | Extra HTTP headers for every request (merged over top-level headers); $NAME / ${NAME} expand from env |
scale |
number |
1 |
Device scale factor (e.g. 2 for retina captures) |
storageState |
string |
- | Playwright storage-state JSON (cookies + localStorage); Playwright only |
colorScheme |
'light' | 'dark' |
'light' |
Emulated prefers-color-scheme (overrides top-level colorScheme / -C) |
crop |
string | { selector, padding? } |
- | Capture only the first element matching selector (plus padding CSS px, clamped to the page) |
const config: Config = {
host: 'app.example.com',
https: true,
// Sent with every request from the page (navigations and in-app `fetch`es)
headers: { Authorization: 'Bearer $SITE_TOKEN' },
scale: 2,
screenshots: {
'health-panel': { query: 'health', crop: { selector: 'section.store', padding: 16 } },
'treemap': { query: '', crop: '.treemap' },
},
}headers:$NAME/${NAME}refs are expanded from the environment at run time; an unset var is an error (raised before any browser launches). TS/JS configs can also just readprocess.env. Header values are never logged (only names). Headers go to every request the page makes, including third-party origins (CDNs, fonts), so only use them on pages you trust with the token.crop: the element box is measured once, after load / scroll /preScreenshotSleep, in document coordinates (so off-screen elements work); for screencasts the same region is used for every frame. A selector that matches nothing is an error.scale: output dimensions are multiplied by it (acropor viewport ofW×Hatscale: 2yields2W×2Hpixels).
colorScheme: 'dark' (top-level, per-shot, or -C dark) emulates prefers-color-scheme: dark, so @media (prefers-color-scheme: dark) rules, matchMedia and <picture><source media> all see dark mode from the first paint. The default is light on both engines, regardless of the host OS theme. Apps whose dark mode is a URL param or localStorage key don't need this; set that via query or a js action instead.
Adding an actions array to a config entry turns it into a screencast. The output defaults to .gif.
| Option | Type | Default | Description |
|---|---|---|---|
actions |
ScreencastAction[] |
- | Action timeline (presence triggers screencast mode) |
fps |
number |
15 |
Frames per second for GIF capture |
gifQuality |
number |
10 |
GIF quality: 1-30, lower = better |
loop |
boolean |
true |
Whether the GIF should loop |
videoCrf |
number |
23 |
CRF quality for video output (lower = better, requires ffmpeg) |
| Action | Fields | Description |
|---|---|---|
wait |
duration |
Pause for duration ms |
key |
key, duration |
Hold key(s) for duration ms (e.g. 'Shift+ArrowLeft') |
keydown |
key |
Press and hold key(s) |
keyup |
key |
Release key(s) |
type |
text |
Type text |
click |
x, y, button? |
Click at coordinates |
hover |
x, y or selector, index? |
Move mouse to coordinates, or to the center of the indexth element matching selector |
drag |
from, to, duration, button? |
Drag between coordinates over duration ms |
animate |
frames, eval, frameDelay? |
Deterministic frame-by-frame capture (see below) |
The animate action calls eval as (frameIndex, totalFrames) => ... for each frame, capturing a screenshot after each call. This produces deterministic, idempotent GIF output.
export default {
// Screenshot (unchanged)
'og': {
query: '?view=default',
width: 1200,
height: 800,
selector: '#root',
path: 'og.png',
},
// Screencast: hold a key to rotate a 3D view
'hero': {
query: '?view=3d',
width: 800,
height: 500,
selector: '#root',
preScreenshotSleep: 3000,
path: 'hero.gif',
fps: 15,
actions: [
{ type: 'wait', duration: 1000 },
{ type: 'key', key: 'Shift+ArrowLeft', duration: 4000 },
{ type: 'wait', duration: 1000 },
],
},
// Screencast: deterministic frame-by-frame animation
'rotate': {
query: '?view=3d',
width: 800,
height: 500,
selector: '#root',
preScreenshotSleep: 3000,
path: 'rotate.gif',
fps: 15,
actions: [
{ type: 'wait', duration: 500 },
{ type: 'animate', frames: 60, eval: '(i, n) => { window.setBearing(i * 360 / n) }' },
{ type: 'wait', duration: 500 },
],
},
}Set the output path to .mp4, .mkv, .mov, or .webm to produce video output via ffmpeg instead of GIF. This gives frame-accurate recordings with proper codec compression (H.264 or VP9).
Requires ffmpeg on $PATH.
export default {
'demo': {
query: '?view=demo',
width: 800,
height: 600,
selector: '#root',
path: 'demo.mp4',
videoCrf: 18, // lower = better quality
actions: [
{ type: 'wait', duration: 1000 },
{ type: 'key', key: 'ArrowRight', duration: 3000 },
{ type: 'wait', duration: 1000 },
],
},
}Codec selection is based on extension: .webm → VP9 (libvpx-vp9), all others → H.264 (libx264).
To derive a GIF from video output:
ffmpeg -i demo.mp4 -vf "fps=15,scale=400:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" demo.gifimport { takeScreenshots, resolveEngine, ScreencastConfig } from 'scrns'
// Optional: explicitly choose an engine (auto-detects if omitted)
const engine = await resolveEngine('playwright')
await takeScreenshots({
'home': { query: '' },
'about': { query: 'about', selector: '.content' },
'demo': {
query: 'demo',
actions: [
{ type: 'wait', duration: 1000 },
{ type: 'key', key: 'Enter', duration: 100 },
{ type: 'wait', duration: 2000 },
],
fps: 10,
} satisfies ScreencastConfig,
}, {
baseUrl: 'http://localhost:3000',
outputDir: './screenshots',
defaultSelector: '#app',
include: /home|demo/,
log: console.log,
engine, // omit to auto-detect
})