useBarcodeDetector
useBarcodeDetector
Reactive wrapper around the Barcode Detection API.
Accepts any source BarcodeDetector#detect understands: HTMLImageElement, SVGImageElement, HTMLVideoElement, HTMLCanvasElement, ImageBitmap, OffscreenCanvas, VideoFrame, Blob, ImageData.
For an HTMLVideoElement source, manages a getUserMedia stream and runs detection on each animation frame. For any other source, runs detection whenever the source ref changes (with immediate: true) or on demand via detect().
The composable defaults to
immediate: false. Browsers (notably Safari/iOS) require a user gesture forgetUserMediaandvideo.play(), so callstart()from a click handler. For non-video sources you can opt back into auto-detect withimmediate: true.
Usage
Demo
Point a camera at any QR code or supported barcode. Detected polygons are drawn over the live feed and listed below.
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { BarcodeDetectorOverlay, useBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
const video = useTemplateRef<HTMLVideoElement>('video')
const { isSupported, supportedFormats, detected, error, isActive, start, stop } =
useBarcodeDetector(video)
</script>
<template>
<p v-if="!isSupported"><code>BarcodeDetector</code> is not available.</p>
<p v-if="error">{{ error.message }}</p>
<div class="stage">
<video ref="video" playsinline muted />
<BarcodeDetectorOverlay :detected="detected" :source="video" />
</div>
<button @click="isActive ? stop() : start()">
{{ isActive ? 'Stop' : 'Start camera' }}
</button>
<ul>
<li v-for="(b, i) in detected" :key="i">
<strong>{{ b.format }}</strong> — <code>{{ b.rawValue }}</code>
</li>
</ul>
</template>
Prefer the <UseBarcodeDetector /> component below if you want the same thing in one tag, with the overlay built in.
Live camera
start() must be called from a user gesture (e.g. a button click) so Safari/iOS will accept the getUserMedia + video.play() calls.
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
const video = useTemplateRef<HTMLVideoElement>('video')
const { isSupported, detected, error, isActive, start, stop } = useBarcodeDetector(video)
</script>
<template>
<video ref="video" playsinline muted />
<button @click="isActive ? stop() : start()">
{{ isActive ? 'Stop' : 'Start camera' }}
</button>
</template>
Static image
Image / blob / canvas sources have no autoplay concerns, so opt back into auto-detect with immediate: true to scan as soon as the source is ready.
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { useBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
const img = useTemplateRef<HTMLImageElement>('img')
const { detected } = useBarcodeDetector(img, { immediate: true })
</script>
<template>
<img ref="img" src="/barcode.png" alt="" />
</template>
Manual detection on a Blob
const { detect } = useBarcodeDetector()
const file = await fetch('/barcode.png').then((r) => r.blob())
const result = await detect(file)
Scan once
Stop the camera as soon as a barcode is found. Combine with accept to keep scanning until a matching barcode shows up. Call start() from a user gesture to begin (and again to re-arm after a stop).
// Stop on any barcode
const { detected, start } = useBarcodeDetector(video, { once: true })
// later, from a click handler:
await start()
// Stop only on QR codes
useBarcodeDetector(video, {
once: true,
accept: (b) => b.format === 'qr_code',
})
// Stop only on values starting with `XX-`
useBarcodeDetector(video, {
once: true,
accept: (b) => b.rawValue.startsWith('XX-'),
})
accept filters detected itself — non-matching barcodes never appear there, never trigger watchers, and never trigger once. detected keeps the result that triggered the stop. Call start() to scan again.
Reactive options
formats and once accept a ref or a getter, so you can flip them at runtime without re-mounting the composable. Useful for "scan only QR codes" toggles, multi-mode scanners, etc.
const onlyQr = ref(false)
const formats = computed<BarcodeFormat[] | undefined>(() =>
onlyQr.value ? ['qr_code'] : undefined,
)
useBarcodeDetector(video, {
formats, // rebuilds the BarcodeDetector when `onlyQr` flips
once: onlyQr, // stop on first hit *only* in QR-only mode
})
accept is intentionally not MaybeRefOrGetter — toValue can't tell a predicate from a getter. Capture refs in your closure instead:
const prefix = ref('')
useBarcodeDetector(video, {
accept: (b) => !prefix.value || b.rawValue.startsWith(prefix.value),
})
Options
| Name | Type | Default | Description |
|---|---|---|---|
formats | MaybeRefOrGetter<BarcodeFormat[] | undefined> | all formats supported by browser | Restrict detection to specific formats. Pass a ref or getter to switch formats at runtime — the underlying BarcodeDetector is rebuilt on change. |
immediate | boolean | false | Auto-start (camera for video sources, run detect() on change otherwise) once the source is available. Defaults to false because getUserMedia / video.play() need a user gesture in Safari/iOS. |
camera | boolean | MediaTrackConstraints | true | For video sources: true calls getUserMedia with rear camera; pass constraints to override; false skips camera setup so you can supply your own stream. |
once | MaybeRefOrGetter<boolean> | false | Stop after the first accepted detection. Pair with accept to stop only on matching barcodes. Reactive: read on each detection, so flipping the ref live is honored. Call start() to re-arm. |
accept | (b: DetectedBarcode) => boolean | undefined | Predicate gating which detections count. Non-matching barcodes are filtered out of detected and don't trigger once. Plain function — closures capture reactive deps for runtime behavior. |
window | Window | defaultWindow | Custom window reference (SSR / iframe). |
Returns
| Name | Type | Description |
|---|---|---|
isSupported | ComputedRef<boolean> | Whether BarcodeDetector exists on window. |
supportedFormats | ShallowRef<BarcodeFormat[]> | Formats reported by the browser once the detector is created. |
detected | ShallowRef<DetectedBarcode[]> | Latest detection result (filtered through accept if set). |
rejected | ShallowRef<DetectedBarcode[]> | Detections the accept predicate filtered out. Always empty otherwise. |
error | ShallowRef<Error | null> | Set when permission is denied or the API is unavailable. |
isActive | ShallowRef<boolean> | Whether the camera + detection loop is running (video sources only). |
detect | (source?: BarcodeImageSource | null) => Promise<DetectedBarcode[]> | Run a single detection. Falls back to the configured source ref. |
start | () => Promise<void> | Start the camera stream and detection loop. No-op for non-video sources. |
stop | () => void | Stop the loop and release the media stream. |
Component
<UseBarcodeDetector /> is an all-in-one wrapper around the composable. It renders a <video> element plus a default SVG overlay that draws polygons over detected barcodes — drop it in and you have a working scanner.
- The
overlayslot replaces only the default overlay. - The default slot is rendered as a sibling after the stage, with full composable state and actions exposed as slot props. Use it for results lists, error messages, controls, etc.
- The
headlessprop skips the built-in stage entirely; the default slot becomes the sole rendering and must wire up its own source element viasetSource.
Drop-in scanner
Use the default slot to wire start / stop to a button — autoplay is gated behind a user gesture so the component works on Safari/iOS.
<script setup lang="ts">
import { UseBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
</script>
<template>
<UseBarcodeDetector v-slot="{ start, stop, isActive, detected }">
<button @click="isActive ? stop() : start()">
{{ isActive ? 'Stop' : 'Start camera' }}
</button>
<ul>
<li v-for="b in detected" :key="b.rawValue">
<strong>{{ b.format }}</strong> — <code>{{ b.rawValue }}</code>
</li>
</ul>
</UseBarcodeDetector>
</template>
Custom UI alongside the scanner
<template>
<UseBarcodeDetector v-slot="{ detected, error, isSupported }">
<p v-if="!isSupported">BarcodeDetector is not available.</p>
<p v-else-if="error">{{ error.message }}</p>
<ul>
<li v-for="b in detected" :key="b.rawValue">
<strong>{{ b.format }}</strong> — <code>{{ b.rawValue }}</code>
</li>
</ul>
</UseBarcodeDetector>
</template>
Custom overlay only
The overlay slot receives the live source element — pass it to <BarcodeDetectorOverlay :source="source"> (or your own SVG) and the overlay tracks the visible source rect automatically.
<template>
<UseBarcodeDetector>
<template #overlay="{ detected, source }">
<BarcodeDetectorOverlay
:detected="detected"
:source="source"
stroke="hotpink"
:stroke-width="6"
/>
</template>
</UseBarcodeDetector>
</template>
Fully headless
headless skips the built-in <video> stage so the default slot does all the rendering. Bind any source BarcodeDetector#detect understands — <video>, <img>, <canvas> — through setSource. The demo below feeds a QR generated with useQRCode (from @vueuse/integrations) into an <img> and lets the wrapper drive detection — no camera required.
<script setup lang="ts">
import { ref, useTemplateRef, watch } from 'vue'
import {
type DetectedBarcode,
type UseBarcodeDetectorReturn,
} from '@orbisk/vue-use-barcode-detection'
import { useQRCode } from '@vueuse/integrations/useQRCode'
const text = ref('Hello, headless!')
const prefix = ref('')
const accept = (b: DetectedBarcode) => !prefix.value || b.rawValue.startsWith(prefix.value)
const qrSrc = useQRCode(text, { width: 320, margin: 4 })
// The wrapper exposes the composable's return — re-run detect() when only
// the predicate changes (the <img> ref is unchanged in that case, so the
// composable's source watcher wouldn't fire on its own).
const scanner = useTemplateRef<UseBarcodeDetectorReturn>('scanner')
watch(prefix, () => void scanner.value?.detect())
</script>
<template>
<UseBarcodeDetector
ref="scanner"
headless
immediate
:accept="accept"
v-slot="{ setSource, source, detect, detected, rejected }"
>
<input v-model="text" type="text" />
<input v-model="prefix" type="text" placeholder="required prefix (any)" />
<div class="stage">
<img :ref="setSource" :src="qrSrc" alt="QR" @load="detect()" />
<BarcodeDetectorOverlay
:detected="detected"
:rejected="rejected"
:source="source"
:label="(b, accepted) => (accepted ? b.rawValue : 'invalid')"
/>
</div>
</UseBarcodeDetector>
</template>
For a live-camera headless setup, bind a <video> instead and wire start / stop / isActive from the slot to a button — the same wrapper without immediate.
<template>
<UseBarcodeDetector headless v-slot="{ setSource, detected, start, stop, isActive }">
<video :ref="setSource" playsinline muted autoplay />
<button @click="isActive ? stop() : start()">
{{ isActive ? 'Stop' : 'Start' }}
</button>
<pre>{{ detected }}</pre>
</UseBarcodeDetector>
</template>
Component props
| Name | Type | Default | Description |
|---|---|---|---|
formats | BarcodeFormat[] | all | Restrict detection to specific formats. |
immediate | boolean | false | Auto-start the camera + detection loop once the video element is mounted. Defaults to false because getUserMedia / video.play() need a user gesture in Safari/iOS. |
camera | boolean | MediaTrackConstraints | true | true calls getUserMedia with rear camera; pass constraints to override; false skips. |
once | boolean | false | Stop after the first accepted detection. Combine with accept to stop only on matching barcodes. |
accept | (b: DetectedBarcode) => boolean | undefined | Predicate gating which detections count. Non-matching barcodes are filtered out of detected. |
headless | boolean | false | Skip the built-in stage; the default slot is the only rendering and must bind its own video. |
Default slot props
| Slot prop | Type |
|---|---|
isSupported | boolean |
supportedFormats | BarcodeFormat[] |
detected | DetectedBarcode[] |
rejected | DetectedBarcode[] — barcodes the accept predicate filtered out |
error | Error | null |
isActive | boolean |
detect | (source?: BarcodeImageSource | null) => Promise<DetectedBarcode[]> |
start | () => Promise<void> |
stop | () => void |
source | HTMLElement | null |
setSource | (el: Element | null) => void — bind via :ref="setSource" |
overlay slot props
| Slot prop | Type | Description |
|---|---|---|
detected | DetectedBarcode[] | Latest detection result (filtered through accept if set). |
rejected | DetectedBarcode[] | Barcodes the accept predicate filtered out. |
source | HTMLElement | null | The internal source element (the <video> from the built-in stage). Pass to <BarcodeDetectorOverlay :source="source"> to keep the auto-tracked alignment. |
BarcodeDetectorOverlay
The default overlay is also exported as a standalone component. Drop it into a custom overlay slot (or anywhere over a <video> / <img>) when you want to keep the default look but add elements alongside it.
The demo above generates a QR with useQRCode from @vueuse/integrations (you'll need to install @vueuse/integrations and qrcode for this), runs useBarcodeDetector on the resulting <img>, and overlays the detected polygons. Type a required prefix to flip the polygon between accepted (green) and rejected (red) colors live.
<script setup lang="ts">
import { ref, useTemplateRef, watch } from 'vue'
import {
BarcodeDetectorOverlay,
type DetectedBarcode,
useBarcodeDetector,
} from '@orbisk/vue-use-barcode-detection'
import { useQRCode } from '@vueuse/integrations/useQRCode'
const text = ref('Hello, scanner!')
const prefix = ref('')
const accept = (b: DetectedBarcode) =>
!prefix.value || b.rawValue.startsWith(prefix.value)
// Fixed-size QR keeps the viewBox reactive-safe: img.naturalWidth is a
// non-reactive DOM property, so a computed reading it would never refresh
// once the image loads, leaving the SVG with a 0×0 coordinate space.
// `margin: 4` keeps the QR-spec quiet zone — Chrome's BarcodeDetector
// frequently refuses to lock on QRs with a smaller margin.
const qrSrc = useQRCode(text, { width: 320, margin: 4 })
const img = useTemplateRef<HTMLImageElement>('img')
const { detect, detected, rejected } = useBarcodeDetector(img, { accept })
watch([qrSrc, prefix], () => {
if (img.value?.complete) void detect()
})
</script>
<template>
<input v-model="text" />
<input v-model="prefix" placeholder="required prefix" />
<div class="stage">
<!-- No `crossorigin` — data: URLs are same-origin; setting it can taint
the canvas BarcodeDetector reads from. -->
<img ref="img" :src="qrSrc" @load="detect" />
<BarcodeDetectorOverlay
:detected="detected"
:rejected="rejected"
:source="img"
:label="(b, accepted) => (accepted ? b.rawValue : 'invalid')"
/>
</div>
</template>
<script setup lang="ts">
import { BarcodeDetectorOverlay, UseBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
</script>
<template>
<UseBarcodeDetector>
<template #overlay="{ detected, source }">
<BarcodeDetectorOverlay :detected="detected" :source="source" stroke="hotpink" />
<span class="badge">{{ detected.length }} found</span>
</template>
</UseBarcodeDetector>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
detected | DetectedBarcode[] | required | Accepted barcodes — drawn with the primary fill/stroke. |
rejected | DetectedBarcode[] | [] | Rejected barcodes — drawn with the secondary fill/stroke. |
source | HTMLElement | null | null | The <video> / <img> / <canvas> the overlay is drawn over. The overlay tracks its visible rect (mirroring object-fit + object-position) and aligns the SVG to it. |
viewBox | string | auto | SVG viewBox. Auto-derived from source's intrinsic size; pass to override (e.g. for off-screen detection sources). |
fill | string | 'rgba(0, 200, 120, 0.15)' | Fill for accepted polygons. |
stroke | string | 'rgb(0, 200, 120)' | Stroke for accepted polygons. |
rejectedFill | string | 'rgba(220, 60, 60, 0.12)' | Fill for rejected polygons. |
rejectedStroke | string | 'rgb(220, 60, 60)' | Stroke for rejected polygons. |
strokeWidth | number | 4 | Stroke width (in viewBox units, but rendered with non-scaling-stroke). |
label | (b, accepted) => string | null | undefined | undefined | Render a label inside each polygon. Receives the barcode and whether it was accepted; return falsy to skip that detection's label. Long strings are auto-truncated with … to fit the polygon width; very narrow polygons get no label. |
labelColor | string | '#fff' | Label text fill color. |
labelFontSize | number | 24 | Label font size in viewBox units. The polygon stroke is reused as the text outline so labels stay legible on busy backgrounds. Lower this if your barcodes are small relative to the source — auto-truncate is keyed off this size. |
Highlight accepted vs. rejected barcodes
Combine the accept predicate with the dual-color overlay to give users instant feedback on why a scan was ignored — e.g. "yes I see this code, but it's not a QR code so I'm waiting for one." <UseBarcodeDetector> wires this up automatically: the default overlay receives both detected (accepted) and rejected lists, and renders them in green / red respectively.
<script setup lang="ts">
import type { DetectedBarcode } from '@orbisk/vue-use-barcode-detection'
import { UseBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
// Only QR codes count — everything else gets the rejected styling.
const onlyQr = (b: DetectedBarcode) => b.format === 'qr_code'
</script>
<template>
<UseBarcodeDetector
once
:accept="onlyQr"
v-slot="{ detected, rejected, isActive, start, stop }"
>
<button @click="isActive ? stop() : start()">
{{ isActive ? 'Stop' : 'Start camera' }}
</button>
<p v-if="rejected.length && !detected.length">
Found {{ rejected.length }} barcode(s), but none are QR codes — keep moving the camera.
</p>
<ul>
<li v-for="b in detected" :key="b.rawValue">
<strong>{{ b.format }}</strong> — <code>{{ b.rawValue }}</code>
</li>
</ul>
</UseBarcodeDetector>
</template>
If you're using the composable directly, pull rejected from the return and pass it to <BarcodeDetectorOverlay> yourself:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import {
BarcodeDetectorOverlay,
type DetectedBarcode,
useBarcodeDetector,
} from '@orbisk/vue-use-barcode-detection'
const video = useTemplateRef<HTMLVideoElement>('video')
const { detected, rejected, start, isActive } = useBarcodeDetector(video, {
accept: (b: DetectedBarcode) => b.format === 'qr_code',
once: true,
})
</script>
<template>
<div class="stage">
<video ref="video" playsinline muted />
<BarcodeDetectorOverlay :detected="detected" :rejected="rejected" :source="video" />
</div>
<button v-if="!isActive" @click="start">Start camera</button>
</template>
Want different colors? Pass fill / stroke / rejectedFill / rejectedStroke directly:
<BarcodeDetectorOverlay
:detected="detected"
:rejected="rejected"
:source="video"
stroke="lime"
rejected-stroke="orange"
rejected-fill="rgba(255, 165, 0, 0.1)"
/>
SSR / hydration
Both the composable and the component are SSR-safe — they don't touch window, navigator, or any browser-only API during setup. All side effects (creating the detector, requesting the camera, starting the RAF loop) are deferred to onMounted, so server-side rendering produces only the static <video> element with no overlay.
To prevent hydration mismatches in slot-rendered UI, the isSupported slot prop is gated on useMounted: it stays false during SSR and during the client's first render, then flips to its real value once the component mounts. This means a template like <p v-if="!isSupported">unsupported</p> won't trigger a hydration warning.
If you want to skip server rendering entirely (e.g. in Nuxt), wrap the component in <ClientOnly>:
<template>
<ClientOnly>
<UseBarcodeDetector />
</ClientOnly>
</template>
Exposed instance
The component exposes the entire return of useBarcodeDetector on its instance — so you can grab a template ref and call start(), stop(), etc. from outside.
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { UseBarcodeDetector } from '@orbisk/vue-use-barcode-detection'
const scanner = useTemplateRef<InstanceType<typeof UseBarcodeDetector>>('scanner')
function rescan() {
scanner.value?.stop()
scanner.value?.start()
}
</script>
<template>
<UseBarcodeDetector ref="scanner" />
<button @click="rescan">Restart</button>
</template>