Zero dependencies
Binarization, finder patterns, perspective math, Reed–Solomon: all of it lives in this one package. Your lockfile stays boring.
A zero-dependency TypeScript library that finds and decodes QR codes from a device camera. Every stage is written from the ISO/IEC 18004 spec itself: nothing wrapped, nothing ported, nothing borrowed.
Why cam2qr
QR codes are everywhere. The libraries that read them mostly went quiet years ago: archived, dormant, or wrapped around a decade-old Java port. cam2qr is an independent implementation built from the ISO/IEC 18004 specification, written for the web we build on now.
Binarization, finder patterns, perspective math, Reed–Solomon: all of it lives in this one package. Your lockfile stays boring.
13.4 kB brotli for the full camera scanner, or 9.6 kB if you only import
decode(). CI checks these budgets for every entry point, on every run.
Live scanning decodes in a module Web Worker by default, and falls back to the main thread on its own. Frames travel as transferable buffers, so nothing is copied.
Strict types from camera to payload. Camera failures arrive as typed error codes
(permission-denied, camera-in-use, …), so you branch on a
value instead of matching strings.
Take the pure decode(imageData) function, the batteries-included
QrScanner, or the first-party react / vue /
svelte adapters. There is no baked-in UI to fight.
Rotation, blur, glare, inverted colors, damaged modules. Plus multiple codes per frame, Kanji mode, GS1 element strings, and structured-append reassembly.
Quick start
import { QrScanner } from 'cam2qr';
const scanner = new QrScanner(videoElement, {
camera: { facing: 'environment' },
onDetect(detections) {
// corner points every frame, before a decode succeeds: live overlays
drawOutlines(detections);
},
onDecode(result) {
// result.content classifies the payload: url | wifi | vcard | gs1 | …
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.stop(); // releases the camera
import { decode, decodeAll, detect } from 'cam2qr';
// Works on any RGBA buffer: canvas ImageData, a decoded PNG in Node, …
const result = decode(imageData, { tryHarder: true });
if (result) {
console.log(result.text); // decoded payload
console.log(result.version); // 1–40
console.log(result.ecc.codewordsCorrected); // damage that was repaired
}
const everything = decodeAll(imageData); // every code in the frame
const candidates = detect(imageData); // corner points only, no decoding
import { useQrScanner } from 'cam2qr/react';
function Scanner() {
const { videoRef, result, error, scanner } = useQrScanner({
onDecode: (r) => console.log(r.text),
});
// starts when the video mounts; releases the camera on unmount
return <video ref={videoRef} />;
}
// <script setup>
import { useQrScanner } from 'cam2qr/vue';
const { videoRef, result, isScanning } = useQrScanner({
onDecode: (r) => console.log(r.text),
});
// template: <video ref="videoRef"></video>
// <p v-if="result">{{ result.text }}</p>
// <script>
import { createQrScanner } from 'cam2qr/svelte';
const { video, result } = createQrScanner({
onDecode: (r) => console.log(r.text),
});
// markup: <video use:video></video>
// {#if $result}<p>{$result.text}</p>{/if}
How it works
Every stage below is our own code, written from the QR specification.
That is why each one can cite its clause. The list also mirrors the module layout of
src/, so these docs double as a map of the codebase.
Read the full stage-by-stage deep dive →
Each pixel is compared with the mean brightness of a sliding window around it. A summed-area table makes that mean four lookups, whatever the window size. So a glare gradient that beats any single global threshold still splits cleanly, and a global Otsu cut keeps large ink regions solid.
Every scan line is searched for the finder pattern's 1:1:3:1:1
rhythm of dark and light runs. Any line through the pattern's center shows that
ratio, however the code is turned. That one property makes rotation free.
The three finder centers (plus the alignment pattern on larger symbols) anchor a projective homography, the same square-to-quad math used in texture mapping. Tilted phone shots sample straight.
The transform maps each module's center back into the image. One pixel read per
module turns the photo into an exact N×N bit matrix. Decoded symbols
release their finder patterns, so several codes in one frame sort out cleanly.
Codewords are de-interleaved into blocks and repaired with Reed–Solomon math over GF(256): syndromes, Berlekamp–Massey, Chien search, Forney. Up to 30% of a symbol can be damaged and still read.
The corrected bitstream is read as mode segments (numeric, alphanumeric, byte with UTF-8/ECI charsets, Shift-JIS Kanji, FNC1/GS1) and then classified: URL, WiFi, vCard, GS1 element strings, geo, phone, SMS, email.
Benchmarks
pnpm compare runs identical seeded frames through cam2qr,
jsQR, and @zxing/library, then regenerates
docs/benchmarks.md.
The tables below come straight from that run: 25 frames per scenario, and the decoded
text must match the payload exactly.
| Detection rate | cam2qr (tryHarder) | jsQR | @zxing/library |
|---|---|---|---|
| salt & pepper noise 2% | 100% | 60% | 20% |
| low contrast | 100% | 48% | 96% |
| inverted colors | 100% | 100% | 0% |
| lighting gradient | 100% | 100% | 96% |
| rotation · perspective · blur | 100% | 100% | 96–100% |
On clean ~200×200 frames: cam2qr 0.63 ms, zxing 0.43 ms,
jsQR 3.48 ms. zxing edges ahead on the easy frames and pays for it on the hard
ones above. Node 22 on a shared cloud container; run pnpm compare for
your hardware. Bundle budgets (20 kB full / 17 kB decode-only, plus the
worker and adapter entries) are enforced on every CI run.
The landscape
Because the incumbents stopped moving, and QR codes did not. cam2qr sets out to be the maintained, modern default.
| cam2qr | jsQR | @zxing/library | html5-qrcode | |
|---|---|---|---|---|
| Status | active | archived | dormant | inactive |
| Origin | from-spec TypeScript | decode-only TS library | Java port | wraps zxing |
| Camera layer | ✓ built in | — | partial | ✓ + baked-in UI |
| Web Worker decoding | ✓ default | DIY | DIY | — |
| Typed camera errors | ✓ | — | — | strings |
| Payload classification | ✓ url/wifi/vcard/gs1/… | — | — | — |
| Multiple codes / frame | ✓ decodeAll + scanner | — | separate API | — |
| Structured-append reassembly | ✓ automatic | — | headers only | — |
| Framework adapters | ✓ react · vue · svelte | — | — | — |
| Runtime dependencies | 0 | 0 | several | bundles zxing |
None of the claims above run on vibes. pnpm compare feeds the
same frames to cam2qr, jsQR, and @zxing/library, then regenerates
docs/benchmarks.md in the repository.
FAQ
Because it only exists in Chromium. Safari and Firefox, which cover most phones in
many markets, get nothing. cam2qr works everywhere getUserMedia does.
And when you want the native path anyway, the opt-in useNativeDetector
option uses it where it exists and falls back to our engine on its own when it is
missing or broken.
We measured before deciding, and we measured against the incumbents, not just ourselves. The full pipeline decodes a clean frame in about 0.55 ms. It ties the zxing TS port on 720p frames, runs about 5× faster than jsQR, and wins every distortion sweep. WASM would add bundler friction and loading complexity to buy speed nobody can perceive. The pipeline sits behind small interfaces, so an accelerator can still drop in if a real workload ever needs one.
Yes. Every stage is implemented from the public ISO/IEC 18004 specification and standard textbook mathematics: the Galois-field arithmetic, the Reed–Solomon decoder, the BCH format codes, the mask patterns, the finder-pattern geometry, and the perspective transform are all this repo's code. It is an independent implementation, not a port of zxing or jsQR.
Several ways at once. Round-trips against an independent generator cover all 40 versions × 4 error-correction levels × 8 masks. Every math stage has unit tests, including recovery from injected errors. Synthetic distortion suites bend, blur, and dirty the images. A growing image corpus turns every "fails to scan" report into a regression test. A comparative benchmark enforces detection-rate floors in CI. And a Playwright run feeds a generated video to real Chromium's fake camera, decoding it through the shipped worker bundle.
All of it ships. Kanji-mode segments decode via Shift-JIS. GS1 symbols get their
element strings split into application identifiers in result.content.
decodeAll() and the scanner's multiple option read every
code in the frame. And structured-append sequences are reassembled automatically,
parity-checked, before onDecode fires. Micro QR and rMQR sit on the
demand-driven backlog.
decode(), decodeAll(), and detect() do.
They are pure functions over RGBA bytes, which is exactly how the test suite runs
thousands of decodes in CI. The camera layer is browser-only by nature.
npm install cam2qr, or the pnpm or yarn equivalent. It is one
ordinary package: zero runtime dependencies, MIT licensed, with the React, Vue, and
Svelte adapters riding along as subpath exports. One install covers all of it.