Skip to content

Repository files navigation

cam2qr

Zero-dependency TypeScript QR code scanning for the browser, camera included.

CI npm license: MIT

The entire pipeline (adaptive binarization, finder-pattern location, perspective correction, Reed-Solomon error correction, segment decoding) is implemented independently from the ISO/IEC 18004 specification. Not a port or a wrapper of an existing scanner. Full docs and a how-it-works deep dive live at cam2qr.com.

  • Zero runtime dependencies. One package, nothing else in your lockfile. 13.4 kB brotli for the full camera scanner; 9.5 kB if you only import decode().
  • Layered. Use decode(imageData) as a pure function (works in Node), QrScanner for batteries-included camera scanning, or the cam2qr/react, cam2qr/vue, and cam2qr/svelte adapters.
  • Fast, off the main thread. ~0.6 ms per clean frame; live scanning decodes in a module Web Worker by default with automatic main-thread fallback.
  • Robust. Decodes 100% of every distortion sweep (noise, blur, rotation, perspective, low contrast, inversion) in the public benchmark against jsQR and @zxing/library.
  • Typed errors, rich results. CameraError.code tells you why the camera failed; results carry corner points, version, EC level, corrected-codeword counts, raw bytes, segments, and a parsed-content classification (URL / WiFi / vCard / geo / tel / sms / email / GS1 element strings).

Install

npm install cam2qr

Live camera scanning

import { QrScanner } from 'cam2qr';

const scanner = new QrScanner(videoElement, {
  camera: { facing: 'environment' },
  onDecode(result) {
    console.log(result.text, result.content, result.cornerPoints);
  },
  onError(error) {
    if (error.code === 'permission-denied') showPermissionHelp();
  },
});

await scanner.start();          // asks for permission, begins scanning
scanner.setTorch(true);         // resolves false when unsupported, no throw
await scanner.setCamera({ facing: 'user' });
scanner.update({ tryHarder: true });
scanner.stop();                 // releases the camera
scanner.destroy();              // full teardown (worker, listeners)

Scanner options (all optional)

Option Default Meaning
camera.facing 'environment' 'environment' (rear) or 'user' (front)
camera.deviceId exact camera from listCameras() (overrides facing)
camera.resolution 1280×720 ideal capture resolution
maxScansPerSecond 15 decode attempts per second (battery/CPU dial)
scanRegion full frame sub-rectangle (or video => region) to decode; a big CPU saver
useWorker true decode in a Web Worker when available
useNativeDetector false use the browser's BarcodeDetector when present, falling back to our engine when missing or failing; native results carry placeholder codec metadata (version: 0, mask: -1, zero ecc, EC level 'M')
pauseOnHidden true pause while the tab is hidden
tryInverted true also try light-on-dark codes
tryHarder false extra passes: more finder triples + a 2× low-pass (blur recovery)
maxDownscale 2 downscale huge frames by up to this factor before decoding
dedupeWindowMs 1500 quiet period before the same payload fires again, timed from when it was last seen — see below (0 = off; tracked per payload)
stopOnDecode false stop 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 decode event (parity-checked); 'individual' fires each symbol separately
onDecode / onError callbacks; scanner.on('decode' | 'detect' | 'error' | 'start' | 'stop', …) also works
onDetect fires every scanned frame with located symbol candidates (or null); corner points arrive before/regardless of a successful decode, for live outline overlays

Also on the instance: pause() / resume(), setZoom(level), getCapabilities(), listCameras() (exported standalone).

How deduping works

dedupeWindowMs is timed from when a payload was last seen, not from when it last fired, and every frame the code stays in view refreshes that timer. So a code held steadily in front of the camera fires onDecode exactly once, however long you hold it — it has to leave the frame for longer than the window before it can fire again.

That is what you want for scanning a series of items (each new code fires immediately, and one lingering code cannot spam you). It does mean two things are not what a first reading suggests:

  • Holding one code in frame does not re-fire every dedupeWindowMs.
  • Re-scanning the same item requires it to leave the frame for a full window — pointing away and back is enough.

Set dedupeWindowMs: 0 to fire on every decoded frame and dedupe yourself.

Live outline overlays

onDetect reports corner points in video pixel coordinates. A canvas sized to videoWidth×videoHeight and CSS-stretched exactly like the video can draw them directly (see demo/); for overlays positioned in CSS pixels, videoToElementCoordinates(points, video) maps them, accounting for object-fit letterboxing/cropping.

