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

OptionDefaultWhat it controls
camera.facing'environment'Rear ('environment') or front ('user') camera.
camera.deviceIdPin an exact camera from listCameras(); overrides facing.
camera.resolution1280×720Ideal capture resolution constraints.
maxScansPerSecond15Decode attempts per second: the battery and CPU dial.
scanRegionfull frameA sub-rectangle (or video => region) to decode. A big CPU saver for viewfinder UIs; corner points map back to full-video coordinates.
useWorkertrueDecode in a module Web Worker, with automatic main-thread fallback.
useNativeDetectorfalseUse 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().
pauseOnHiddentruePause while the tab is hidden; resume on visibility.
tryInvertedtrueAlso look for light-on-dark symbols. Cheap.
tryHarderfalseExtra passes: more finder-triple candidates, plus a 2× low-pass for blurry codes.
maxDownscale2Downscale huge frames by up to this factor before decoding. Full resolution is still tried when the fast pass fails.
dedupeWindowMs1500Quiet period before the same payload fires again (0 = every frame). Tracked per payload, so several visible codes dedupe independently.
stopOnDecodefalseOne-shot mode: release the camera after the first result.
multiplefalseDecode 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 / onErrorCallbacks. scanner.on(event, fn) / off() work too.

Instance methods

Camera errors

error.codeMeaningTypical response
permission-deniedThe user (or a policy) blocked camera access.Show how to re-enable; offer file upload plus decode().
camera-not-foundNo camera matches the constraints.Relax facing/deviceId; list devices.
camera-in-useAnother app or tab holds the device.Ask the user to close it, then retry.
insecure-contextThe camera needs HTTPS (or localhost).Serve over HTTPS.
unsupportedNo getUserMedia in this browser.Fall back to file upload plus decode().
stream-failedThe 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.

Every ECI charset in the AIM assignment table maps to its 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.

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.

Numbers, not vibes: the benchmarks section shows measured detection rates and speed against jsQR and @zxing/library, regenerated by pnpm compare.