Capturing
What to capture, what to turn it into, and what to leave out.
Choosing what to capture
Pass target as a CSS selector, an element, or a function returning one. Leave it out and you get the whole page.
await s2a.capture() // the page
await s2a.capture({ target: '#invoice' }) // one element
await s2a.capture({ target: '.receipt' }) // first match
await s2a.capture({ target: () => ref.current })area decides how much of it you get:
element— the target’s whole box, even the part scrolled past the bottom of the window. This is the default when you name a target.viewport— only what’s on screen right now, stopping at the fold. Ask for this by name when you want a bug report to show exactly what the user was looking at.document— the full scrollable page, top to bottom. The default when you don’t name a target.
Scroll positions inside the target are kept. If a panel is scrolled halfway, the capture shows it halfway.
Formats
Ask for several at once. The expensive work — resolving styles, inlining fonts and images, rasterizing — happens once, then the result is encoded N times.
await s2a.capture({
target: '#invoice',
formats: ['png', 'pdf', 'svg'],
scale: 2, // defaults to the device's own pixel ratio, capped at 3
quality: 0.92, // jpeg and webp only
})svg and html keep the text selectable rather than flattening it to pixels, which is handy for archiving anything someone might need to copy a number out of later. pdf writes a single page sized to the capture by default.
Watermarking
Stamps text across the capture, in every format it gets exported as — PNG, JPEG, WebP and PDF from the bitmap, SVG and HTML as real selectable <text>. It is applied to the rendered scene rather than at the end of one encoder, because the reason to watermark a screenshot doesn’t depend on which file type it happens to be saved as.
const s2a = createClient({
publishableKey: 'pk_live_…',
watermark: { text: 'CONFIDENTIAL', repeat: true, size: 22, opacity: 0.12 },
})
// or per capture, which overrides the client setting
await s2a.capture({ target: '#account-summary', watermark: 'ACME INTERNAL' })- A string is shorthand for
{ text }. - repeat: true tiles it across the whole image. Worth turning on for anything genuinely sensitive — a single diagonal mark is one crop away from being gone, while a tiled one survives any crop that leaves enough of the picture to be worth having.
- size is the font size in CSS pixels — the same unit as the page you are capturing, so
size: 32is 32px of the screenshot whatever it was rendered at. Left out, a single mark spans most of the width and a tiled one takes a readable tile size. opacity(default 0.14),angle(45) andcolordo what you would expect.- It is drawn after rasterizing, so it can’t be hidden by the page’s own stacking contexts the way an overlay element could — and the end user sees it in the editor, which is where they confirm what gets sent.
This replaces pdf.watermark, which still works but only marks the PDF. If both are set the general one wins, so nothing gets stamped twice.
PDF options
Set pdf on the client for defaults, or per capture to override them. Everything below is optional; the defaults produce what this has always produced.
const s2a = createClient({
publishableKey: 'pk_live_…',
pdf: { pageSize: 'a4', orientation: 'landscape', header: 'Northwind Trading Co.' },
})The client-level pdf also applies to the editor’s own Save a copy → PDF, so what your user downloads matches what your webhook receives. The full set:
await s2a.capture({
target: '#invoice',
formats: ['pdf'],
pdf: {
pageSize: 'a4', // auto (default) | a3 | a4 | a5 | letter | legal | tabloid
orientation: 'landscape', // portrait (default) | landscape | auto
fit: 'width', // width (default, runs onto more pages) | page (shrink to one)
margin: 36, // points, every side
margins: { top: 60, left: 40, right: 40, bottom: 50 }, // per-side overrides
background: '#f8fafc',
header: 'Northwind Trading Co.',
logo: 'data:image/png;base64,…', // sits in the header
logoHeight: 18,
footer: 'Confidential — do not distribute',
},
})- pageSize: ‘auto’ makes one page exactly the size of the capture — no margins, no scaling, nothing cut off. Everything else here except
watermarkandbackgroundonly applies to a fixed size, becauseautohas no margin to put a header in and no orientation to choose. - orientation: ‘auto’ follows the capture — wider than tall goes landscape. Worth choosing for screenshots, where portrait A4 leaves half the page empty and shrinks the content to fit the remaining width.
- fit: ‘width’ fills the column and continues onto as many pages as it takes, which keeps text readable on a long page. ‘page’ shrinks the whole thing onto one — right for a chart or a receipt, wrong for anything with small text.
- logo must be a data URL. The PDF has to stand alone, and a remote image would need fetching at export time, would fail behind a login, and would leave the file depending on a URL that outlives nothing.
- header and footer take plain text or HTML. Plain text stays real, selectable PDF text. Markup is laid out by the browser and embedded as a strip, so bold, colour, size and an inline logo all work:
<strong style="color:#4f46e5">Acme</strong> · Q3 report. Images inside it must be data URLs, and scripts are stripped.
When somebody saves a PDF from the editor they get a small panel first — page size, orientation and fit, prefilled from your settings, so pressing Save gives them exactly your defaults. Header, footer, logo and watermark are deliberately not on it: those are your document’s furniture, and a watermark exists precisely so the file stays traceable, which is not a thing to hand the reader a switch for.
One PDF from several sections
A report is usually a page of sections, and the artefact people want is the document — not a folder of images they have to assemble.
import { canvasesToPdf } from '@screen2api/sdk'
const sections = document.querySelectorAll('.report-section')
const canvases = await Promise.all(
[...sections].map(async (el) => ({
canvas: await s2a.toCanvas({ target: el }),
title: el.dataset.title, // becomes that section's running header
})),
)
const pdf = await canvasesToPdf(canvases, {
pageSize: 'a4',
orientation: 'auto',
footer: 'Q3 report — confidential',
})Every option behaves exactly as it does for one canvas, because it is the same layout code. Each section starts a new page, a long one runs onto as many as it needs, and orientation: 'auto' is decided per section — so a wide chart lands landscape while the text around it stays portrait.
The editor’s Save a copy → PDF takes the same options from editor.pdf. They’re separate because they answer different questions — one governs the file delivered to your webhook, the other the file your user saves to their desktop. Pass the same object to both if you want them to match.
Text is written in WinAnsi, which covers Latin-1 plus the typographic punctuation you actually use — em dashes, curly quotes, accents, the euro sign. Characters outside it (CJK, emoji) need an embedded font and come out as ?.
CSV export
csv is the odd one out: it reads the DOM instead of the bitmap. Point it at a table and you get the data, not a picture of the data.
await s2a.capture({
target: '#line-items',
formats: ['csv'], // no rasterizing happens at all
})
// Or both, from one call
await s2a.capture({
target: '#line-items',
formats: ['png', 'csv'],
})Anything with a tabular shape works:
<table>— includingcolspanandrowspan, which are expanded so every row lines up- ARIA tables —
role="table","grid"or"treegrid", which is what most virtualized data grids render <dl>becomes two columns;<ul>and<ol>become one- Anything else, if you mark it up with
data-csv-rowanddata-csv-cell
Point it at something that isn’t any of those and the call rejects with a Screen2ApiError naming the element, rather than handing you an empty file to debug later.
What lands in the cells
The same rule as the rest of the engine: whatever the user can see. Rows hidden with display:none are skipped, the value someone typed into an input beats the value in the HTML, and rendered text beats source text. When the display text isn’t what you want to export, override it per cell:
<td data-csv-value="2026-07-28T09:12:00Z">3 days ago</td>
<td data-csv-redact>4111 1111 1111 1111</td>redact and exclude apply here too — redacted cells come through as [redacted], excluded ones are dropped entirely.
Options
await s2a.capture({
target: '#line-items',
formats: ['csv'],
csv: {
delimiter: ';', // for locales where Excel expects it
omitHeader: true,
preserveLineBreaks: false,
bom: true, // Excel needs this to read accents correctly
sanitize: true, // neutralise spreadsheet formulas — see below
},
})Leave sanitize on. A cell beginning =, +, - or @ is executed as a formula by Excel, Sheets and LibreOffice. Since these values come off a live page — often typed by one of your users — an export is a direct route to =HYPERLINK(...) credential phishing. We prefix a single quote so the spreadsheet displays the text and runs nothing.
One consequence worth knowing: annotations never appear in a CSV, because there is nowhere in a spreadsheet to put an arrow. If a capture asks for png and csv together, the PNG carries the markup and the CSV carries the data.
Redaction
Anything matching redact is blurred before the image is encoded, so the original pixels never leave the browser. exclude removes the element from the capture entirely.
await s2a.capture({
target: '#account',
redact: ['[data-pii]', '.card-number'],
exclude: ['.support-widget', 'nav'],
})Set the same selectors project-wide in Settings if you’d rather not rely on every call site remembering. Elements carrying data-s2a-ignore are always skipped.