const scanner = new QrScanner(video, {
  onDetect(detections) {
    clearOverlay();
    for (const d of detections ?? []) drawPolygon(d.cornerPoints); // tracks while you aim
  },
  onDecode(result) {
    flashPolygon(result.cornerPoints); // confirmed decode
  },
});

Error codes

start() rejects with a CameraError whose code is one of permission-denied, camera-not-found, camera-in-use, insecure-context (camera needs HTTPS or localhost), unsupported, stream-failed.

The error event carries either typed flavor, so you can tell a camera fault from a decode fault: a CameraError for camera/stream problems (the codes above), or a DecodeError when the decode runner itself fails — a frame that failed to decode or the Web Worker crashing (code: 'runner-failed'). Match on error.name:

scanner.on('error', (error) => {
  if (error.name === 'CameraError' && error.code === 'permission-denied') showPermissionHelp();
  // DecodeError (e.g. 'runner-failed') means decoding hiccuped, not the camera —
  // the scanner keeps running (a failed worker transparently falls back inline).
});

One-shot decoding

import { decode } from 'cam2qr';

const result = decode(imageData, { tryHarder: true }); // QrResult | null

Takes any { data, width, height } RGBA buffer (a canvas ImageData, a decoded PNG in Node, …). Options: tryInverted, tryHarder, maxDownscale, parseContent. decode() returns null when no decodable code is present and only throws on genuinely invalid input.

Multiple codes per frame

import { decodeAll } from 'cam2qr';

const results = decodeAll(imageData); // QrResult[]: every decodable symbol, deduplicated

decodeAll always runs the full pass plan (all scales, plus the inverted pass), so it can find a dark-on-light and a light-on-dark code in the same frame. On the scanner, set multiple: true instead.

Locate without decoding

import { detect } from 'cam2qr';

const candidates = detect(imageData); // Detection[]: { cornerPoints, moduleSize }

Cheaper than a decode and useful for framing feedback; candidates are plausibility-ranked and may include finder-like decoys that would not survive a decode.

The result

interface QrResult {
  text: string;                    // decoded, charset-aware (ECI/UTF-8/Shift-JIS kanji)
  bytes: Uint8Array;               // raw payload bytes
  content?: ParsedContent;         // url | wifi | vcard | geo | tel | sms | email | gs1 | text
  cornerPoints: [Point, Point, Point, Point]; // symbol outline in image pixels
  moduleSize: number;              // measured pixels per module
  version: number;                 // 1–40
  errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H';
  mask: number;
  segments: Segment[];             // per-mode payload breakdown (incl. kanji)
  ecc: { blocks: number; codewordsCorrected: number }; // damage signal
  structuredAppend?: { index: number; total: number; parity: number };
  fnc1?: { position: 'first' } | { position: 'second'; applicationIndicator: string };
}

GS1 symbols (FNC1 in first position) get their element strings split into application identifiers: content becomes { type: 'gs1', elements: [{ ai: '01', value: '0401…' }, …] }, with GS separators and alphanumeric % escapes handled per spec.

Structured-append sequences (one payload split across up to 16 symbols) are reassembled by the scanner by default: parts are withheld and a single decode event fires with the joined payload once every symbol has been seen and the parity byte checks out. Show all symbols at once with multiple: true, or pan across them within 30 s. decode()/decodeAll() stay pure and return the parts with their structuredAppend headers.

React

import { useQrScanner } from 'cam2qr/react';

function Scanner() {
  const { videoRef, result, error, isScanning, scanner } = useQrScanner({
    onDecode: (r) => console.log(r.text),
  });
  return <video ref={videoRef} />;
}

The hook starts the camera when the video mounts, releases it on unmount, and re-renders on results/errors. Pass enabled: false to keep the camera off; use scanner for imperative control (torch, camera switching, update()).

Live-tunable options are applied as they change, so you can drive them straight from state:

useQrScanner({ dedupeWindowMs, maxScansPerSecond, tryHarder, scanRegion });

That covers maxScansPerSecond, scanRegion, tryInverted, tryHarder, maxDownscale, dedupeWindowMs, stopOnDecode, multiple and structuredAppend — the same set scanner.update() accepts.

Everything else is fixed when the scanner is created, because changing it means reacquiring the camera. In particular, changing camera on a later render does not switch cameras — switch imperatively instead:

scanner?.setCamera({ facing: 'user' }); // or { deviceId } from listCameras()

useWorker, useNativeDetector and pauseOnHidden are mount-time config; to change one, remount the scanner (e.g. with a key).

