Skip to content
Native Animation

React Native Camera Mask and Overlay for Expo

By Native Animation · Updated

A React Native camera mask is a visual overlay above a camera preview; it does not detect QR codes, recognize objects, or crop a document. In Expo, use CameraView for camera access and scanning, map detector coordinates into the preview only when needed, and render the mask without intercepting camera controls. QR Target Lock animates host-supplied bounds and state; it does not open the camera or decode an image.

Separate the camera, detector, and overlay

LayerResponsibilityOutput
CameraPermission, lens, preview, lifecycleVisible preview and camera events
Detector and hostDecode, validate, track request identityBounds, payload, accepted status
OverlayMask, corners, feedback, retry affordancePresentation and user intent

A scanning animation is not evidence of detection. The complete camera example below uses real CameraView barcode callbacks; the later QR Target Lock example deliberately uses labeled fixture bounds to show its adapter contract.

Target Expo SDK 58 and React Native 0.88.0. Install with npx expo install expo-camera (bunx expo install in Bun projects), following the SDK 58 Camera documentation. In a development or production build, configure the expo-camera plugin's cameraPermission message in app.json. Native permission configuration requires a new binary; Expo Go supplies its own native configuration. The animated catalog piece additionally needs the dependencies in its README, including Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3.

Map source bounds into the preview

Aspect fill, cropping, and orientation

For an upright source image of size Sw × Sh displayed with centered aspect fill in Vw × Vh, scale is max(Vw / Sw, Vh / Sh). The centered offsets are (Vw − Sw × scale) / 2 and (Vh − Sh × scale) / 2. Apply both to the rectangle; using width scale alone fails when the preview crops the source.

This pure helper accepts only upright, axis-aligned source-pixel rectangles with a centered cover preview. Rotate the source and its bounds into display orientation before calling it. A rotated rectangle needs all four corners transformed before rebuilding an axis-aligned box.

export type Rect = { x: number; y: number; width: number; height: number };
export type Size = { width: number; height: number };

export function mapCoverBounds(bounds: Rect, source: Size, preview: Size,
  mirrored = false): Rect | null {
  if (![bounds.x, bounds.y, bounds.width, bounds.height, source.width, source.height,
    preview.width, preview.height].every(Number.isFinite)
    || source.width <= 0 || source.height <= 0 || preview.width <= 0 || preview.height <= 0
    || bounds.width <= 0 || bounds.height <= 0) return null;
  const scale = Math.max(preview.width / source.width, preview.height / source.height);
  const offsetX = (preview.width - source.width * scale) / 2;
  const offsetY = (preview.height - source.height * scale) / 2;
  const left = bounds.x * scale + offsetX;
  const top = bounds.y * scale + offsetY;
  const right = left + bounds.width * scale;
  const bottom = top + bounds.height * scale;
  const x1 = Math.max(0, left);
  const y1 = Math.max(0, top);
  const x2 = Math.min(preview.width, right);
  const y2 = Math.min(preview.height, bottom);
  if (x2 <= x1 || y2 <= y1) return null;
  const width = x2 - x1;
  return { x: mirrored ? preview.width - x2 : x1,
    y: y1, width, height: y2 - y1 };
}

Known fixture: a 1200 × 800 source in a 300 × 300 preview has scale 0.375 and horizontal offset −75. Bounds {x: 400, y: 200, width: 400, height: 400} map to {x: 75, y: 75, width: 150, height: 150}. These are arithmetic test inputs, not measured device geometry. Fully cropped bounds return null instead of a phantom target at an edge.

Mirrored preview and normalized coordinates

Mirror horizontal geometry only when the displayed preview is mirrored and the detector coordinates are not already mirrored. Normalize after mapping: divide x/width by viewport width and y/height by viewport height. If the overlay viewport starts inside a larger preview, subtract its measured origin first and clip to that viewport.

Expo documents BarcodePoint coordinates in the camera source space, adjusted to the camera view dimensions when using that view. Do not automatically feed CameraView events through the sensor-pixel cover helper. Inspect the event contract for the exact platform and detector. Bounds can be empty or cover only the scanner's recognized area; corner-point order also differs by platform. When reliable bounds are absent, show a searching frame or a decoded result without inventing a tracked rectangle.

Draw a mask without blocking the controls

Save this complete example as CameraMaskExample.tsx outside the Expo Router route directory. Render it only on the active camera screen and unmount it on navigation/background according to the host's lifecycle policy. Keep one camera preview active at a time. This teaching mask provides an aiming window, not a detection region: a QR outside the opening can still trigger scanning.

import { useRef, useState } from 'react';
import { Button, Linking, StyleSheet, Text, View } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';

export function CameraMaskExample() {
  const [permission, requestPermission] = useCameraPermissions();
  const [result, setResult] = useState<string | null>(null);
  const [error, setError] = useState('');
  const [ready, setReady] = useState(false);
  const [attempt, setAttempt] = useState(0);
  const generation = useRef(0);
  const accepted = useRef(false);
  if (!permission) return <Text>Checking camera permission…</Text>;
  if (!permission.granted) return (
    <View style={styles.screen}>
      <Text>Camera access is needed to scan a QR code.</Text>
      <Button title={permission.canAskAgain ? 'Allow camera' : 'Open settings'}
        onPress={() => { if (permission.canAskAgain) void requestPermission();
          else void Linking.openSettings(); }} />
    </View>
  );
  function retry() {
    generation.current += 1;
    accepted.current = false;
    setResult(null); setError(''); setReady(false);
    setAttempt(generation.current);
  }
  return (
    <View style={styles.screen}>
      <View style={styles.preview}>
        <CameraView key={attempt} style={StyleSheet.absoluteFill} facing="back"
          barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
          onCameraReady={() => { if (attempt === generation.current) setReady(true); }}
          onMountError={({ message }) => {
            if (attempt !== generation.current) return;
            accepted.current = true; setError(message); setReady(false);
          }}
          onBarcodeScanned={result || error ? undefined : ({ data }) => {
            if (attempt !== generation.current || accepted.current) return;
            accepted.current = true;
            setResult(data);
          }} />
        <View pointerEvents="none" accessible={false} style={StyleSheet.absoluteFill}>
          <View style={styles.shade} />
          <View style={styles.middle}>
            <View style={styles.shade} />
            <View style={styles.window} />
            <View style={styles.shade} />
          </View>
          <View style={styles.shade} />
        </View>
      </View>
      <Text accessibilityLiveRegion="polite">
        {error ? 'Camera unavailable: ' + error : result !== null
          ? 'QR decoded: ' + result : ready ? 'Point the camera at a QR code.' : 'Starting camera…'}
      </Text>
      <Button title="Scan again" onPress={retry} />
    </View>
  );
}
const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, gap: 16 },
  preview: { width: '100%', aspectRatio: 1, overflow: 'hidden', backgroundColor: '#111111' },
  shade: { flex: 1, backgroundColor: '#00000088' },
  middle: { flexDirection: 'row', height: '60%' },
  window: { width: '60%', borderWidth: 2, borderColor: '#FFFFFF' },
});

The four shaded areas surround a transparent window; no opaque black rectangle covers the target. Mask pointerEvents is none, and Scan again lives outside it. The host owns safe areas. On web, use a secure context and a supported camera; permission refusal and settings recovery should also have a manual-entry alternative in the real product.

Animate target acquisition and confirmation

Pending, searching, locked, and failed states

Keep starting/permission pending in the host. After readiness, searching means no reliable target; tracking means current bounds exist; locked means the host accepted a stable target. A result means validated content is ready to present. A frame animation completing does not establish that validation succeeded. Map mount errors to a visible failure; use lost when a previously tracked target is no longer valid.

Clear a stale detection before retrying

The camera example latches the first callback with a ref so repeated scans cannot accept twice before React rerenders. Retry clears the payload and error, advances a generation, and remounts the camera. Callbacks from the old attempt are ignored. For asynchronous payload validation, also capture that generation before awaiting and compare it before publishing the result. Invalidate it on cancellation or navigation, and stop any freshness timer on unmount.

If a tracking detector stops reporting a target, expire its old bounds using the detector's freshness policy. Retaining the last box indefinitely can look like a current lock.

Connect the host to QR Target Lock

QR Target Lock takes detectedBounds normalized to its own centered square scan viewport, plus a controlled status. Its result shape is id/title/subtitle/actionLabel. renderPreview supplies content behind the frame, but does not expose a public viewport-measurement callback. A live integration must align the camera and the component's scan viewport using the delivered layout; a generic full-screen rectangle is not interchangeable with that square.

After adding the piece with the CLI, this complete fixture demonstrates the public contract. It is a simulated target, not a camera adapter:

import { useState } from 'react';
import { Button, Text, View } from 'react-native';
import { QrTargetLock, type QrTargetStatus } from './qr-target-lock';

export function TargetFixture() {
  const [status, setStatus] = useState<QrTargetStatus>('searching');
  const [hasTarget, setHasTarget] = useState(false);
  return <View style={{ flex: 1 }}>
    <Text>Simulated bounds fixture — no camera or detection</Text>
    <Button title="Supply known target" onPress={() => {
      setHasTarget(true); setStatus('tracking');
    }} />
    <Button title="Confirm fixture" disabled={!hasTarget}
      onPress={() => setStatus('locked')} />
    <QrTargetLock status={status} onStatusChange={setStatus}
      detectedBounds={hasTarget ? { x: 0.25, y: 0.25, width: 0.5, height: 0.5 } : undefined}
      renderPreview={() => <Text>Fixture preview</Text>}
      labels={{ searching: 'Searching', tracking: 'Tracking', locked: 'Locked',
        lost: 'Target lost', error: 'Unable to scan', retry: 'Try again' }}
      onRetry={() => { setHasTarget(false); setStatus('searching'); }} />
  </View>;
}

Omitting bounds falls back to the component's centered target geometry; it does not mean a detection exists. Only the host's current status and validated data should establish a lock. Controlled hosts must accept requests through props; reset() and presentResult() cannot overrule a controlled status. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release.

Accessibility, permissions, and privacy boundaries

Expose scanning status and named retry/result actions outside decorative geometry. Offer manual entry when camera use is impossible. Do not open decoded URLs automatically; show and validate the payload first. Camera permission does not authorize uploading frames or retaining documents. Keep capture, storage, upload consent, and retention in the host's data policy. Test permission denial, revocation, orientation changes, backgrounding, and Reduce Motion in your actual camera screen.

Choose a document capture workflow

Document capture needs perspective-corner editing, crop/export, and possibly OCR. A QR target rectangle does not provide those features, so do not present QR framing as a scanner engine. Document Corner Capture covers the review side of that workflow: your host supplies a frozen, oriented image after a capture request, and the component draws the exterior mask, lets the user move four independent corners with a magnifier, proposes corners through onConfirm, and collects accepted pages with stable IDs. Camera access, edge detection, pixel correction, OCR, and PDF export stay in the host. Similarly, Meal Scan Nutrition visualizes host-supplied nutrition data. It does not recognize food, estimate calories, or open a camera.

FAQ

Does a camera mask limit the detector to its opening?

No. The mask is visual. Enforce any region-of-interest rule in the detector or host using reliable mapped bounds.

Why is my frame offset from the QR code?

Check coordinate space, preview origin, cover cropping, orientation, and mirroring. Avoid applying the cover transform again to already adjusted preview coordinates.

What if the detector returns empty bounds?

Keep a searching frame or show a decoded result without positional tracking. Do not fabricate a target location.

Does QR Target Lock decode or crop images?

No. It renders supplied bounds, states, and result content. The host supplies camera access, detection, validation, and any document export.