Camera access through the MediaDevices API, scan detection, and a fallback without a camera, all in a single component
A barcode scanner right in the browser sounds like a native app feature, yet it can be fully built for the web with the MediaDevices API and a lean Alpine component. Using a barcode-driven product search as the running example, this article shows how camera access gets requested, how the native BarcodeDetector API and a JS library complement each other as a fallback, and how denied permissions get handled cleanly.
Table of Contents
- 1. Use case: product search by barcode in the store
- 2. MediaDevices API fundamentals: getUserMedia and camera access
- 3. The Alpine component as a wrapper around the scanner library
- 4. Practical example: the complete scan flow
- 5. Native BarcodeDetector API versus a JS library as fallback
- 6. Permission handling: querying permission states
- 7. Fallback without camera access: manual input as an equal alternative
- 8. Performance: throttling the detection interval and stopping the video track
- 9. The HTTPS requirement and deployment pitfalls
- 10. Summary
- 11. FAQ
1. Use case: product search by barcode in the store
In brick-and-mortar retail or during warehouse inventory, a barcode scanner has been standard equipment for decades, often as a separate handheld device. For mobile users of an online store or an internal inventory system, such an extra device is rarely available, while practically every smartphone already carries a camera that can handle the exact same task, with no native app and no app store install required.
The practical use case in this article is a product search: the user opens the camera right inside the browser, holds a package's barcode up to the lens, and the recognized number automatically triggers a product search against the store backend. The same component works unchanged for QR codes too, for example to open a coupon code or a product page from a printed QR code directly.
2. MediaDevices API fundamentals: getUserMedia and camera access
Access to the device camera goes through navigator.mediaDevices.getUserMedia(), a method that returns a promise and, on success, resolves with a MediaStream. That stream then gets assigned as srcObject to a video element, which renders the live camera feed in the browser, without any single frame ever actually being stored anywhere.
For barcode scanning, the rear camera is almost always the more sensible choice, which is why the facingMode constraint can be set to environment, instead of the front camera that often gets picked by default. It is also worth specifying a minimum resolution through the width and height constraints, since too low a resolution noticeably hurts the detection rate for small or damaged barcodes.
async function requestCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
return stream;
}
3. The Alpine component as a wrapper around the scanner library
The actual detection logic, whether native through BarcodeDetector or through an external library, stays deliberately separate from the Alpine component. The component itself only manages reactive state: whether the camera is active, which code was most recently detected, and whether a permission error is currently present. That separation makes it possible to swap out the detection logic later without touching the markup or the component structure.
Inside x-init, the video stream gets requested and assigned to the reference element as soon as the component mounts into the DOM. The actual detection loop runs independently through requestAnimationFrame, checking the current video frame at regular intervals and writing a match directly into the component's reactive lastCode property.
4. Practical example: the complete scan flow
The example below combines camera access, detection, and product search inside a single component. As soon as a barcode gets detected, the component automatically stops the camera stream to save battery and processing power, then triggers the actual product search against the backend through fetch. The user gets immediate feedback that way, without having to press an extra button themselves.
Error handling around getUserMedia() matters here: if the user denies camera access, or no camera exists at all, the call throws an exception that gets caught in the catch block, switching the component into the fallback mode described further below instead of blocking the whole page with an unhandled error.
<div
x-data="{
stream: null,
lastCode: null,
permissionDenied: false,
async start() {
try {
this.stream = await requestCamera();
this.$refs.video.srcObject = this.stream;
this.detectLoop();
} catch (e) {
this.permissionDenied = true;
}
},
async detectLoop() {
const detector = new BarcodeDetector({ formats: ['ean_13', 'qr_code'] });
const tick = async () => {
if (!this.stream) return;
const codes = await detector.detect(this.$refs.video);
if (codes.length > 0) {
this.lastCode = codes[0].rawValue;
this.stopCamera();
this.searchProduct(this.lastCode);
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
},
stopCamera() {
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
},
async searchProduct(code) {
const response = await fetch(`/rest/V1/products/search-by-barcode/${code}`);
this.$dispatch('product-found', await response.json());
},
}"
x-init="start()"
>
<video x-ref="video" autoplay playsinline muted class="w-full rounded"></video>
<p x-show="permissionDenied" class="text-red-600">Camera access unavailable.</p>
</div>
5. Native BarcodeDetector API versus a JS library as fallback
The native BarcodeDetector API recognizes barcodes and QR codes right inside the browser, with no extra JavaScript bundle, and runs efficiently because the actual image recognition happens off the main thread. The catch is browser support: Chromium-based browsers support the API reliably, while Safari and Firefox have no native implementation so far, which means relying purely on BarcodeDetector is not a fit for every audience.
For browsers without native support, a JS library like ZXing-js steps in as a fallback, solving the same detection task purely in JavaScript, though at noticeably higher computational cost and with an extra bundle of several hundred kilobytes. In practice, the component checks on startup whether window.BarcodeDetector exists, and only dynamically imports the library when the native API is missing.
6. Permission handling: querying permission states
Alongside directly attempting to call getUserMedia(), the current permission status can be queried in advance through the Permissions API, provided the browser supports it. navigator.permissions.query({ name: 'camera' }) returns one of three states: prompt, granted, or denied, letting the interface show a suitable message even before the user actually clicks the scan button.
A clear, helpful error message is particularly worthwhile for the denied state, because the browser will not automatically ask again once permission has already been refused. The user has to reset the permission manually in the browser's site settings, so the component should explicitly explain at that point exactly where that setting can be found, instead of showing only a generic error message.
7. Fallback without camera access: manual input as an equal alternative
A barcode scanner must never be the only way to find a product, because camera access can fail for numerous reasons: missing hardware, denied permission, an insecure context without HTTPS, or simply a browser without support. Right next to the scan button, there should therefore always be a simple text input where the barcode number can be typed in manually.
This input field ideally reuses the same searchProduct() method as a successful scan, so both paths in the code converge on the same endpoint and the same error handling. That way the application stays fully usable even when scan mode is not available for any reason at all, without forcing the user to abandon the process entirely.
8. Performance: throttling the detection interval and stopping the video track
A detection loop that tries to recognize a code on every single requestAnimationFrame call, meaning up to sixty times a second, puts unnecessary strain on mobile devices and noticeably shortens battery life. In practice, throttling detection to roughly four to eight attempts per second is entirely sufficient, since a barcode stays visible in frame for several consecutive frames anyway.
It is equally important to consistently stop the camera stream through getTracks().forEach(track => track.stop()) as soon as a code gets detected or the component gets removed from the DOM. Skip that step, and the device's camera indicator light stays visibly on even though the component has long since disappeared, which reasonably makes users suspicious.
9. The HTTPS requirement and deployment pitfalls
Browsers only grant camera access in a secure context, meaning over HTTPS or on localhost during local development. If the scanner component accidentally gets tested on a staging environment without a valid TLS certificate, getUserMedia() fails even when the user would actually be willing to grant permission, because the browser does not even show the dialog in that case.
Another pitfall involves embedded contexts like an iframe inside a cross-origin widget: without a matching allow="camera" attribute on the iframe element, camera access stays blocked even over HTTPS. Anyone shipping the scanner component in an embedded context should therefore explicitly grant that permission through the surrounding document's permissions policy.
| Approach | How it works | Browser support | Weakness |
|---|---|---|---|
| Native BarcodeDetector API | Detection right in the browser, no extra bundle | Chromium-based browsers, no native Safari/Firefox | No broad support, a fallback is mandatory |
| ZXing-js library | Pure JS implementation of the detection logic | Practically all modern browsers | Higher computational cost, extra bundle weight |
| QuaggaJS | Specialized for classic 1D barcodes | Broad browser support through canvas processing | No active development, limited QR code support |
| Manual input | The barcode number is typed on the keyboard | Universal, independent of camera and browser | Slower and more error-prone than an actual scan |
| Photo upload as fallback | A single image gets uploaded and evaluated server-side | Works even without live camera access in the browser | Extra server load, no instant feedback in the UI |
Mironsoft
Alpine.js interactivity for Hyvä frontends
A Hyvä frontend that needs more interactivity, but without React overhead?
We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.
Custom Components
Develop interactive Alpine.js components for specific shop requirements.
Performance Review
Review existing Alpine.js implementations for reactivity pitfalls and performance.
Team Training
Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.
10. Summary
Barcode Scanner in Alpine.js: Key Takeaways
Camera through the MediaDevices API
getUserMedia returns a live stream assigned as srcObject to a video element, with facingMode environment for the rear camera.
Native API with a library fallback
BarcodeDetector runs fast with no bundle, but only gets replaced by a JS library when native browser support is missing.
Handle permissions cleanly
The Permissions API returns the current status in advance, and a denied permission requires a clear, helpful error message.
Manual input as an equal alternative
A text input next to the scan button keeps the application fully usable even without working camera access.