Documentation
Everything the scanner does.
Three layers, each happy to work alone: a pure decode()
function over pixels, a batteries-included QrScanner for live camera work,
and framework adapters that wrap it. This page is the full reference. The
deep dive explains what happens inside.
Install
One package, five entry points.
// npm install cam2qr
import { decode, decodeAll, detect, QrScanner, listCameras } from 'cam2qr';
import { useQrScanner } from 'cam2qr/react'; // optional peer: react >= 18
import { useQrScanner } from 'cam2qr/vue'; // optional peer: vue >= 3.2
import { createQrScanner } from 'cam2qr/svelte'; // optional peer: svelte >= 3.55
The builds are ESM-first with CJS fallbacks, sideEffects: false,
and strictly typed. The worker that handles off-main-thread decoding ships as
cam2qr/worker, and bundlers that understand
new Worker(new URL(…)) (Vite, webpack, Rollup…) pick it up on
their own.
Live scanning
new QrScanner(video, options)
import { QrScanner } from 'cam2qr';
const scanner = new QrScanner(videoElement, {
camera: { facing: 'environment' },
onDecode(result) { console.log(result.text, result.content); },
onDetect(detections) { drawOutlines(detections); },
onError(error) { if (error.code === 'permission-denied') showHelp(); },
});
await scanner.start();
Options
| Option | Default | What it controls |
|---|---|---|
| camera.facing | 'environment' | Rear ('environment') or front ('user') camera. |
| camera.deviceId | — | Pin an exact camera from listCameras(); overrides facing. |
| camera.resolution | 1280×720 | Ideal capture resolution constraints. |
| maxScansPerSecond | 15 | Decode attempts per second: the battery and CPU dial. |
| scanRegion | full frame | A sub-rectangle (or video => region) to decode. A big CPU saver for viewfinder UIs; corner points map back to full-video coordinates. |
| useWorker | true | Decode in a module Web Worker, with automatic main-thread fallback. |
| useNativeDetector | false | Use the browser's BarcodeDetector where present, with a permanent fallback to our engine when it is missing or failing. Native results carry placeholder codec metadata (version: 0, mask: -1, zero ecc). Fixed at start(). |
| pauseOnHidden | true | Pause while the tab is hidden; resume on visibility. |
| tryInverted | true | Also look for light-on-dark symbols. Cheap. |
| tryHarder | false | Extra passes: more finder-triple candidates, plus a 2× low-pass for blurry codes. |
| maxDownscale | 2 | Downscale huge frames by up to this factor before decoding. Full resolution is still tried when the fast pass fails. |
| dedupeWindowMs | 1500 | Quiet period before the same payload fires again (0 = every frame). Tracked per payload, so several visible codes dedupe independently. |
| stopOnDecode | false | One-shot mode: release the camera after the first result. |
| multiple | false | Decode every code in the frame; each symbol fires its own decode event. |
| structuredAppend | 'reassemble' | Join multi-symbol sequences into one parity-checked decode event, or 'individual' to fire each part. See payload semantics. |
| onDecode / onDetect / onError | — | Callbacks. scanner.on(event, fn) / off() work too. |
Instance methods
await start(): asks for permission, attaches the stream, and begins scanning. Rejects with a typedCameraError.stop(): stops scanning and releases the camera.pause()/resume()keep the stream alive.destroy(): full teardown (worker, listeners). The instance cannot be reused.await setCamera({ facing | deviceId }): hot-switches cameras mid-scan.await setTorch(on)/await setZoom(level): resolvefalsewhen unsupported. They never throw.getCapabilities():{ torch: boolean, zoom: { min, max, step } | null }for the active track.update(options): live-tunesmaxScansPerSecond,scanRegion,tryInverted,tryHarder,maxDownscale,dedupeWindowMs,stopOnDecode,multiple,structuredAppendwithout restarting the camera.on(event, fn)/off(event, fn): the events aredecode,detect,error,start,stop.listCameras()(a standalone export): returns{ id, label, facing }[]. Labels fill in after permission is granted.
Camera errors
| error.code | Meaning | Typical response |
|---|---|---|
| permission-denied | The user (or a policy) blocked camera access. | Show how to re-enable; offer file upload plus decode(). |
| camera-not-found | No camera matches the constraints. | Relax facing/deviceId; list devices. |
| camera-in-use | Another app or tab holds the device. | Ask the user to close it, then retry. |
| insecure-context | The camera needs HTTPS (or localhost). | Serve over HTTPS. |
| unsupported | No getUserMedia in this browser. | Fall back to file upload plus decode(). |
| stream-failed | The stream started but broke (device unplugged…). | Restart or re-enumerate. |
Live overlays
The detect event: corner points before the decode.
Fires on every scanned frame with the located symbol candidates as
Detection[] ({ cornerPoints, moduleSize }) in video pixel
coordinates, or null when the frame has none. Candidates arrive even while
the symbol is still too blurred or far away to decode, which is what makes a live
viewfinder outline feel locked on.
// A canvas sized to the video's pixel grid and CSS-stretched exactly like it
// can draw video coordinates directly:
overlay.width = video.videoWidth;
overlay.height = video.videoHeight;
const scanner = new QrScanner(video, {
onDetect(detections) {
ctx.clearRect(0, 0, overlay.width, overlay.height);
for (const d of detections ?? []) strokePolygon(ctx, d.cornerPoints);
},
onDecode(result) {
flashPolygon(ctx, result.cornerPoints); // confirmed decode
},
});
For DOM overlays positioned in CSS pixels, videoToElementCoordinates(points,
video) maps video coordinates through the element's object-fit
letterboxing and cropping (it assumes the default centered object-position).
One-shot API
decode · decodeAll · detect
Pure functions over any { data, width, height } RGBA buffer:
a canvas ImageData, a decoded PNG in Node, a video frame you grabbed
yourself. No DOM, no camera. This is the layer everything else is built on.
import { decode, decodeAll, detect } from 'cam2qr';
const result = decode(imageData, { tryHarder: true });
// QrResult | null; null means "no decodable code", never an exception
const results = decodeAll(imageData);
// QrResult[]: every symbol in the frame, deduplicated across passes;
// always runs the full pass plan (scales + inverted), so it can find a
// dark-on-light and a light-on-dark code together
const candidates = detect(imageData, { maxCandidates: 4 });
// Detection[]: plausibility-ranked corner points, no decoding at all;
// may include finder-like decoys that would not survive a decode
decode() and decodeAll() accept tryInverted,
tryHarder, maxDownscale, and parseContent;
detect() accepts tryInverted, maxDownscale, and
maxCandidates. All three throw only on malformed input dimensions.
Results
Every result carries its evidence.
interface QrResult {
text: string; // decoded, charset-aware (ECI/UTF-8/Shift-JIS)
bytes: Uint8Array; // raw payload bytes
content?: ParsedContent; // url | wifi | vcard | geo | tel | sms | email | gs1 | text
cornerPoints: [Point, Point, Point, Point]; // symbol outline, for overlays
moduleSize: number; // pixels per module, a distance/quality proxy
version: number; // 1–40
errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H';
mask: number; // 0–7
segments: Segment[]; // per-mode payload breakdown (incl. kanji)
ecc: { blocks: number; codewordsCorrected: number }; // how damaged was it?
structuredAppend?: { index: number; total: number; parity: number };
fnc1?: { position: 'first' } | { position: 'second'; applicationIndicator: string };
}
content is a best-effort classification (WiFi credentials, vCards, URLs,
geo/tel/sms/email URIs, GS1 element strings) and it never throws. Payloads it does not
recognize come back as { type: 'text' }. Disable it with
parseContent: false if you only want the raw text.
Payload semantics
The parts of the spec most libraries skip.
GS1 / FNC1
Symbols with FNC1 in first position carry GS1 data. cam2qr applies the
alphanumeric % escapes (%% → literal, lone % →
GS separator) and splits the element string into application identifiers:
result.content
// { type: 'gs1', raw: '0104012345678903171912311021ABC…',
// elements: [ { ai: '01', value: '04012345678903' }, // GTIN
// { ai: '17', value: '191231' }, // expiry
// { ai: '10', value: 'ABC123' } ] } // lot
Fixed-length AIs come from the GS1 predefined-length table; anything
else reads to the next GS separator. FNC1 in second position surfaces its application
indicator on result.fnc1.
Kanji mode
Kanji-mode segments (13-bit packed Shift-JIS, spec §8.4.7) decode natively. No dependency, no flag to set. The segment carries both the decoded text and the Shift-JIS bytes.
Structured append
A payload can be split across up to 16 symbols. The scanner reassembles sequences by
default: parts are collected across frames (within a 30 s window), and one
decode event fires with the joined payload once every symbol has been seen
and the parity byte (the XOR of all payload bytes) checks out. A misdecoded
part can never produce a corrupt join. Show all the symbols at once with
multiple: true, or pan across them. Set
structuredAppend: 'individual' to receive the raw parts; the pure
decode()/decodeAll() functions always return parts with their
structuredAppend headers.
TextDecoder label (the ISO-8859 series, Windows code pages, Shift-JIS,
Big5, GB18030, EUC-KR, UTF-8/16). Runtimes missing a converter fall back to the
UTF-8 → ISO-8859-1 heuristic instead of failing.Framework adapters
Thin wrappers, tree-shaken when unused.
import { useQrScanner } from 'cam2qr/react';
function Scanner() {
const { videoRef, result, error, isScanning, scanner } = useQrScanner({
onDecode: (r) => console.log(r.text),
});
return <video ref={videoRef} />;
}
// Starts when the video mounts; releases the camera on unmount.
// enabled: false keeps the camera off; `scanner` gives imperative control.
// <script setup>
import { useQrScanner } from 'cam2qr/vue';
const { videoRef, result, error, isScanning, scanner } = useQrScanner({
onDecode: (r) => console.log(r.text),
});
// template: <video ref="videoRef"></video>
// `enabled` accepts a Ref<boolean> to toggle the camera reactively;
// everything tears down when the component scope disposes.
// <script>
import { createQrScanner } from 'cam2qr/svelte';
const { video, result, error, isScanning, scanner } = createQrScanner({
onDecode: (r) => console.log(r.text),
});
// markup: <video use:video></video>
// {#if $result}<p>{$result.text}</p>{/if}
// Stores + an action, built on svelte/store; works with Svelte 3, 4, and 5.
// Gate the <video> with {#if} to toggle scanning.
React Native: the pure decode()
layer already runs under Hermes. A dedicated adapter package is planned; the
feasibility write-up lives in
docs/react-native.md.
Node
Decode server-side or in tests.
import { readFileSync } from 'node:fs';
import { PNG } from 'pngjs'; // any PNG decoder works
import { decode } from 'cam2qr';
const png = PNG.sync.read(readFileSync('ticket.png'));
const result = decode({ data: png.data, width: png.width, height: png.height });
console.log(result?.text);
This is exactly how cam2qr's own test suite runs thousands of decodes in CI. The pipeline has no DOM anywhere in it.
Performance
Where the milliseconds go.
scanRegion: the single biggest lever. A centered viewfinder box can cut per-frame cost by 4–10× and rejects background clutter.maxScansPerSecond: 15 is smooth; 5–10 still feels instant and halves the battery drain.maxDownscale: 2(the default): on 720p+ frames the downscaled pass usually decodes first, and it doubles as a low-pass filter for oversampled codes. It measured fastest in the comparative benchmark.useWorker: true(the default): keeps jank off the main thread. Frames transfer; they are not copied.tryHarder: save it for a retry path or for file uploads. It multiplies finder candidates and adds a blur-recovery pass.useNativeDetector: worth testing on Chromium-heavy fleets. Results lose codec metadata and behavior varies by platform, which is why it is off by default.
Numbers, not vibes: the
benchmarks section shows measured detection rates and speed against jsQR and
@zxing/library, regenerated by pnpm compare.