QRScanner
A camera view that reads QR codes and hands over their text. A scanner that fills the screen is a
destination of its own — Router.push(scanner), its buttons in scanner.widgets(...) — and goes
among a screen's children only as a part of that screen. To take a photo, use CameraView.
- scan a QR code with the camera and get the text or link inside it
- open a full-screen scanner with a cancel button over the camera
- go back and stop the camera once a code is read
- hide the scan button where the device can't scan (
QRScanner.isSupported) - tell the user when the camera permission was refused
- show a small live scanner inside a card on a screen
Platforms: iOS, Android; the web and the web player only in a browser with BarcodeDetector (not Safari or Firefox); not on desktop — check QRScanner.isSupported.
At a glance
// app.json: "plugins": { "qr-scanner": "*" }
const result = signal('Nothing scanned yet')
const scan = async () => {
const scanner = QRScanner().onScan(text => {
if (text === null) return // the code left the view
result.value = text // a link, a ticket id — any text
Router.pop() // back to the home screen; a phone's camera stops
})
scanner.widgets(
UIWidget(UIButton(UIText('Cancel').style({ color: 'white' })).onClick(() => Router.pop()))
.style({ bottom: 'comfort-bottom', left: 0, right: 0, alignItems: 'center' }),
)
try {
await Router.push(scanner) // asks for the camera, then shows the scanner
} catch (e) {
toast(e.message) // refused, or no scanner on this host
}
}
Router.init(UIScreen(
UIText(() => result.value).style({ color: 'white' }),
QRScanner.isSupported
? UIButton(UIText('Scan a QR code').style({ color: 'white' })).onClick(scan)
: UIText('This device cannot scan QR codes').style({ color: 'gray' }),
).style({ bgColor: 'black', p: 20, pt: 'safe-top', gap: 16 }))Add the QR scanner plugin to the app: app.json
The scanner is the qr-scanner plugin: list it in the project's app.json —
"plugins": { "qr-scanner": "*" }. The list is one for every platform (the bundle's // plugins:
header): a plugin it does not name is not there — in a shell built from the project, on the web
and in the web player alike — and the compile warns about one the app uses and does not list. The
le.codes viewer app has the plugin whatever the list says.
Check that the device can scan QR codes: QRScanner.isSupported
QRScanner.isSupported: boolean // this host has the scanner: check it before showing a Scan buttonTo know whether this device can scan QR codes, check QRScanner.isSupported before showing a
Scan button, as the At a glance screen does. It is false on desktop, in a browser
without BarcodeDetector or the camera API (getUserMedia, given to https pages and localhost),
and in an app whose app.json does not list the plugin (the le.codes viewer app has it either
way); there Router.push(scanner) and scanner.open() reject at once, before any permission
prompt.
In headless tests (lecodes render / lecodes test) isSupported is false unless the run names
the view: --native-view qrScanner draws the scanner as a placeholder box that opens, carries its
widgets and goes back like the real one, and never scans.
QR codes only — no other barcodes
The scanner reads QR codes only, on every host: a product barcode (EAN, UPC), Code 128, Data Matrix or PDF417 is not decoded, and there is no option for other formats. With several QR codes in view, one of them is reported.
Get the code's text: onScan
scanner.onScan(callback: (text: string | null) => void): thisonScan hands over what the QR code in view says — a link, a ticket id, any text — as a string,
and null once no code has been in view for a moment (under a second on a phone, 1.5 s on the
web). It fires when that changes, not for every camera frame: a code that stays in view is
reported once, and again only after it has left the view and come back. Go back with
Router.pop() on the first code the app can use.
A scanner shown a second time remembers the code it reported last and may not report that same
code when it is scanned again — so the examples here make a new QRScanner() for each scan, and
the same code scanned twice in a row is reported both times.
Open a full-screen scanner with a Cancel button over the camera: Router.push
Router.push(scanner): Promise<void> // ask for the camera, then show the scanner
scanner.widgets(...widgets: UIWidget[]): QRScanner // a Cancel button and hints over the cameraTo show the QR scanner as a screen with a Cancel button over the camera, push it:
Router.push(scanner) opens it as a destination of its own, the Cancel button and hints go in
scanner.widgets(...), and the back button and the back swipe go back by themselves. Widgets are
laid out over the camera while it is shown and leave with it; a touch outside a widget reaches the
camera (UIWidget). The At a glance block is the whole flow.
Router.push(scanner) prepares before it shows: the current screen stays while the camera
permission is asked, and the scanner replaces it once the permission is granted. The promise
rejects, and nothing is pushed, when the permission is refused, when the host has no scanner, or
when another navigation happened meanwhile.
Go back and stop the camera: Router.pop
Router.pop() // back to the screen under the scanner
scanner.open(opts?: { transition }): Promise<void> // show it without the router
scanner.close() // take it off screen; nothing takes its placeOnce a code is read — or the user taps Cancel — go back with Router.pop(), as the
At a glance block does: the screen under the scanner returns, and on iOS and
Android the scanner's camera stops (it starts again when the scanner comes back). It stops the same
way when the scanner is covered by another destination or closed. On the web the camera can start
as soon as QRScanner() is made and stays on after the scanner leaves the screen: make the
scanner when the user asks to scan, not when the app starts.
scanner.open() shows the scanner without the router (an active router is suspended until
Router.restore()), and scanner.close() takes it off screen with nothing in its place — after a
Router.push, go back with Router.pop(), never close().
When the camera permission is refused
Router.push(scanner) asks for the camera permission and rejects when the user refuses it; the
current screen stays. A refused permission is turned back on in the system settings, so offer
them: permissions.query('camera') says 'denied' then, and permissions.openSettings() opens
the app's page there on iOS and Android (on the web it does nothing —
Permissions).
const denied = signal(false)
const scan = async () => {
const scanner = QRScanner().onScan(text => { if (text) { toast(text); Router.pop() } })
try {
await Router.push(scanner) // asks for the camera first
} catch (e) {
denied.value = (await permissions.query('camera')) === 'denied'
if (!denied.value) toast(e.message) // no scanner here, or another screen opened
}
}
Router.init(UIScreen(
UIButton(UIText('Scan').style({ color: 'white' })).onClick(scan),
UIButton(UIText('Allow the camera in Settings').style({ color: 'white' }))
.style({ display: () => (denied.value ? 'flex' : 'none') })
.onClick(() => permissions.openSettings()),
).style({ bgColor: 'black', p: 20, pt: 'safe-top', gap: 12 }))A live scanner inside a screen
When scanning is a part of a screen — a scan window in a card above a form — put the scanner among
the screen's children and size it with styles, like an image. An embedded scanner does not ask for
the camera permission itself (only Router.push and open() do; on the web the browser asks
when the scanner's camera starts): ask with permissions.request('camera') when the user starts
scanning, and add the scanner once it is 'granted'.
const code = signal('')
const card = UIColumn().style({ height: 240, borderRadius: 12, overflow: 'hidden', bgColor: '#1c1c1e' })
const startScanning = async () => {
if (!QRScanner.isSupported) return toast('This device cannot scan QR codes')
if ((await permissions.request('camera')) !== 'granted') return toast('No camera access')
card.setContent([QRScanner().style({ flexGrow: 1 }).onScan(text => { if (text) code.value = text })])
}
UIScreen(
card,
UIText(() => code.value || 'Point the camera at the table code').style({ color: 'white' }),
UIButton(UIText('Start the camera').style({ color: 'white' })).onClick(startScanning),
).style({ bgColor: 'black', p: 20, pt: 'safe-top', gap: 16 }).open()Pitfalls
const scanner = QRScanner()
// ✗ a full-screen scanner among a screen's children, its buttons absolute over it: no permission step
UIScreen(scanner.style({ flexGrow: 1 }), UIButton(UIText('Cancel')).style({ position: 'absolute', bottom: 40 }))
// ✓ a destination of its own, its buttons in its widgets
Router.push(scanner.widgets(UIWidget(UIButton(UIText('Cancel')).onClick(() => Router.pop())).style({ bottom: 40 })))
// ✗ close() after a push: the scanner goes, nothing takes its place, the stack still holds it
scanner.onScan(text => { if (text) scanner.close() })
// ✓ go back the way it came
scanner.onScan(text => { if (text) Router.pop() })See also
- CameraView — the camera preview and photo capture
- Screens & Router —
Router.push/pop, transitions - UIWidget — buttons and hints over the camera (
scanner.widgets(...)) - Permissions — asking for the camera earlier,
openSettings - NativeView — the view channel underneath, and the headless placeholder
- Conventions — host gating — the other capability guards