Skip to content
Devix Open Source

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;
  • playsinline and 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.';
}
Updated 15 Sep 2026