QR scanning,
all the way down.

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.

  • 0 dependencies
  • 13.4 kB brotli
  • ~0.6 ms / frame
  • 198 tests
  • MIT license
$ npm install cam2qr

Why cam2qr

Fresh code for a format that outlived its scanners.

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.

[0 deps]

Zero dependencies

Binarization, finder patterns, perspective math, Reed–Solomon: all of it lives in this one package. Your lockfile stays boring.

[13.4 kB]

Tiny & tree-shakeable

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.

[worker]

Off the main thread

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.

[typed]

TypeScript-native

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.

[layered]

Every layer, your pick

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.

[complete]

Reads what others skip

Rotation, blur, glare, inverted colors, damaged modules. Plus multiple codes per frame, Kanji mode, GS1 element strings, and structured-append reassembly.

Quick start

Five ways in.

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

How it works

One camera frame, six stages, about half a millisecond.

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 →

detect/binarizer.ts

Binarize

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.

detect/finder.ts · §7.3.2

Locate

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.

detect/perspective.ts

Rectify

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.

detect/detector.ts

Sample

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.

core/reed-solomon.ts · §8.5

Correct

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.

core/segments.ts · §8.4

Decode

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

Measured, not asserted.

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 ratecam2qr (tryHarder)jsQR@zxing/library
salt & pepper noise 2%100%60%20%
low contrast100%48%96%
inverted colors100%100%0%
lighting gradient100%100%96%
rotation · perspective · blur100%100%96–100%

Median ms/frame (1280×720, one symbol)

cam2qr (scanner default)6.9 ms
@zxing/library7.3 ms
cam2qr (full resolution)7.9 ms
jsQR49.9 ms

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

Why another QR library?

Because the incumbents stopped moving, and QR codes did not. cam2qr sets out to be the maintained, modern default.

cam2qrjsQR@zxing/libraryhtml5-qrcode
Statusactivearchiveddormantinactive
Originfrom-spec TypeScriptdecode-only TS libraryJava portwraps zxing
Camera layer✓ built inpartial✓ + baked-in UI
Web Worker decoding✓ defaultDIYDIY
Typed camera errorsstrings
Payload classification✓ url/wifi/vcard/gs1/…
Multiple codes / frame✓ decodeAll + scannerseparate API
Structured-append reassembly✓ automaticheaders only
Framework adapters✓ react · vue · svelte
Runtime dependencies00severalbundles 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

Questions we'd ask too.

Why not just use the native BarcodeDetector API?

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.

Why pure TypeScript instead of WASM?

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.

Is it really from scratch?

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.

How is it tested?

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.

What about Kanji, GS1, multiple codes, split payloads?

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.

Does it work in Node?

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.

How do I install it?

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.