@changeinteraction/screen-capture
v0.2.9
Published
Complete React workflow for browser screenshots, screen recordings, annotation, trimming, preview, and form uploads.
Maintainers
Readme
@changeinteraction/screen-capture
A typed React capture workflow that turns screenshots, screen recordings, and
non-destructive edits into a standard browser File ready for FormData or a
direct object-storage upload.
The library captures and edits locally. It does not upload media, create an account, add telemetry, or choose a backend for the host application.
Features
| Area | Included |
| --- | --- |
| Screenshot | Browser picker, frame capture, callout annotation, preview, PNG export |
| Recording | Staged start, timer, microphone/system audio, stop/cancel, size auto-stop |
| Video editor | Preview, split, trim, delete clips, timed callouts, mute, MP4/WebM export |
| Attachment field | Picker, paste, drag/drop, MIME/size validation, preview, edit, remove |
| Integration | High-level studio, headless hooks, native File, FormData, direct upload |
| Distribution | React 18/19 types, scoped compiled CSS, ESM, tests, CI, no runtime npm dependencies |
Requirements
- React 18.0 or newer.
- A browser with
navigator.mediaDevices.getDisplayMediafor capture. MediaRecorderfor recording and WebCodecs-capable media paths for edited video export.- HTTPS in production;
localhostis accepted for local development. - A trusted user action such as a click. Browsers do not allow silent capture.
See browser support and capability detection.
Install
npm install @changeinteraction/screen-captureWhy use 0.2.7 or newer?
Version 0.2.7 includes the responsive screenshot annotator layout introduced
in 0.2.6 and fixes a screenshot-preview blob URL race exposed by React Strict
Mode. Earlier versions can show an oversized black annotation stage or log
blob:... net::ERR_FILE_NOT_FOUND while the browser is still decoding a newly
captured image.
For production applications that require reproducible installs, pin the exact version and commit the lockfile:
npm install --save-exact @changeinteraction/[email protected]^0.2.7 accepts future 0.2.x releases. Those releases follow SemVer, but an
exact pin ensures that dependency behavior changes only after your team reviews,
tests, and deliberately upgrades it.
Import the stylesheet once near the application root:
import "@changeinteraction/screen-capture/styles.css";The generated utilities and theme variables are scoped to .asc-root, so the
package does not publish generic global .flex, .fixed, or color rules into
the host application. CaptureStudio returns a React fragment and does not add
that class for you. Put .asc-root on a real DOM ancestor that contains the
entire studio output:
<div className="asc-root">
<CaptureStudio onDone={setAttachment}>{/* product + field */}</CaptureStudio>
</div>Putting .asc-root only on the form styles the field, but built-in toolbars and
editor dialogs rendered as its siblings may remain unstyled. The common
ancestor is the safe default.
Complete workflow
CaptureStudio owns permission and editor state. CaptureAttachmentField owns
the controlled form value and attachment UI. The application still owns the
form, upload, error reporting, and persistence.
Place the studio at the nearest component that owns both the capture target and
the feedback panel. For a dashboard, that is usually Home or the dashboard
shell—not the panel itself. Hoist the attachment state to the same component,
then pass studio, attachment, and setAttachment into the panel. Otherwise
captureRootRef can only retain the panel subtree and Element Capture may omit
the dashboard the user intended to report.
"use client";
import { useState, type RefObject } from "react";
import {
CaptureAttachmentField,
CaptureExportError,
CaptureStudio,
finalizeCapturePayload,
type CapturePayload,
} from "@changeinteraction/screen-capture";
const ALLOWED_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"video/webm",
"video/mp4",
] as const;
export function DashboardWithFeedback() {
const [attachment, setAttachment] = useState<CapturePayload | null>(null);
const [progress, setProgress] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<div className="asc-root">
<CaptureStudio
onDone={setAttachment}
initialAudioMode="none"
maxRecordingBytes={7 * 1024 * 1024}
>
{(studio) => (
<>
<main
ref={studio.captureRootRef as RefObject<HTMLElement>}
data-screen-capture-root
>
Content retained during supported current-tab Element Capture
</main>
<form
onSubmit={async (event) => {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const body = new FormData(event.currentTarget);
if (attachment) {
const file = await finalizeCapturePayload(
attachment,
setProgress
);
body.set("attachment", file);
}
const response = await fetch("/api/feedback", {
method: "POST",
body,
});
if (!response.ok) throw new Error("Upload failed");
} catch (cause) {
setError(
cause instanceof CaptureExportError
? cause.message
: "Could not submit feedback."
);
} finally {
setSubmitting(false);
}
}}
>
<CaptureAttachmentField
value={attachment}
onChange={setAttachment}
onEdit={studio.edit}
onScreenshot={studio.startCapture}
onRecord={() => studio.toggleRecording("none")}
screenshotState={studio.captureState}
recordingState={studio.recordState}
captureError={studio.captureError}
recordingError={studio.recordError}
allowedTypes={ALLOWED_TYPES}
maxSizeBytes={8 * 1024 * 1024}
disabled={submitting}
onValidationError={setError}
/>
{progress > 0 && progress < 1 && (
<p>Exporting {Math.round(progress * 100)}%</p>
)}
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Sending..." : "Send"}
</button>
</form>
</>
)}
</CaptureStudio>
</div>
);
}The maintained copy of this example is in
examples/complete-workflow.tsx and is
compiled in CI. The dashboard-owner/feedback-panel split is implemented in
examples/feedback-form-study-case.tsx.
The 8 MB and 7 MB values above are an application policy chosen to match the UI copy. They are not additional package defaults. Keep the recording threshold below the attachment limit so the final encoded chunk has headroom.
Recording button behavior
Use studio.toggleRecording(mode) when one button should follow the full state
machine:
idle/error -> open picker -> ready -> begin recording -> recording -> stopUse startRecording, beginRecording, and stopRecording separately when the
application provides dedicated controls for each stage.
Payload and upload contract
The controlled value is deliberately not always a File:
type CapturePayload = File | ImageEditDraft | VideoEditDraft;An edit draft contains the original sourceFile plus live shapes, trim
segments, and mute state. Keeping the draft avoids repeated encoding and allows
the user to reopen the editor without losing information.
Only finalize at the upload boundary:
const file = await finalizeCapturePayload(payload, setProgress);
formData.set("attachment", file);The result is a normal browser File with binary data, a name, MIME type, and
size. Do not convert it to base64 for normal uploads. Use multipart for small
files or a presigned direct upload for larger recordings.
See the complete form and upload guide.
Safe export failures
Edited video is never silently replaced by the unedited source. If the browser
cannot decode or encode the draft, finalizeCapturePayload rejects:
try {
const file = await finalizeCapturePayload(payload);
await upload(file);
} catch (error) {
if (error instanceof CaptureExportError) {
// Keep `payload` in state. Its source and edits are still available.
showMessage(error.message);
}
}burnDraft is a lower-level API and returns { file, fellBack }; callers using
it directly must check fellBack themselves.
Audio
The public audio modes are:
type AudioMode = "none" | "mic" | "system" | "both";Microphone and system/tab audio are mixed into a stable recording track. System audio is only available when the browser and selected capture surface offer it and the user enables it in the picker. Adding system audio requires a new share; it is intentionally refused after recording has started.
Examples:
onRecord={() => studio.toggleRecording("mic")}
onRecord={() => studio.toggleRecording("system")}
onRecord={() => studio.toggleRecording("both")}Upload limits
The package defaults are 50 MiB for attachment validation and 48 MiB for recording auto-stop. They are product defaults, not a guarantee that a hosting platform accepts that request size.
For a serverless endpoint with a smaller body limit, configure both sides:
<CaptureStudio maxRecordingBytes={3.5 * 1024 * 1024} onDone={setAttachment}>
{(studio) => (
<CaptureAttachmentField
value={attachment}
onChange={setAttachment}
maxSizeBytes={4 * 1024 * 1024}
onRecord={() => studio.toggleRecording("none")}
/>
)}
</CaptureStudio>For production video, prefer direct object-storage upload so the media does not pass through the application function body.
Client limits are only UX. The maintained
examples/server-route.ts validates subject,
description, optional attachment shape, empty files, MIME allowlist, and the
same 8 MB byte limit again at the trusted boundary. Adapt its authentication,
signature inspection, storage, quota, and retention policy to your backend.
Public API
| Surface | Purpose |
| --- | --- |
| CaptureStudio | Complete screenshot/record/editor orchestration |
| CaptureAttachmentField | Controlled capture/upload/preview/edit field |
| CaptureActionButtons | Presentational capture buttons |
| useScreenCapture | Headless screenshot capture |
| useScreenRecorder | Headless staged recording and audio control |
| finalizeCapturePayload | Convert a raw file or draft into an upload-ready File |
| CaptureExportError | Safe edited-video export failure |
| ScreenshotAnnotator, RecordingPreview | Standalone editors |
| AnnotatedImagePreview, AnnotatedVideoPreview | Live draft previews |
| VideoAnnotationLayer, VideoTimeline | Lower-level editor building blocks |
| useVideoThumbnailState | Timeline thumbnails with loading/error status and native fallback |
| @changeinteraction/screen-capture/editor | Optional editor-focused entry point |
See the complete API reference for every prop, return value, state, type guard, constant, editor type, and timeline helper.
Framework notes
- Next.js App Router components using this package must be client components.
- SSR is supported when capture APIs are called only from client interactions.
- Import the stylesheet once; do not repeatedly import it inside each field.
- The browser permission picker cannot be styled, bypassed, or pre-approved.
- Attach
data-screen-capture-rootto the same element receivingcaptureRootRefwhen using current-tab Element Capture.
Production checklist
- Pin an exact reviewed package version and commit the application lockfile.
- Keep capture calls directly behind a trusted click.
- Configure MIME, byte-size, duration, and resolution policies for the product.
- Revalidate the final file on the trusted server/storage boundary.
- Authenticate upload initialization and use short-lived presigned URLs.
- Define retention, deletion, encryption, and access-control policies.
- Show an explicit processing state while edited video is finalized.
- Handle
CaptureExportErrorwithout discarding the draft. - Test the supported browser/OS/capture-surface matrix with real devices.
- Avoid base64 and in-process media storage on serverless functions.
- Record user consent where required by the product or jurisdiction.
See security and privacy and
troubleshooting. A copyable Web Request route is
included in examples/server-route.ts.
Documentation
- API reference
- Form and upload integration
- Feedback form case study
- Browser support
- Security and privacy
- Troubleshooting
- Migration guide
- Hướng dẫn tiếng Việt
- Compiled examples
Development
npm ci
npm test
npm pack --dry-runnpm test builds the library, runs unit tests, compiles the maintained
examples, and validates local documentation links.
License
MIT
