expo-roi-barcode-scanner
v0.4.2
Published
Native Expo barcode scanner that only reports barcodes inside a region of interest
Maintainers
Readme
expo-roi-barcode-scanner
A native Expo (SDK 55–56) barcode scanner that only accepts a barcode whose center is inside a region of interest (ROI). iOS uses AVFoundation, Android uses CameraX + ML Kit; both scan the full frame and apply the ROI gate natively before an event reaches React Native.
Ships two things:
<BarcodeScanner>— batteries‑included: camera + dimmed ROI overlay + manual shutter + click/haptics + responsive scanned-part cards and a quantity list by default. Continuous detection and single-card mode remain available. Fully customizable; feedback can be turned off.<RoiBarcodeScanner>— the low‑level headless native view, if you want to build your own UI.
This is a native module: it works in development and production builds, not Expo Go.
What's new in 0.4.2
- Connect the scanner directly to an existing i18n catalogue with the new dependency-free
translate(key, params)prop. It works with react-i18next, i18next, React Intl, Lingui, and custom translation systems. languagenow accepts the locale string returned by an app's i18n instance, including unsupported locales. Built-in translations are used when available and English remains the safe fallback.- Stable translation-key and parameter types are exported, and missing, empty, or throwing host translations fall back to package text.
Still current from 0.4.1:
- Manual shutter presses now visibly progress through ready → searching → failed. The shutter remains its configured color; if no barcode is accepted within 750 ms, a red localized “try again” overlay appears inside the ROI, matching the placement of successful-scan feedback.
- Failed attempts play a distinct bundled descending-tone sound and stronger error haptic by default. Both are configurable or independently disableable.
- The new
languageprop localizes all built-in visible and accessibility labels. English is the default; 15 languages and regional tags such assv-SEare supported.
Still current from 0.4.0:
- The standard
<BarcodeScanner>experience is now manual batch scanning. A plain install renders the shutter button, accumulating scanned-part cards, the scanned-count badge, swipe/✕ removal, and the full quantity list. SetscanMode="continuous"and/ormultiScan={false}to opt into the previous behaviors. - Floating scanned-part cards now scale responsively so they stay clear of the ROI on smaller Android devices, with a modest iOS size adjustment for platform-appropriate readability. Full-list rows remain full size.
- Android's CameraX preview is explicitly remeasured under React Native Fabric so its internal preview surface does not remain black or zero-sized.
- Expo SDK 55 is the development and test baseline (React Native 0.83 / React 19.2); SDK 56 remains supported by the peer range.
- Includes the iOS permission/lifecycle recovery introduced while developing 0.3.2.
Recent 0.3.1 highlights, still current:
- Verified support for Expo SDK 55 and 56.
- Android camera startup retries safely after a permission dialog, lifecycle race, or transient CameraX failure, and failed controllers are fully reset.
- Android
onCameraReadyfires only when the preview is streaming; camera and torch failures provide actionableonErrorcodes. - Updated Android to CameraX 1.6.1 and removed the previous
ListenableFuturedependency workaround. - No Gesture Handler/Reanimated/Worklets requirement — single scan, multi-scan, swipe-to-remove, and animations bundle using React Native core APIs.
Because these releases change native Android and iOS code, upgrading requires a new development or production build; a Metro restart or OTA update is not enough. See the full changelog.
Requirements
- Expo SDK 55 or 56 (React Native 0.83–0.85, React 19.2) on the New Architecture — the default on these SDKs.
- A development build (custom dev client) or a production build — it ships native code, so it does not run in Expo Go.
- iOS 15.1+ and Android 7.0+ (API 24).
- Batch scanning, swipe-to-remove, and animations use React Native core and need no extra gesture or animation packages.
Install (SDK 55 or 56)
npm install expo-roi-barcode-scanner
npx expo install expo-camera expo-asset expo-haptics expo-audio
npx expo prebuild # links the native module via autolinking
npx expo run:ios # or: npx expo run:androidexpo-camerais used only to request camera permission.expo-hapticsandexpo-audiopower the built‑in sound/haptics;expo-assetletsexpo-audioload the bundled click. Letexpo installchoose all three for your SDK so npm cannot pull a newer SDK'sexpo-assetthrough the audio peer dependency. They're optional if you only use the low-level native view.
Add the iOS permission copy to app.json (Android's CAMERA permission is contributed automatically):
{ "expo": { "ios": { "infoPlist": { "NSCameraUsageDescription": "Scan barcodes" } } } }Not publishing to npm? Install from a tarball (
npm pack→npm install /path/to/expo-roi-barcode-scanner-0.4.2.tgz) or git (npm install github:<owner>/expo-roi-barcode-scanner).
Quick start
import { SafeAreaView, StyleSheet, Text, View } from 'react-native';
import { useCameraPermissions } from 'expo-camera';
import { BarcodeScanner } from 'expo-roi-barcode-scanner';
export default function ScanScreen() {
const [permission, requestPermission] = useCameraPermissions();
if (!permission) return <View style={{ flex: 1, backgroundColor: 'black' }} />;
if (!permission.granted) {
return (
<SafeAreaView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text onPress={requestPermission}>Allow camera</Text>
</SafeAreaView>
);
}
return (
<BarcodeScanner
style={StyleSheet.absoluteFill}
onScan={(r) => console.log(r.type, r.data)}
/>
);
}That's the entire UI. By default it shows the dimmed ROI overlay, manual shutter, click/haptic feedback, responsive recent-scan cards, count badge, swipe/✕ removal, and the full quantity list. Press the shutter once for each item you want to add.
To restore immediate continuous detection, set scanMode="continuous". To replace the accumulating session with a single confirmation card, set multiScan={false}.
<BarcodeScanner> props
| Prop | Type | Default | Description |
|---|---|---|---|
| onScan | (result) => void | — | Called on each accepted scan. result is { data, type, bounds, id, at } — id is stable and unique per scan. |
| language | string | 'en' | Current app locale, such as i18n.resolvedLanguage. Included languages use built-in text; unknown locales fall back to English. |
| translate | (key, params?) => string \| undefined | — | Supply scanner strings from the host app's i18n catalogue. Return empty/undefined to use the package fallback. |
| regionOfInterest | { x, y, width, height } | full-width centered strip | Normalized scan window (0–1). |
| barcodeTypes | BarcodeType[] | common 1D + qr | Formats to detect. |
| cooldownMs | number | 2000 | Ignore repeats of the same value for this long. |
| scanMode | 'continuous' \| 'manual' | 'manual' | Accept immediately, or only after the built-in shutter is pressed. Works in both single and multi-scan mode. |
| shutterButtonStyle / shutterButtonColor | style / string | — / '#fff' | Theme the built-in manual shutter. |
| shutterAccessibilityLabel | string | 'Scan barcode' | Accessibility label for the built-in shutter. |
| renderShutterButton | ({ onPress, disabled, status }) => ReactNode | — | Replace the built-in shutter; call onPress from your custom control. status is idle, searching, or failed. |
| paused | boolean | false | Pause scan results while keeping the live camera preview visible. |
| torch | boolean | false | Toggle the flashlight. |
| Feedback | | | |
| sound | boolean \| AudioSource | true | true = built‑in click; pass require('./beep.mp3'), a URI, or { uri, headers } for a custom sound; false = silent. |
| soundVolume | number | 1 | Scan-sound volume from 0 to 1. |
| haptics | boolean \| HapticsConfig \| callback | true | true = success feedback; configure notification, impact, or selection feedback; pass a callback for a custom pattern; false disables. |
| failureSound | boolean \| AudioSource | true | Sound played when a manual scan times out; accepts the same custom sources as sound, or false to disable. |
| failureSoundVolume | number | 1 | Failure-sound volume from 0 to 1. |
| failureHaptics | boolean \| HapticsConfig \| callback | true | true = stronger error-notification haptic on timeout; configure, replace, or disable it independently. |
| Overlay | | | |
| overlayColor | string | '#000' | Color of the dimmed area. |
| overlayOpacity | number | 0.85 | Overlay opacity, 0–1. |
| stripBorderColor / stripBorderWidth / stripBorderRadius | string / number / number | none / 0 / 6 | Optional border around the strip. |
| Flash | | | |
| showFlash / flashColor | boolean / string | true / '#45f58a' | In‑strip confirmation flash. |
| Confirmation card | | | |
| showConfirmation | boolean | true | Show the bottom card. |
| showBarcodeType | boolean | true | Show the detected barcode format below the scanned value. |
| confirmationLabel | string | '✓ Scanned' | Header text. |
| confirmationIcon | ReactNode | — | Replaces the header label (e.g. a custom icon). |
| confirmationStyle / confirmationLabelStyle / confirmationCodeStyle / confirmationTypeStyle | style | — | Theme card container / label / code / type text (colors, fonts, etc.). |
| renderConfirmation | (result) => ReactNode | — | Full control — render your own card. |
| Batch scanning | | | (see Batch scanning) |
| multiScan | boolean | true | Use an accumulating session: dismissible recent cards + a count badge that opens a full list. Set false for the single confirmation card. |
| onRemoveScan | (item) => void | — | A scan was removed (✕ / swipe‑left / list). Undo whatever onScan did for item.id. |
| onSessionChange | (items) => void | — | The whole session list changed. Convenience mirror. |
| maxRecentChips | number | 3 | How many recent scans show as chips above the badge. |
| sessionTitle | string | 'Scanned' | Title of the full‑list sheet. |
| chipStyle / chipCodeStyle / chipTypeStyle | style | — | Theme the chip / list‑row container and its text. |
| removeButtonColor | string | '#ff6b6b' | Color of the circular ✕ remove button. |
| badgeStyle / badgeTextStyle / sheetStyle | style | — | Theme the count badge and the list sheet. |
| renderScanItem | (item, remove) => ReactNode | — | Full control — render your own chip / list row. |
| style | ViewStyle | — | Container style. |
| children | ReactNode | — | Extra overlay content (e.g. a custom header). |
| onCameraReady / onError | () => void / (e) => void | — | Lifecycle events. |
Manual shutter scanning
Manual mode is the default and uses the same code path for the single confirmation card and the multi-scan session. Pressing the shutter opens a 750 ms detection window and changes the status text while leaving the shutter color untouched. The next barcode inside the ROI is accepted and the scanner disarms. If none is accepted, a red localized retry overlay appears inside the ROI, a distinct descending-tone failure sound plays, and a stronger error haptic is requested. A deliberate manual press bypasses cooldownMs, so pressing twice on the same product adds two entries in multi-scan mode.
// Default: manual shutter + accumulating scanned-part session.
<BarcodeScanner
onScan={(item) => addToOrder(item)}
onRemoveScan={(item) => removeFromOrder(item.id)}
/>
// Opt into a single confirmation card while keeping the manual shutter.
<BarcodeScanner
multiScan={false}
onScan={(item) => submit(item)}
/>The built-in circular shutter is positioned at the bottom and the confirmation/session UI moves above it. To provide a different button, use renderShutterButton:
<BarcodeScanner
onScan={handleScan}
renderShutterButton={({ onPress, disabled, status }) => (
<Pressable disabled={disabled} onPress={onPress} style={styles.scanButton}>
<Text>{status === 'searching' ? 'Searching…' : status === 'failed' ? 'Try again' : 'Scan now'}</Text>
</Pressable>
)}
/>Do not toggle paused to implement a shutter. paused is for temporarily suppressing results while leaving the preview running; scanMode="manual" owns the press-to-scan behavior.
Language
Pass a supported language or regional locale to translate the built-in shutter states, confirmation label, session title, count badge, Done action, and accessibility labels. Explicit props such as confirmationLabel, sessionTitle, and shutterAccessibilityLabel still override their translated defaults.
<BarcodeScanner language="sv-SE" onScan={handleScan} />
<BarcodeScanner language="de" onScan={handleScan} />Connect an existing i18n library
The package does not depend on a particular i18n implementation. Pass the app's current locale to language and adapt its translation function through translate. Changing the app language changes the scanner language on the next render.
With react-i18next:
import { useTranslation } from 'react-i18next';
import { BarcodeScanner } from 'expo-roi-barcode-scanner';
function ScannerScreen() {
const { t, i18n } = useTranslation();
return (
<BarcodeScanner
language={i18n.resolvedLanguage}
translate={(key, params) => t(`barcodeScanner.${key}`, params)}
onScan={handleScan}
/>
);
}Example i18next catalogue:
{
"barcodeScanner": {
"tapToScan": "Tap to scan",
"lookingForBarcode": "Looking for barcode…",
"noBarcodeFound": "No barcode found — try again",
"scanBarcode": "Scan barcode",
"scannedConfirmation": "✓ Scanned",
"sessionTitle": "Scanned",
"scannedCount": "{{count}} scanned",
"openScannedList": "{{count}} scanned, open list",
"removeBarcode": "Remove {{value}}",
"done": "Done"
}
}The stable keys are tapToScan, lookingForBarcode, noBarcodeFound, scanBarcode, scannedConfirmation, sessionTitle, scannedCount, openScannedList, removeBarcode, and done. Count labels receive { count }; the remove label receives { value }. Return undefined or an empty string for a missing key to use the selected built-in translation and ultimately English.
Theming examples
// Dark, silent, no haptics, custom colors and font
<BarcodeScanner
onScan={handleScan}
multiScan={false}
sound={false}
haptics={false}
showBarcodeType={false}
overlayColor="#0b1020"
overlayOpacity={0.7}
flashColor="#ffd166"
confirmationStyle={{ backgroundColor: '#111827', borderRadius: 24 }}
confirmationCodeStyle={{ color: '#ffd166', fontFamily: 'Menlo', fontSize: 28 }}
confirmationLabel="Part scanned"
/>
// Custom sound + haptic feel + fully custom confirmation content
<BarcodeScanner
onScan={handleScan}
multiScan={false}
sound={require('./assets/beep.mp3')}
soundVolume={0.65}
haptics={{ type: 'impact', style: 'heavy' }}
renderConfirmation={(r) => (
<MyCard icon={<CheckIcon />} title="Added to order" code={r.data} />
)}
/>Custom sound and haptics
sound accepts the same source shapes as expo-audio, so it can use a bundled asset, local/remote URI, or a source object with headers:
<BarcodeScanner
onScan={handleScan}
sound={{
uri: 'https://example.com/sounds/scanned.mp3',
headers: { Authorization: `Bearer ${token}` },
}}
soundVolume={0.5}
/>Choose notification, impact, or selection haptics with an object. Notification styles are success, warning, and error; impact styles are light, medium, heavy, soft, and rigid.
<BarcodeScanner onScan={handleScan} haptics={{ type: 'notification', style: 'warning' }} />
<BarcodeScanner onScan={handleScan} haptics={{ type: 'impact', style: 'rigid' }} />
<BarcodeScanner onScan={handleScan} haptics={{ type: 'selection' }} />Failure feedback is independent from successful-scan feedback:
<BarcodeScanner
onScan={handleScan}
failureSound={require('./assets/not-found.wav')}
failureSoundVolume={0.7}
failureHaptics={{ type: 'impact', style: 'heavy' }}
/>
<BarcodeScanner onScan={handleScan} failureSound={false} failureHaptics={false} />For a custom haptic pattern or another haptics library, pass a callback. It receives the accepted scan:
<BarcodeScanner
onScan={handleScan}
haptics={async (scan) => {
await playMyHapticPattern(scan.type);
}}
/>Batch scanning (multi-scan session)
By default, scanning many items produces an accumulating session instead of one throwaway card:
- Each accepted scan appears in a responsive dismissible card stack outside the ROI (the last
maxRecentChips). - A count badge ("21 scanned") opens a full list sheet of everything scanned this session.
- Remove a mistake by tapping the circular ✕ on a chip/row, or swiping it left. Removing a code also clears its cooldown so you can immediately re‑scan it correctly.
Because onScan has already fired by the time the user removes something, wire up onRemoveScan to undo it. Every scan carries a stable id, so the same barcode can appear twice and still be removed individually.
import { BarcodeScanner, type ScannedItem } from 'expo-roi-barcode-scanner';
export default function ScanScreen() {
const [order, setOrder] = useState<ScannedItem[]>([]);
return (
<BarcodeScanner
style={StyleSheet.absoluteFill}
sessionTitle="Order"
barcodeTypes={['ean13', 'upcA', 'code128', 'qr']}
onScan={(item) => setOrder((prev) => [...prev, item])}
onRemoveScan={(item) => setOrder((prev) => prev.filter((it) => it.id !== item.id))}
/>
);
}- No root wrapper, Babel plugin, Worklets install, or animation peer is required.
- The component keeps its own visible list;
onScan/onRemoveScankeep your data in sync.
What it feels like
The session uses React Native's built-in Animated and PanResponder APIs:
- A new scan sweeps in from the top‑right and settles into the top slot; the chips already there shift up to make room.
- Removing a chip (✕ or swipe‑left) slides the remaining ones straight up to close the gap — quick, no bounce — and never animates a revealed chip in from the side.
- The count badge glides to its new position as the stack grows and shrinks.
Customizing the session
| Prop | What it does |
|---|---|
| maxRecentChips | How many recent chips show above the badge (default 3). |
| sessionTitle | Heading of the full‑list sheet (default 'Scanned'). |
| chipStyle / chipCodeStyle / chipTypeStyle | Style the chip / list‑row container and its text. |
| removeButtonColor | Color of the circular ✕ button. |
| badgeStyle / badgeTextStyle | Style the count badge. |
| sheetStyle | Style the full‑list sheet container. |
| renderScanItem | (item, remove) => ReactNode — render a fully custom chip / row. Call remove() to delete it (still fires onRemoveScan). |
| onSessionChange | (items) => void — fires with the whole list on every add/remove; handy to mirror it verbatim. |
Counting quantities
onScan fires once per physical scan, so scanning the same barcode three times gives three entries — each with its own id. Group by data to turn the raw scans into parts with quantities:
const parts = useMemo(() => {
const byCode = new Map<string, { data: string; type: string; quantity: number }>();
for (const item of order) {
const existing = byCode.get(item.data);
if (existing) existing.quantity += 1;
else byCode.set(item.data, { data: item.data, type: item.type, quantity: 1 });
}
return [...byCode.values()];
}, [order]);Remove one entry (✕ / swipe) and that part's quantity drops by one; remove the last and the part disappears. The example/ app implements exactly this — a home screen with an order list and per‑part quantities, plus the scanner screen.
Low‑level <RoiBarcodeScanner>
The headless native view — no overlay, sound, or haptics. Build your own UI on top.
import { RoiBarcodeScanner } from 'expo-roi-barcode-scanner';
<RoiBarcodeScanner
style={StyleSheet.absoluteFill}
regionOfInterest={{ x: 0.06, y: 0.44, width: 0.88, height: 0.12 }}
barcodeTypes={['ean13', 'code128', 'qr']}
onBarcodeScanned={({ nativeEvent }) => console.log(nativeEvent.data)}
onError={({ nativeEvent }) => console.warn(nativeEvent)}
/>Props: regionOfInterest, barcodeTypes, paused, torch, onBarcodeScanned, onCameraReady, onError, style. On the low-level view, paused suppresses barcode events without stopping the preview. It has no dependency on expo-haptics/expo-audio.
ROI behavior
regionOfInterest is measured against the rendered camera view, 0 to 1. y: 0.44, height: 0.12 means only the middle 12% of the view accepts scans; a barcode over the dimmed overlay cannot produce an event. The rule is “barcode center inside ROI,” so a long 1D barcode may extend slightly past the visual strip.
Supported barcode types
aztec, codabar, code128, code39, code93, dataMatrix, ean13, ean8, itf14, pdf417, qr, upcA, upcE. (UPC‑A is reported as ean13 on iOS; Codabar requires iOS 15.4+.)
Android troubleshooting
If an Android device shows no preview and the torch does not turn on:
- Confirm the app has camera permission and is a development/production build, not Expo Go.
- On Android 12+, open Quick Settings and enable the system Camera access toggle. Android intentionally gives apps a blank camera feed while this privacy switch is off.
- Close other camera/video apps that may own the camera, then reopen the scanner.
- After upgrading this package, rebuild the native app. Metro or an OTA update cannot replace the Android native module:
npx expo prebuild
npx expo run:android --deviceLog onError while diagnosing a device. Android reports useful codes such as camera_permission, camera_in_use, camera_disabled, camera_configuration, camera_initialization, camera_stream_configuration, torch_unavailable, and torch_configuration:
<BarcodeScanner
onScan={handleScan}
onError={(error) => console.warn('Scanner error', error.code, error.message)}
/>Run the demo
The example/ app is a full batch‑scanning flow: a home screen with an order list and per‑part quantities, and a scanner screen (multiScan) you open to add, remove, and re‑scan parts.
cd example
npm install
npx expo prebuild --clean
npx expo run:ios --device # or: npx expo run:android --device