Dev.to (React Native)~25 min read·Sep 18, 2026
Build a Production QR/Barcode Scanner Modal in React Native
Originally published on PEAKIQ
Source: https://www.peakiq.in/blog/building-a-production-qr-barcode-scanner-modal-in-react-native
Below is the scanner we ship: a full-screen modal with a close button, title, gallery picker, flashlight, a three-dot menu, and a zoom pill — reading QR codes and barcodes on iOS and Android from a single call site.
const value = await QRBarcodeScanner.open({ title: "'Scan Product QR' });"
if (value) lookupProduct(value);
Package
Purpose
react-native-vision-camera
Camera + native scanning
vision-camera-barcodes-scanner
ML Kit barcode frame processor (iOS path)
react-native-torch-nitro
Hardware flashlight
react-native-worklets-core
Worklet → JS hop (useRunOnJS)
react-native-ml-kit/barcode-scanning
Decode still photos and gallery images
zustand
The one small store the whole thing runs on
react-native-safe-area-context
Insets inside the modal window
npm i react-native-vision-camera react-native-worklets-core \
react-native-torch-nitro react-native-ml-kit/barcode-scanning \
vision-camera-barcodes-scanner zustand
cd ios && pod install
Android needs the bundled ML Kit model — this is what makes the native code-scanner path actually decode anything; without it, the code still compiles but has no model to run against. Add this to android/gradle.properties:
VisionCamera_enableCodeScanner=true
That's the entire native setup. Everything else below is application code.
The scanner is driven by a single Zustand store. open() returns a promise, close(value) resolves it, and setResult() parks an outcome for the modal to render. The detail worth calling out is the re-entrancy guard — it exists because re-calling open() while a session was already showing used to silently wipe a parked "Invalid QR" result out from under the user. Fixing it once here means every caller is safe automatically.
import { create } from 'zustand';
export type ScanFormat = string | number | null | undefined;
export type ScanCodeType = 'qr' | 'barcode';
export type ScanOutcomeState =
| 'invalid'
| 'qrCodeNotFound'
| 'error'
| 'productFound'
| 'unknownBarcode';
export interface ScanOutcome {
state: ScanOutcomeState;
barcode?: string;
productName?: string;
type?: ScanCodeType;
product?: {
productName: string;
unNumber: string;
location: string;
batch: string;
expiry: string;
hazard: string;
};
}
export interface QRBarcodeScannerOptions {
title?: string;
message?: string;
/** Runs the instant a code is read, inside the scanner. */
validate?: (value: string) => boolean;
/** Caller-driven: fires with each valid code while the scanner stays open. */
onCodeRead?: (value: string, format?: ScanFormat) => void;
onSearch?: (barcode?: string) => void;
onCreate?: (barcode?: string) => void;
}
interface ScannerStoreState {
visible: boolean;
options: QRBarcodeScannerOptions | null;
result: ScanOutcome | null;
resolve: ((value: string | null) => void) | null;
open: (options?: QRBarcodeScannerOptions) => Promise<string | null>;
close: (value?: string | null) => void;
setResult: (result: ScanOutcome | null) => void;
}
export const useScannerStore = create<ScannerStoreState>((set, get) => ({
visible: false,
options: null,
result: null,
resolve: null,
open: (options = {}) => {
if (get().visible) {
// Already showing — never clobber a parked result or orphan the live
// resolver. Hand the caller "cancelled" instead.
return Promise.resolve<string | null>(null);
}
return new Promise<string | null>((resolve) => {
set({ visible: true, options: options || {}, result: null, resolve });
});
},
close: (value: string | null = null) => {
const { resolve } = get();
if (resolve) resolve(value); // wake the awaiting caller
set({ visible: false, options: null, result: null, resolve: null });
},
setResult: (result) => set({ result }),
}));
/** Convenience singleton so any module can open the scanner. */
export const QRBarcodeScanner = {
open: (options?: QRBarcodeScannerOptions) =>
useScannerStore.getState().open(options),
close: (value?: string | null) => useScannerStore.getState().close(value),
setResult: (r: ScanOutcome | null) => useScannerStore.getState().setResult(r),
};
This mounts a single time at your app root, next to whatever hosts your alerts. Two details matter here:
transparent={false} (the default) is what makes it actually full-screen rather than a sheet over the current view.
The child is only mounted while visible is true. Each open starts a fresh camera session; each close fully tears it down. There is never a camera running behind another screen.
A top-level Modal is a separate native window, so it needs its own SafeAreaProvider — your app's normal inset provider doesn't reach inside it.
import React, { forwardRef, useState } from 'react';
import { Modal } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import BarcodeScannerScreen from './BarcodeScannerScreen';
import { useScannerStore } from './scannerStore';
export const ScannerModal = forwardRef<unknown, {}>((_props, _ref) => {
const visible = useScannerStore((s) => s.visible);
const options = useScannerStore((s) => s.options);
const result = useScannerStore((s) => s.result);
const close = useScannerStore((s) => s.close);
const [galleryNoticeKey, setGalleryNoticeKey] = useState(0);
return (
<Modal visible={visible} animationType="fade" onRequestClose={() => close()}>
{visible && (
<SafeAreaProvider>
<BarcodeScannerScreen
title={options?.title}
message={options?.message}
validate={options?.validate}
onCodeRead={options?.onCodeRead}
onSearch={options?.onSearch}
onCreate={options?.onCreate}
result={result}
onClose={() => close()}
galleryNoticeKey={galleryNoticeKey}
/>
</SafeAreaProvider>
)}
</Modal>
);
});
If any of your other screens wrap themselves in their own <Modal>, a second top-level modal will present behind one that's already open. The fix is a small presenter stack: a screen that owns its own modal mounts a local copy of this one and pushes itself onto a shared hostStack; the app-root modal only presents when that stack is empty. Same store, same UI — the stack just decides which native window renders it.
This owns the top bar, torch, zoom, continuous mode, validation, and result rendering. After a scan, the camera "parks" by toggling cameraActive off rather than unmounting anything.
import React, { useEffect, useRef, useState } from 'react';
import { AppState, Platform, StyleSheet, Text, View } from 'react-native';
import { useCameraDevice } from 'react-native-vision-camera';
import { useTorch } from 'react-native-torch-nitro';
import CameraPreview from './CameraPreview';
import ScannerTopBar from './ScannerTopBar';
import ScannerMenu from './ScannerMenu';
import ZoomControl from './ZoomControl';
import ScanResultChip from './ScanResultChip';
import ResultOverlay from './ResultOverlay';
import { ScanOutcome, ScanFormat, useScannerStore } from './scannerStore';
interface Props {
visible?: boolean;
title?: string;
message?: string;
validate?: (value: string) => boolean;
result?: ScanOutcome | null;
onCodeRead?: (value: string, format?: ScanFormat) => void;
onSearch?: (barcode?: string) => void;
onCreate?: (barcode?: string) => void;
onClose: () => void;
galleryNoticeKey?: number;
}
const BarcodeScannerScreen: React.FC<Props> = ({
visible = true,
title,
message,
validate,
result: pushedResult,
onCodeRead,
onClose,
}) => {
const setResult = useScannerStore((s) => s.setResult);
const [flashOn, setFlashOn] = useState(false);
const [scannedValue, setScannedValue] = useState<string | null>(null);
const [scannedFormat, setScannedFormat] = useState<ScanFormat>(null);
const [zoom, setZoom] = useState(1);
const [continuous, setContinuous] = useState(false);
const [menuVisible, setMenuVisible] = useState(false);
const [resetKey, setResetKey] = useState(0);
// Single physical device on Android — CameraX binding an ImageAnalysis use
// case to a logical/combo multi-camera is unreliable across OEMs.
const device = useCameraDevice('back', Platform.OS === 'android'
? undefined
: { physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera', 'telephoto-camera'] });
// 0.5x only exists if the device has an ultra-wide lens (minZoom < 1).
const canZoomOut = (device?.minZoom ?? 1) < 1;
const zoomOptions = canZoomOut ? [0.5, 1, 2] : [1, 2];
const { toggle, off } = useTorch({ onStateChanged: setFlashOn });
const offRef = useRef(off);
offRef.current = off;
// Torch must never survive leaving the foreground — some OEMs keep the
// flash burning in the background unless you kill it explicitly.
useEffect(() => {
const sub = AppState.addEventListener('change', () => {
setFlashOn(false);
offRef.current();
});
return () => sub.remove();
}, []);
// Park a scan → kill the torch too; nothing left to light up.
useEffect(() => {
if (scannedValue != null) {
setFlashOn(false);
offRef.current();
}
}, [scannedValue]);
const cameraActive = visible && (continuous || scannedValue == null);
const handleScanned = (value: string, format?: ScanFormat) => {
setResult(null);
setScannedValue(value);
setScannedFormat(format ?? null);
if (validate && !validate(value)) {
// Wrong kind of code: show "Invalid QR code", park the camera, and do
// NOT resolve the open() promise — the bad value never leaks upward.
setResult({ state: 'invalid' });
return;
}
if (onCodeRead) onCodeRead(value, format);
};
const handleScanAgain = () => {
setScannedValue(null);
setScannedFormat(null);
setResetKey((k) => k + 1);
setResult(null);
};
return (
<View style={styles.screen}>
<CameraPreview
device={device}
onCodeScanned={handleScanned}
isActive={cameraActive}
zoom={zoom}
continuous={continuous}
resetKey={resetKey}
/>
<ScannerTopBar
title={title}
onClose={onClose}
flashOn={flashOn}
onToggleFlash={() => toggle()}
onMore={() => setMenuVisible((v) => !v)}
onPickFromGallery={() => {}} // wire your ImagePicker here
/>
{message && cameraActive && (
<View style={styles.messageWrap} pointerEvents="none">
<Text style={styles.messageText}>{message}</Text>
</View>
)}
<ScannerMenu
visible={menuVisible}
continuous={continuous}
onToggleContinuous={() => { setContinuous((c) => !c); setMenuVisible(false); }}
onClose={() => setMenuVisible(false)}
/>
{cameraActive && (
<ZoomControl value={zoom} options={zoomOptions} onChange={setZoom} />
)}
{pushedResult != null ? (
<ResultOverlay outcome={pushedResult} onScanAgain={handleScanAgain} onClose={onClose} />
) : (
!onCodeRead &&
scannedValue != null && (
<ScanResultChip
value={scannedValue}
onClose={onClose}
onScanAgain={handleScanAgain}
onDone={() => useScannerStore.getState().close(scannedValue)}
/>
)
)}
</View>
);
};
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#1C1C1C' },
messageWrap: { position: 'absolute', left: 20, right: 20, top: '40%', alignItems: 'center' },
messageText: {
color: '#FFF', fontSize: 16, fontWeight: '600', textAlign: 'center',
paddingHorizontal: 20, paddingVertical: 12,
backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 12, overflow: 'hidden',
},
});
export default BarcodeScannerScreen;
This is the core of the whole implementation, and it splits by platform for a reason that isn't obvious from the API surface. iOS uses the ML Kit frame-processor worklet. Android uses VisionCamera's native CodeScanner (CameraX) instead, because on a lot of Android OEM hardware the frame-processor stream arrives out of focus — the full autodetect path on Android just isn't trustworthy the way it is on iOS.
To cover the hardware where even that isn't enough, there's a still-photo safety net on Android: snap and decode roughly every 900ms. That path was proven to decode on the specific problem hardware that motivated it. A frame gate on top of both paths ensures only codes inside the drawn scan box actually count as a read.
import React, { useCallback, useEffect, useRef } from 'react';
import { Linking, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
Camera, useCameraFormat, useCameraPermission, useCodeScanner, useFrameProcessor,
} from 'react-native-vision-camera';
import type { CameraDevice, Code, CodeType } from 'react-native-vision-camera';
import { useBarcodeScanner } from 'react-native-vision-camera-barcodes-scanner';
import MlKitBarcodeScanning from '@react-native-ml-kit/barcode-scanning';
import { useRunOnJS } from 'react-native-worklets-core';
import ScanFrame from './ScanFrame';
import { ScanFormat } from './scannerStore';
interface Props {
device?: CameraDevice;
onCodeScanned?: (value: string, format?: ScanFormat) => void;
isActive?: boolean;
zoom?: number;
continuous?: boolean;
resetKey?: number;
}
const FRAME_CONFIG = { widthPercent: 0.92, heightPercent: 0.45 };
const NATIVE_CODE_TYPES: CodeType[] = [
'aztec', 'code-128', 'code-39', 'code-93', 'codabar', 'data-matrix',
'ean-13', 'ean-8', 'itf', 'pdf-417', 'qr', 'upc-a', 'upc-e',
];
const CameraPreview: React.FC<Props> = ({
device, onCodeScanned, isActive = true, zoom = 1, continuous = false, resetKey = 0,
}) => {
const { hasPermission, requestPermission } = useCameraPermission();
const cameraRef = useRef<Camera>(null);
const handledCodeRef = useRef(false);
const lastDeliveredRef = useRef<string | null>(null);
const layoutRef = useRef({ width: 0, height: 0 });
const frameBoxRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
const previewOriginRef = useRef({ x: 0, y: 0 });
const previewNodeRef = useRef<View | null>(null);
const onCodeScannedRef = useRef(onCodeScanned);
const absZoom = device
? Math.min(Math.max(zoom, device.minZoom), device.maxZoom)
: zoom;
// Android: without an explicit format the analysis stream lands at 640x480,
// far too coarse for ML Kit to resolve a QR at normal distance.
const format = useCameraFormat(device, [
{ videoResolution: { width: 1920, height: 1080 } },
{ fps: 30 },
]);
// Android-only focus pulse: some CameraX chipsets deliver out-of-focus
// ImageAnalysis frames until an explicit focus() call is fired.
useEffect(() => {
if (Platform.OS !== 'android' || !isActive) return;
const pulse = () => {
const { width, height } = layoutRef.current;
if (width > 0 && height > 0)
cameraRef.current?.focus({ x: width / 2, y: height / 2 }).catch(() => {});
};
const initial = setTimeout(pulse, 400);
const interval = setInterval(pulse, 2000);
return () => { clearTimeout(initial); clearInterval(interval); };
}, [isActive]);
useEffect(() => {
if (isActive && !continuous) handledCodeRef.current = false;
if (!isActive) lastDeliveredRef.current = null;
}, [isActive, continuous]);
useEffect(() => {
if (resetKey > 0) { handledCodeRef.current = false; lastDeliveredRef.current = null; }
}, [resetKey]);
const onCameraLayout = useCallback((e: any) => {
const { width, height } = e.nativeEvent.layout;
layoutRef.current = { width, height };
previewNodeRef.current?.measureInWindow?.((x, y) => {
previewOriginRef.current = { x, y };
});
}, []);
const onFrameBox = useCallback((box: { x: number; y: number; width: number; height: number }) => {
const o = previewOriginRef.current;
frameBoxRef.current = { x: box.x - o.x, y: box.y - o.y, width: box.width, height: box.height };
}, []);
// JS-side delivery: single-shot latch in normal mode, dedupe in continuous mode.
const onBarcodesDetectedJS = useCallback(
(barcodes: any[]) => {
if (!barcodes.length) return;
const barcode = barcodes[0];
const value = barcode?.rawValue;
if (!value) return;
if (continuous) {
if (value === lastDeliveredRef.current) return;
lastDeliveredRef.current = value;
} else {
if (handledCodeRef.current) return;
handledCodeRef.current = true;
}
onCodeScannedRef.current?.(value, barcode?.format);
},
[continuous],
);
const onBarcodesJS = useRunOnJS(onBarcodesDetectedJS, [onBarcodesDetectedJS]);
// Native CodeScanner path (Android): bounds are already dp-relative to the
// preview, so the frame gate compares directly.
const handleNativeCodeScan = useCallback(
(codes: Code[]) => {
if (!isActive) return;
const box = frameBoxRef.current;
for (const code of codes) {
if (!code?.value) continue;
if (box.width > 0 && box.height > 0 && code.frame) {
const cx = code.frame.x + code.frame.width / 2;
const cy = code.frame.y + code.frame.height / 2;
if (cx < box.x || cx > box.x + box.width || cy < box.y || cy > box.y + box.height) continue;
}
onBarcodesDetectedJS([{ rawValue: code.value, format: code.type }]);
return;
}
},
[isActive, onBarcodesDetectedJS],
);
const codeScanner = useCodeScanner({
codeTypes: NATIVE_CODE_TYPES,
onCodeScanned: handleNativeCodeScan,
});
const { scanBarcodes } = useBarcodeScanner([
'aztec', 'code_128', 'code_39', 'code_93', 'codabar', 'data_matrix',
'ean_13', 'ean_8', 'itf', 'pdf_417', 'qr', 'upc_a', 'upc_e',
]);
// iOS worklet path, with a coordinate gate that inverts the aspect-fill
// transform so barcode coordinates line up with the drawn scan box.
const frameProcessor = useFrameProcessor(
(frame: any) => {
'worklet';
let barcodes: any[] = [];
try { barcodes = scanBarcodes(frame); } catch { return; }
if (!barcodes.length) return;
const barcode = barcodes[0];
if (!barcode?.rawValue) return;
const viewW = layoutRef.current.width, viewH = layoutRef.current.height;
const rawW = frame.width, rawH = frame.height;
if (!(viewW > 0 && viewH > 0 && rawW > 0 && rawH > 0)) return;
let fbX = frameBoxRef.current.x, fbY = frameBoxRef.current.y;
let fbW = frameBoxRef.current.width, fbH = frameBoxRef.current.height;
if (!(fbW > 0 && fbH > 0)) {
fbW = viewW * FRAME_CONFIG.widthPercent;
fbH = viewH * FRAME_CONFIG.heightPercent;
fbX = (viewW - fbW) / 2; fbY = (viewH - fbH) / 2;
}
const bcx = barcode.left + (barcode.width || 0) / 2;
const bcy = barcode.top + (barcode.height || 0) / 2;
// Portrait content space = rotated raw buffer.
const contentW = rawH, contentH = rawW;
const scale = Math.max(viewW / contentW, viewH / contentH);
const scaledW = contentW * scale, scaledH = contentH * scale;
const offX = (scaledW - viewW) / 2, offY = (scaledH - viewH) / 2;
const invA = 1 / scale;
// iOS coordinates live in raw space — rotate into upright content space.
let checkCx = bcx, checkCy = bcy;
if (Platform.OS === 'ios') {
const o = frame.orientation;
if (o === 'portrait') { checkCx = bcx; checkCy = bcy; }
else if (o === 'portrait-upside-down') { checkCx = rawW - bcx; checkCy = rawH - bcy; }
else if (o === 'landscape-left') { checkCx = bcy; checkCy = rawW - bcx; }
else { checkCx = rawH - bcy; checkCy = bcx; }
}
const corners = [
[fbX, fbY], [fbX + fbW, fbY], [fbX, fbY + fbH], [fbX + fbW, fbY + fbH],
];
let rMinX = Infinity, rMaxX = -Infinity, rMinY = Infinity, rMaxY = -Infinity;
for (const [sx, sy] of corners) {
const rx = (sx + offX) * invA, ry = (sy + offY) * invA;
if (rx < rMinX) rMinX = rx; if (rx > rMaxX) rMaxX = rx;
if (ry < rMinY) rMinY = ry; if (ry > rMaxY) rMaxY = ry;
}
const inFrame =
checkCx >= rMinX && checkCx <= rMaxX &&
checkCy >= rMinY && checkCy <= rMaxY;
if (!inFrame) return;
onBarcodesJS(barcodes);
},
[],
);
// Android safety net: still-photo backstop for hardware the frame
// processor never focuses correctly.
const stillScanBusyRef = useRef(false);
useEffect(() => {
if (Platform.OS !== 'android' || !isActive) return;
const interval = setInterval(async () => {
if (stillScanBusyRef.current) return;
stillScanBusyRef.current = true;
try {
const photo = await cameraRef.current?.takePhoto();
if (!photo) return;
const results = await MlKitBarcodeScanning.scan(`file://${photo.path}`);
if (results.length > 0) {
onBarcodesDetectedJS(
results.map((r) => ({ rawValue: r.value, format: r.format })),
);
}
} catch { /* frame just missed */ } finally {
stillScanBusyRef.current = false;
}
}, 900);
return () => clearInterval(interval);
}, [isActive, onBarcodesDetectedJS]);
if (!hasPermission) {
return (
<View style={styles.preview}>
<Text style={styles.message}>Camera permission is required to scan.</Text>
<TouchableOpacity style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Allow Camera</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.link} onPress={() =>
Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings()}>
<Text style={styles.linkText}>Open Settings</Text>
</TouchableOpacity>
</View>
);
}
if (!device) {
return (
<View style={styles.preview}>
<Text style={styles.message}>No camera device available.</Text>
</View>
);
}
return (
<View
ref={(node) => { previewNodeRef.current = node; }}
style={styles.preview}
onLayout={onCameraLayout}
collapsable={false}
pointerEvents="box-none"
>
<Camera
ref={cameraRef}
style={StyleSheet.absoluteFill}
device={device}
format={Platform.OS === 'android' ? format : undefined}
isActive={isActive}
zoom={absZoom}
enableZoomGesture={false}
codeScanner={Platform.OS === 'android' ? codeScanner : undefined}
frameProcessor={Platform.OS === 'android' ? undefined : frameProcessor}
photo={Platform.OS === 'android'}
pixelFormat="yuv"
onError={(e) => console.warn('[camera]', e.message)}
/>
<View style={styles.scanFrameWrap} pointerEvents="none">
<ScanFrame
instructionText="Position barcode or QR code inside the frame"
config={FRAME_CONFIG}
onFrameBox={onFrameBox}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
preview: { ...StyleSheet.absoluteFillObject, backgroundColor: '#1C1C1C', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
message: { color: 'rgba(255,255,255,0.85)', fontSize: 15, textAlign: 'center', marginBottom: 12 },
button: { backgroundColor: '#4E2BA1', borderRadius: 10, paddingHorizontal: 20, paddingVertical: 12 },
buttonText: { color: '#FFF', fontSize: 15, fontWeight: '600' },
link: { paddingVertical: 8 },
linkText: { color: '#7EB2FF', fontSize: 14, fontWeight: '600' },
scanFrameWrap: { ...StyleSheet.absoluteFillObject },
});
export default CameraPreview;
A self-contained overlay, no extra libraries. It measures its own on-screen box and reports it back so the camera gate compares against the exact same rectangle the user sees.
import React, { useCallback, useRef } from 'react';
import { StyleSheet, Text, View } from 'react-native';
interface Props {
instructionText: string;
config: { widthPercent: number; heightPercent: number };
onFrameBox: (box: { x: number; y: number; width: number; height: number }) => void;
}
const ScanFrame: React.FC<Props> = ({ instructionText, config, onFrameBox }) => {
const rootRef = useRef<View>(null);
const onLayout = useCallback(() => {
rootRef.current?.measureInWindow((x, y, w, h) => {
onFrameBox({ x, y, width: w, height: h });
});
}, [onFrameBox]);
return (
<View ref={rootRef} style={styles.wrap} pointerEvents="none" onLayout={onLayout}>
<View
style={{
width: `${config.widthPercent * 100}%`,
height: `${config.heightPercent * 100}%`,
}}
>
<View style={styles.frame} />
</View>
<Text style={styles.instructions}>{instructionText}</Text>
</View>
);
};
const styles = StyleSheet.create({
wrap: { flex: 1, alignItems: 'center', justifyContent: 'center' },
frame: {
flex: 1,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.9)',
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0)',
},
instructions: {
marginTop: 16, color: '#FFF', fontSize: 13, fontWeight: '500',
textAlign: 'center', textShadowColor: 'rgba(0,0,0,0.8)', textShadowRadius: 4,
},
});
export default ScanFrame;
A nicer touch, optional: dim everything outside the frame with react-native-hole-view, giving a flashlight-style cutout. The bordered frame above already communicates the target clearly enough without it.
The reason this file needs care is 360dp-wide Android phones. Every pixel of width is spoken for, and the title will happily push the three-dot menu off the edge if you let it. The rule: the title may shrink, the buttons never clip, and below 380dp the whole row compacts.
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Ionicons from 'react-native-vector-icons/Ionicons';
interface Props {
title?: string;
onClose: () => void;
flashOn: boolean;
onToggleFlash: () => void;
onMore: () => void;
onPickFromGallery: () => void;
}
const BTN = 36;
const BTN_COMPACT = 32;
const RoundIconButton: React.FC<{
onPress: () => void; size?: number; children: React.ReactNode;
}> = ({ onPress, size = BTN, children }) => (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={[styles.circleBtn, { width: size, height: size, borderRadius: size / 2 }]}
>
{children}
</TouchableOpacity>
);
const ScannerTopBar: React.FC<Props> = ({
title = 'Scan Code', onClose, flashOn, onToggleFlash, onMore, onPickFromGallery,
}) => {
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const compact = width < 380;
return (
<View style={[styles.container, { paddingTop: insets.top + 8, paddingHorizontal: compact ? 10 : 16 }]}>
<RoundIconButton onPress={onClose} size={compact ? BTN_COMPACT : BTN}>
<Ionicons name="close" size={compact ? 18 : 20} color="#FFF" />
</RoundIconButton>
<View style={[styles.titlePill, compact && styles.titlePillCompact]}>
<Text style={styles.titleText} numberOfLines={1} ellipsizeMode="tail">{title}</Text>
{!compact && <Ionicons name="chevron-forward" size={13} color="rgba(255,255,255,0.7)" />}
</View>
<View style={[styles.rightGroup, compact && styles.rightGroupCompact]}>
<RoundIconButton onPress={onPickFromGallery} size={compact ? BTN_COMPACT : BTN}>
<Ionicons name="image-outline" size={compact ? 17 : 19} color="#FFF" />
</RoundIconButton>
<RoundIconButton onPress={onToggleFlash} size={compact ? BTN_COMPACT : BTN}>
<Ionicons name={flashOn ? 'flash' : 'flash-off'} size={compact ? 17 : 19} color="#FFF" />
</RoundIconButton>
<RoundIconButton onPress={onMore} size={compact ? BTN_COMPACT : BTN}>
<Ionicons name="ellipsis-horizontal" size={compact ? 18 : 20} color="#FFF" />
</RoundIconButton>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute', top: 0, left: 0, right: 0,
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
},
circleBtn: {
backgroundColor: 'rgba(25, 25, 25, 0.6)',
alignItems: 'center', justifyContent: 'center',
},
titlePill: {
flexDirection: 'row', alignItems: 'center', gap: 4,
height: 32, paddingHorizontal: 14, borderRadius: 16,
backgroundColor: 'rgba(25, 25, 25, 0.6)',
flexShrink: 1, minWidth: 0, flexWrap: 'nowrap',
},
titlePillCompact: { height: 30, paddingHorizontal: 10, borderRadius: 15 },
titleText: { color: '#FFF', fontSize: 13, fontWeight: '500', flexShrink: 1 },
rightGroup: { flexDirection: 'row', gap: 8 },
rightGroupCompact: { gap: 6 },
});
export default ScannerTopBar;
The three-dot menu is a backdrop plus a card anchored under the button, with a plain-View toggle for continuous scan mode — no switch library needed.
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Ionicons from 'react-native-vector-icons/Ionicons';
interface Props {
visible: boolean;
continuous: boolean;
onToggleContinuous: () => void;
onClose: () => void;
}
const ScannerMenu: React.FC<Props> = ({ visible, continuous, onToggleContinuous, onClose }) => {
const insets = useSafeAreaInsets();
if (!visible) return null;
return (
<>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.menu, { top: insets.top + 58 }]}>
<TouchableOpacity style={styles.menuItem} activeOpacity={0.7} onPress={onToggleContinuous}>
<View style={styles.menuItemLeft}>
<Ionicons name="repeat" size={18} color="#FFF" />
<Text style={styles.menuItemText}>Continuous scan</Text>
</View>
<View style={[styles.switch, continuous && styles.switchOn]}>
<View style={[styles.switchKnob, continuous && styles.switchKnobOn]} />
</View>
</TouchableOpacity>
</View>
</>
);
};
const styles = StyleSheet.create({
backdrop: { ...StyleSheet.absoluteFillObject },
menu: {
position: 'absolute', right: 16, minWidth: 220,
backgroundColor: 'rgba(28, 28, 36, 0.97)', borderRadius: 16, paddingVertical: 6,
borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.1)',
shadowColor: '#000', shadowOpacity: 0.4, shadowRadius: 12, shadowOffset: { width: 0, height: 4 },
elevation: 6,
},
menuItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 12 },
menuItemLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
menuItemText: { color: '#FFF', fontSize: 14, fontWeight: '600' },
switch: {
width: 44, height: 26, borderRadius: 13, backgroundColor: 'rgba(255,255,255,0.2)',
justifyContent: 'center', paddingHorizontal: 3,
},
switchOn: { backgroundColor: '#30D158' },
switchKnob: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFF' },
switchKnobOn: { alignSelf: 'flex-end' },
});
export default ScannerMenu;
The zoom pill only shows 0.5x if the hardware reports an ultra-wide lens (device.minZoom < 1) and disappears automatically once a code is read, since it unmounts along with cameraActive.
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface Props {
value: number;
options: number[];
onChange: (zoom: number) => void;
}
const ZoomControl: React.FC<Props> = ({ value, options, onChange }) => {
const insets = useSafeAreaInsets();
return (
<View style={[styles.wrap, { bottom: insets.bottom + 22 }]}>
<View style={styles.pill}>
{options.map((zoom) => {
const selected = zoom === value;
return (
<TouchableOpacity key={zoom} onPress={() => onChange(zoom)}
activeOpacity={0.8}
style={[styles.option, selected && styles.optionSelected]}>
<Text style={[styles.label, selected && styles.labelSelected]}>{zoom}x</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrap: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
pill: { flexDirection: 'row', borderRadius: 22, padding: 4, backgroundColor: 'rgba(25,25,25,0.6)' },
option: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 18 },
optionSelected: { backgroundColor: '#FFF' },
label: { color: 'rgba(255,255,255,0.85)', fontSize: 13.5, fontWeight: '600' },
labelSelected: { color: '#1C1C1C', fontWeight: '700' },
});
export default ZoomControl;
There are two modes. In the simple case, a raw-value chip shows with "Scan again" and "Done":
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Ionicons from 'react-native-vector-icons/Ionicons';
interface Props {
value: string;
onClose: () => void;
onScanAgain: () => void;
onDone: () => void;
}
const ScanResultChip: React.FC<Props> = ({ value, onClose, onScanAgain, onDone }) => (
<View style={styles.wrap}>
<View style={styles.chip}>
<Ionicons name="checkmark-circle" size={20} color="#30D158" />
<Text style={styles.value} numberOfLines={2} ellipsizeMode="middle">{value}</Text>
<TouchableOpacity onPress={onClose} style={styles.iconBtn}>
<Ionicons name="close" size={18} color="#FFF" />
</TouchableOpacity>
<TouchableOpacity onPress={onScanAgain} style={styles.action}>
<Ionicons name="refresh" size={16} color="#FFF" />
<Text style={styles.actionText}>Scan again</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onDone} style={[styles.action, styles.primary]}>
<Text style={styles.actionText}>Done</Text>
</TouchableOpacity>
</View>
</View>
);
const styles = StyleSheet.create({
wrap: { position: 'absolute', left: 12, right: 12, bottom: 40, alignItems: 'center' },
chip: {
flexDirection: 'row', alignItems: 'center', gap: 10,
backgroundColor: 'rgba(28,28,36,0.97)', borderRadius: 999,
paddingHorizontal: 16, paddingVertical: 10,
borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.1)',
},
value: { color: '#FFF', fontSize: 13, flexShrink: 1, maxWidth: 160 },
iconBtn: { padding: 2 },
action: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12 },
primary: { backgroundColor: 'rgba(255,255,255,0.15)' },
actionText: { color: '#FFF', fontSize: 13, fontWeight: '600' },
});
export default ScanResultChip;
When the caller wants to drive the flow itself — looking up a product before deciding what to show — it pushes a typed outcome instead, and a bottom sheet renders it:
// ResultSheet.tsx — a generic bottom sheet; the chip drives the header.
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ScanOutcome } from './scannerStore';
const META: Record<string, { title: string; message: string }> = {
invalid: { title: 'Invalid QR code', message: 'This QR code is not valid here. Please scan the correct code.' },
qrCodeNotFound: { title: 'QR code not found', message: 'This QR code could not be read.\nPlease try again.' },
error: { title: 'Something went wrong', message: 'An error occurred while processing the scan.\nPlease try again.' },
productFound: { title: 'Product found', message: '' },
unknownBarcode: { title: 'Product not found', message: 'No matching product for this barcode.' },
};
const ResultSheet: React.FC<{ outcome: ScanOutcome }> = ({ outcome }) => (
<View style={styles.sheet}>
<Text style={styles.title}>{META[outcome.state]?.title ?? 'Scan'}</Text>
{!!META[outcome.state]?.message && (
<Text style={styles.message}>{META[outcome.state].message}</Text>
)}
{!!outcome.barcode && <Text style={styles.barcode}>{outcome.barcode}</Text>}
</View>
);
const styles = StyleSheet.create({
sheet: {
backgroundColor: 'rgba(28,28,36,0.97)', borderRadius: 20, padding: 20,
borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(255,255,255,0.1)',
},
title: { color: '#FFF', fontSize: 17, fontWeight: '700', marginBottom: 6 },
message: { color: 'rgba(255,255,255,0.75)', fontSize: 14, lineHeight: 20 },
barcode: { color: '#7EB2FF', fontSize: 13, marginTop: 10 },
});
export default ResultSheet;
// ResultOverlay.tsx — routes an outcome to chip + sheet.
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import ResultSheet from './ResultSheet';
import { ScanOutcome } from './scannerStore';
interface Props {
outcome: ScanOutcome;
onScanAgain: () => void;
onClose: () => void;
}
const ResultOverlay: React.FC<Props> = ({ outcome, onScanAgain, onClose }) => {
const isInvalid = outcome.state === 'invalid';
return (
<View style={styles.wrap}>
{!isInvalid && (
<View style={styles.actions}>
<Text style={styles.link} onPress={onScanAgain}>Scan again</Text>
<Text style={styles.link} onPress={onClose}>Close</Text>
</View>
)}
<ResultSheet outcome={outcome} />
</View>
);
};
const styles = StyleSheet.create({
wrap: { position: 'absolute', left: 12, right: 12, bottom: 40 },
actions: { flexDirection: 'row', justifyContent: 'space-between', paddingBottom: 10 },
link: { color: '#7EB2FF', fontSize: 14, fontWeight: '600' },
});
export default ResultOverlay;
A plain scan that resolves to a string:
import { QRBarcodeScanner } from './scannerStore';
const value = await QRBarcodeScanner.open({ title: 'Scan Product QR' });
if (value) navigation.navigate('Detail', { id: value });
Validation inside the scanner, so a wrong-kind code shows "Invalid QR code" and never reaches the caller at all, structurally:
const value = await QRBarcodeScanner.open({
title: 'Scan Login QR',
validate: (raw) => {
const parsed = JSON.parse(raw); // must be a login config JSON
return !!parsed?.issuer && !!parsed?.clientId;
},
});
if (value) loginWithConfig(value);
Caller-driven, where the scanner stays open while a lookup happens and the outcome is pushed back in:
QRBarcodeScanner.open({
title: 'Scan Product QR',
onCodeRead: (value) => {
const product = findProduct(value);
if (product) {
QRBarcodeScanner.setResult({ state: 'productFound', barcode: value, product });
} else {
QRBarcodeScanner.setResult({ state: 'unknownBarcode', barcode: value });
}
},
});
Torch vs. background. Force the torch off on every AppState change, not only when the app returns to the foreground. Some Android OEMs leave the flash burning in the background otherwise.
codeScanner and frameProcessor are mutually exclusive on Android, since both create an ImageAnalysis use case under the hood. Pick exactly one, branched by Platform.OS.
Android frame-processor focus. Some chipsets hand the frame processor out-of-focus frames even while the preview itself looks sharp. The native CodeScanner plus the 900ms still-photo backstop are the two fixes this relies on — the still-photo path was proven to decode on the hardware that originally motivated it.
Behind another modal. If any screen owns its own <Modal>, a second root-level modal renders behind it, not on top. Keep the one-modal-at-root rule, and add the small presenter-stack from Step 2 for the screens that break it.
Re-entrancy. open() called while the scanner is already visible has to return "cancelled," not wipe result, options, or resolve out from under the current session — otherwise something like an auto-open on app resume can silently erase a result the user hasn't seen yet.
Cold launch — the scanner opens on the first try on both platforms.
A valid code parks the camera; Done resolves the promise.
A wrong-kind code shows "Invalid QR code" and parks; Scan again re-arms it.
A parked result survives backgrounding and returning to the app.
Torch turns off immediately on backgrounding.
On a 360dp Android device, close, gallery, flash, and the overflow menu are all fully tappable, and the title truncates instead of pushing them off-screen.
Codes outside the drawn frame are ignored.
Ten-plus scans in a row on Android produce no dead frames.
None of the individual pieces here are exotic — a store, a modal, a camera view, a frame gate. What makes a scanner like this hard to get right is that it has to behave identically across two camera stacks that don't agree on what "in focus" means, and the fixes for that only show up once you've tested on the actual hardware that breaks. Copy the files in order, wire QRBarcodeScanner.open() to a button, and it holds up the same on a 360dp Android phone as it does on a Pro Max.