Guide
Scanning
import { createScanner, canScan, scanImage } from '@devix-labs/qr-code';
const scanner = createScanner(document.querySelector('video'), {
onFound: ([code]) => { console.log(code.value); },
});
if (canScan()) await scanner.start();
What this does and does not ship
The decoding is the browser's own, through the Barcode Detection API. We do not ship a decoder.
That is a deliberate trade, and the numbers are the argument: jsQR is 56.8 kB gzipped and was last published in 2021; html5-qrcode is 107.8 kB because it bundles ZXing, and was last published in 2023 with 445 open issues. Both are mostly decoder. Meanwhile the issues people actually file against them are about the camera — iOS freezing, webviews not starting, video sizing, the torch — which is the part a library should own.
So this package is 7.7 kB and owns the camera:
- picks the back camera, which is the one people point at things;
playsinlineand muted, so iOS plays it rather than going full screen;- stops the track when the tab is hidden and starts it again when it returns — a camera left running in a background tab is why the indicator light stays on, and on iOS it is why the video never resumes;
- torch control where the device has one;
- every camera listed, so you can offer a choice.
Browser support
import { canScan, supportedFormats } from '@devix-labs/qr-code';
canScan(); // boolean
await supportedFormats(); // ['qr_code', 'ean_13', …] or []
| Barcode Detection API | |
|---|---|
| Chrome, Edge — Android | yes |
| Chrome, Edge — desktop | behind a flag on macOS; yes on Windows and ChromeOS |
| Safari | yes, 17 and later |
| Firefox | no |
So: always branch on canScan(). Where it is false, offer a file upload —
most people scanning on a desktop are holding their phone anyway, and a photo
works:
<input type="file" accept="image/*" capture="environment">
input.addEventListener('change', async () => {
if (canScan()) {
const [code] = await scanImage(input.files[0]);
if (code) use(code.value);
} else {
await uploadForServerSideDecoding(input.files[0]);
}
});
If you need scanning to work in every browser, add a decoder yourself and call
it in the canScan() false branch — you then pay the 57 kB only where it is
actually needed, rather than shipping it to everyone.
Options
| Option | Default | |
|---|---|---|
onFound |
— | Called with every code in the frame. |
once |
true |
Stop after the first. Set false for a till or a door. |
facing |
'environment' |
Or 'user'. |
deviceId |
— | From scanner.cameras(). |
formats |
['qr_code'] |
The detector reads more; ask for what you want. |
onError |
— | Decode errors, which are normal and frequent — do not show them. |
scanner.cameras(); // every camera, after permission
scanner.hasTorch(); // whether this one has a light
await scanner.setTorch(true);
scanner.stop(); // always call this when you unmount
onFound gives you the value, the format and the box it was found in, so you can
draw a highlight over the video.
Permissions
start() rejects if the person refuses the camera. Catch it and say what to do —
the browser will not ask twice, so an unexplained blank video is the worst
outcome:
try {
await scanner.start();
} catch (error) {
message.textContent = error.name === 'NotAllowedError'
? 'Camera access was refused. Allow it in your browser settings and reload.'
: 'No camera was found.';
}