Pass paused to suspend and resume decoding without releasing the camera — no black-flash or permission re-prompt, unlike toggling enabled. It is reactive and correct from the first render, so paused: true starts the scanner suspended (the stream still warms up) and flips live as the prop changes:

const { videoRef } = useQrScanner({ paused: !isSheetOpen });

Vue

<script setup>
import { useQrScanner } from 'cam2qr/vue';

const { videoRef, result, error, isScanning, scanner } = useQrScanner({
  onDecode: (r) => console.log(r.text),
});
</script>

<template>
  <video ref="videoRef"></video>
  <p v-if="result">{{ result.text }}</p>
</template>

Same lifecycle as the React hook, with refs instead of state. enabled accepts a Ref to toggle the camera reactively; paused (also boolean | Ref<boolean>) suspends and resumes decoding while keeping the camera stream warm — no black-flash, unlike enabled. Everything is torn down when the component's scope disposes.

The composable takes its options object once, so unlike the React hook it does not re-read them per render: tune a running scanner with scanner.value?.update({ … }).

Svelte

<script>
  import { createQrScanner } from 'cam2qr/svelte';

  const { video, result, isScanning } = createQrScanner({
    onDecode: (r) => console.log(r.text),
  });
</script>

<video use:video></video>
{#if $result}<p>{$result.text}</p>{/if}

Readable stores plus an action (built on svelte/store only, so Svelte 3, 4, and 5 all work). The camera starts when the <video> mounts and is released when it unmounts, so you can gate the element with {#if} to toggle scanning. To suspend decoding without releasing the camera, pass paused — a boolean, or a store to toggle reactively:

<script>
  import { writable } from 'svelte/store';
  const paused = writable(false);
  const { video, result } = createQrScanner({ paused });
</script>

<video use:video></video>
<button on:click={() => paused.update((p) => !p)}>Toggle</button>

Bundlers & legacy resolvers (CRA, TS < 5)

The subpath entries (cam2qr/react, cam2qr/vue, cam2qr/svelte) work out of the box with modern bundlers and TypeScript's bundler/node16/nodenext resolution via the package exports map. For toolchains still on classic moduleResolution: node — TypeScript < 5, Create React App / react-scripts, older Jest/Metro resolvers — the package also ships directory stubs (react/package.json, …) that point those resolvers at the same build, so import { useQrScanner } from 'cam2qr/react' resolves its types and its module without any declare module shim or Jest moduleNameMapper. No configuration is needed; the root decode/QrScanner entry never depended on this.

Node and other runtimes

decode(), decodeAll(), and detect() are pure functions over RGBA bytes with no DOM or Node API usage, so they run in browsers, workers, Node, and Hermes. The camera layer is browser-only by nature. For React Native, docs/react-native.md sketches the adapter approach over react-native-vision-camera.

Benchmarks

docs/benchmarks.md is regenerated by pnpm compare, which feeds the same seeded synthetic frames to cam2qr, jsQR, and @zxing/library (both are devDependencies used as measurement baselines only). Snapshot from the current run: cam2qr with tryHarder decodes 100% of every distortion sweep (jsQR drops to 60% on noise and 48% on low contrast; zxing to 20% on noise and 0% on inverted codes), at ~0.6 ms per clean small frame: ~5× faster than jsQR, on par with zxing. The harness asserts cam2qr's detection-rate floors, so quality regressions fail CI.

There is also a regression corpus in test/fixtures/corpus, where every image must decode, enforced by the main test suite. "Fails to scan" reports become corpus images; see its README.

How it's tested

Every decoder stage has unit tests (GF(256), Reed-Solomon with injected errors, BCH format/version, masks, bitstream). Round-trip tests generate symbols with an external generator and decode them across all 40 versions × 4 EC levels × 8 masks. A pure-TS software renderer produces distorted images (rotation, perspective, noise, blur, lighting gradients, inversion) for detection tests. The scanner loop is integration-tested against fake camera hardware, and Playwright feeds a generated video to Chromium's fake camera to verify the shipped bundle end to end.

Demo

pnpm demo builds the library and serves a scanner page (Vite, port 5183) that runs against the built artifacts: camera picker, torch, pause, tryHarder toggle, decode outline overlay, and parsed-content display.

Contributing

Bug reports with a sample image are especially valuable, since every "fails to scan" report can become a permanent regression test. See CONTRIBUTING.md for setup, commands, architecture, and conventions.

License

MIT

About

Zero-dependency QR Code Scanner with hooks for React, Vue, and Svelte

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages