Back to Blog

React Native Camera in 2026: Expo Camera, Photo Capture, and Image Picking

Build React Native camera features with Expo Camera v14: photo capture, front/back camera switch, flash control, image picking from gallery, cropping, and upload to S3.

Viprasol Tech Team
February 17, 2027
13 min read

React Native Camera in 2026: Expo Camera, Photo Capture, and Image Picking

Camera access is one of the most commonly requested mobile features โ€” profile photos, document scanning, receipt capture, QR code scanning. Expo Camera v14 (released with SDK 50+) redesigned the API significantly: CameraView replaced Camera, and the hook-based API changed the mental model.

This post covers the full implementation: permission handling, photo capture with CameraView, front/back camera switching, flash modes, image picker from gallery, image compression, and upload to S3 with progress tracking.


Installation

npx expo install expo-camera expo-image-picker expo-image-manipulator expo-file-system
// app.json โ€” required permissions
{
  "expo": {
    "plugins": [
      [
        "expo-camera",
        {
          "cameraPermission": "$(PRODUCT_NAME) needs camera access to capture photos.",
          "microphonePermission": "$(PRODUCT_NAME) needs microphone access for video."
        }
      ],
      [
        "expo-image-picker",
        {
          "photosPermission": "$(PRODUCT_NAME) needs photo library access to select images."
        }
      ]
    ]
  }
}

Permission Handling

// hooks/useCameraPermissions.ts
import { useCameraPermissions, PermissionStatus } from "expo-camera";
import { useMediaLibraryPermissions } from "expo-image-picker";

export function useCameraAndGalleryPermissions() {
  const [cameraPermission, requestCameraPermission] = useCameraPermissions();
  const [galleryPermission, requestGalleryPermission] = useMediaLibraryPermissions();

  const requestAll = async () => {
    const [cam, gal] = await Promise.all([
      cameraPermission?.status !== PermissionStatus.GRANTED
        ? requestCameraPermission()
        : cameraPermission,
      galleryPermission?.status !== PermissionStatus.GRANTED
        ? requestGalleryPermission()
        : galleryPermission,
    ]);
    return {
      camera: cam?.status === PermissionStatus.GRANTED,
      gallery: gal?.status === PermissionStatus.GRANTED,
    };
  };

  return {
    cameraGranted: cameraPermission?.status === PermissionStatus.GRANTED,
    galleryGranted: galleryPermission?.status === PermissionStatus.GRANTED,
    cameraCanAskAgain: cameraPermission?.canAskAgain ?? true,
    requestAll,
  };
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Full Camera Screen

// screens/CameraScreen.tsx
import React, { useRef, useState, useCallback } from "react";
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  Alert,
  Dimensions,
} from "react-native";
import { CameraView, CameraType, FlashMode, useCameraPermissions } from "expo-camera";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { compressAndUpload } from "@/lib/upload";

const { width } = Dimensions.get("window");

export default function CameraScreen() {
  const cameraRef = useRef<CameraView>(null);
  const [permission, requestPermission] = useCameraPermissions();
  const [facing, setFacing] = useState<CameraType>("back");
  const [flash, setFlash] = useState<FlashMode>("off");
  const [isCapturing, setIsCapturing] = useState(false);

  // Permission not yet determined
  if (!permission) return <View style={styles.container} />;

  // Permission denied
  if (!permission.granted) {
    return (
      <View style={styles.permissionContainer}>
        <Ionicons name="camera-outline" size={64} color="#9CA3AF" />
        <Text style={styles.permissionTitle}>Camera access needed</Text>
        <Text style={styles.permissionText}>
          Allow camera access to capture photos.
        </Text>
        {permission.canAskAgain ? (
          <TouchableOpacity onPress={requestPermission} style={styles.permissionButton}>
            <Text style={styles.permissionButtonText}>Allow Camera</Text>
          </TouchableOpacity>
        ) : (
          <Text style={styles.permissionText}>
            Please enable camera in Settings.
          </Text>
        )}
      </View>
    );
  }

  const toggleFacing = () => {
    setFacing((prev) => (prev === "back" ? "front" : "back"));
  };

  const cycleFlash = () => {
    setFlash((prev) => {
      if (prev === "off") return "on";
      if (prev === "on") return "auto";
      return "off";
    });
  };

  const flashIcon = flash === "off" ? "flash-off" : flash === "on" ? "flash" : "flash-outline";

  const handleCapture = useCallback(async () => {
    if (!cameraRef.current || isCapturing) return;
    setIsCapturing(true);

    try {
      const photo = await cameraRef.current.takePictureAsync({
        quality: 0.8,          // 0โ€“1, affects JPEG compression
        base64: false,          // Don't include base64 (use URI instead)
        exif: false,            // Skip EXIF to reduce payload
        skipProcessing: false,  // Apply iOS processing (exposure, white balance)
      });

      if (!photo) throw new Error("Capture failed");

      // Navigate to preview / crop screen with the photo URI
      router.push({
        pathname: "/photo-preview",
        params: { uri: photo.uri, width: photo.width, height: photo.height },
      });
    } catch (err) {
      Alert.alert("Error", "Failed to capture photo. Please try again.");
    } finally {
      setIsCapturing(false);
    }
  }, [isCapturing]);

  return (
    <View style={styles.container}>
      <CameraView
        ref={cameraRef}
        style={styles.camera}
        facing={facing}
        flash={flash}
        ratio="16:9"
      >
        {/* Top controls */}
        <View style={styles.topControls}>
          <TouchableOpacity onPress={() => router.back()} style={styles.iconButton}>
            <Ionicons name="close" size={28} color="white" />
          </TouchableOpacity>

          <TouchableOpacity onPress={cycleFlash} style={styles.iconButton}>
            <Ionicons name={flashIcon} size={28} color="white" />
          </TouchableOpacity>
        </View>

        {/* Capture area guide */}
        <View style={styles.captureGuide} pointerEvents="none">
          <View style={styles.cornerTL} />
          <View style={styles.cornerTR} />
          <View style={styles.cornerBL} />
          <View style={styles.cornerBR} />
        </View>

        {/* Bottom controls */}
        <View style={styles.bottomControls}>
          {/* Gallery picker shortcut */}
          <TouchableOpacity
            onPress={() => router.push("/gallery-picker")}
            style={styles.galleryButton}
          >
            <Ionicons name="images-outline" size={28} color="white" />
          </TouchableOpacity>

          {/* Shutter button */}
          <TouchableOpacity
            onPress={handleCapture}
            disabled={isCapturing}
            style={[styles.shutterButton, isCapturing && styles.shutterButtonDisabled]}
          >
            <View style={styles.shutterInner} />
          </TouchableOpacity>

          {/* Flip camera */}
          <TouchableOpacity onPress={toggleFacing} style={styles.flipButton}>
            <Ionicons name="camera-reverse-outline" size={28} color="white" />
          </TouchableOpacity>
        </View>
      </CameraView>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: "#000" },
  camera: { flex: 1 },
  permissionContainer: {
    flex: 1, alignItems: "center", justifyContent: "center",
    backgroundColor: "#F9FAFB", padding: 32, gap: 16,
  },
  permissionTitle: { fontSize: 20, fontWeight: "700", color: "#111827" },
  permissionText: { fontSize: 14, color: "#6B7280", textAlign: "center" },
  permissionButton: {
    backgroundColor: "#2563EB", paddingHorizontal: 24, paddingVertical: 12,
    borderRadius: 8,
  },
  permissionButtonText: { color: "#fff", fontWeight: "600" },
  topControls: {
    flexDirection: "row", justifyContent: "space-between",
    padding: 16, paddingTop: 48,
  },
  iconButton: { padding: 8 },
  captureGuide: {
    flex: 1, margin: 32, position: "relative",
  },
  cornerTL: { position: "absolute", top: 0, left: 0, width: 30, height: 30,
    borderTopWidth: 3, borderLeftWidth: 3, borderColor: "white", borderRadius: 2 },
  cornerTR: { position: "absolute", top: 0, right: 0, width: 30, height: 30,
    borderTopWidth: 3, borderRightWidth: 3, borderColor: "white", borderRadius: 2 },
  cornerBL: { position: "absolute", bottom: 0, left: 0, width: 30, height: 30,
    borderBottomWidth: 3, borderLeftWidth: 3, borderColor: "white", borderRadius: 2 },
  cornerBR: { position: "absolute", bottom: 0, right: 0, width: 30, height: 30,
    borderBottomWidth: 3, borderRightWidth: 3, borderColor: "white", borderRadius: 2 },
  bottomControls: {
    flexDirection: "row", alignItems: "center", justifyContent: "space-around",
    paddingBottom: 48, paddingHorizontal: 24,
  },
  galleryButton: { padding: 12 },
  shutterButton: {
    width: 72, height: 72, borderRadius: 36,
    backgroundColor: "transparent", borderWidth: 4, borderColor: "white",
    alignItems: "center", justifyContent: "center",
  },
  shutterButtonDisabled: { opacity: 0.5 },
  shutterInner: { width: 58, height: 58, borderRadius: 29, backgroundColor: "white" },
  flipButton: { padding: 12 },
});

Image Picker from Gallery

// lib/gallery-picker.ts
import * as ImagePicker from "expo-image-picker";
import * as ImageManipulator from "expo-image-manipulator";

export interface PickedImage {
  uri: string;
  width: number;
  height: number;
  fileSize?: number;
  mimeType: string;
}

export async function pickFromGallery(
  options: {
    allowsMultipleSelection?: boolean;
    aspect?: [number, number];
    maxDimension?: number;
  } = {}
): Promise<PickedImage[] | null> {
  const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
  if (!permission.granted) return null;

  const result = await ImagePicker.launchImageLibraryAsync({
    mediaTypes: ImagePicker.MediaTypeOptions.Images,
    allowsEditing: !options.allowsMultipleSelection, // Crop only in single mode
    aspect: options.aspect,
    quality: 1,                                       // Don't compress here โ€” do it after
    allowsMultipleSelection: options.allowsMultipleSelection ?? false,
    selectionLimit: 10,
    exif: false,
  });

  if (result.canceled) return null;

  // Compress and resize if needed
  const processed = await Promise.all(
    result.assets.map((asset) =>
      compressImage(asset.uri, options.maxDimension ?? 1920)
    )
  );

  return processed;
}

async function compressImage(uri: string, maxDimension: number): Promise<PickedImage> {
  const manipulated = await ImageManipulator.manipulateAsync(
    uri,
    [{ resize: { width: maxDimension } }], // Resize to max width; height maintains ratio
    {
      compress: 0.8,
      format: ImageManipulator.SaveFormat.JPEG,
    }
  );

  return {
    uri: manipulated.uri,
    width: manipulated.width,
    height: manipulated.height,
    mimeType: "image/jpeg",
  };
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Upload to S3 with Progress

// lib/upload.ts
import * as FileSystem from "expo-file-system";

export async function uploadPhotoToS3(
  localUri: string,
  onProgress?: (progress: number) => void
): Promise<{ url: string; key: string }> {
  // Step 1: Get presigned URL from your API
  const res = await fetch("/api/upload/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ contentType: "image/jpeg" }),
  });
  const { uploadUrl, key, publicUrl } = await res.json();

  // Step 2: Upload directly to S3 using presigned URL (no server proxy)
  const uploadResult = await FileSystem.uploadAsync(uploadUrl, localUri, {
    httpMethod: "PUT",
    headers: { "Content-Type": "image/jpeg" },
    uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
  });

  if (uploadResult.status !== 200) {
    throw new Error(`S3 upload failed: ${uploadResult.status}`);
  }

  return { url: publicUrl, key };
}

// Server-side: generate presigned URL
// app/api/upload/presign/route.ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "crypto";

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function POST(req: Request) {
  const { contentType } = await req.json();
  const key = `uploads/${randomUUID()}.jpg`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: key,
    ContentType: contentType,
    ContentLengthRange: [0, 10 * 1024 * 1024], // 10MB max
  });

  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
  const publicUrl = `https://${process.env.CLOUDFRONT_DOMAIN}/${key}`;

  return Response.json({ uploadUrl, key, publicUrl });
}

QR Code Scanner

// screens/QrScannerScreen.tsx
import { CameraView, useCameraPermissions } from "expo-camera";
import { useState } from "react";
import { View, Text, StyleSheet } from "react-native";

export default function QrScannerScreen({
  onScan,
}: {
  onScan: (data: string) => void;
}) {
  const [permission, requestPermission] = useCameraPermissions();
  const [scanned, setScanned] = useState(false);

  if (!permission?.granted) {
    return (
      <View style={styles.center}>
        <Text onPress={requestPermission}>Allow camera access</Text>
      </View>
    );
  }

  return (
    <CameraView
      style={StyleSheet.absoluteFillObject}
      facing="back"
      barcodeScannerSettings={{
        barcodeTypes: ["qr"],
      }}
      onBarcodeScanned={scanned ? undefined : ({ data }) => {
        setScanned(true);
        onScan(data);
        // Reset after 2s (allow re-scanning a different code)
        setTimeout(() => setScanned(false), 2000);
      }}
    />
  );
}

const styles = StyleSheet.create({
  center: { flex: 1, alignItems: "center", justifyContent: "center" },
});

Cost and Timeline

ComponentTimelineCost (USD)
Camera screen + permissions1โ€“2 days$800โ€“$1,600
Gallery picker + compression0.5โ€“1 day$400โ€“$800
S3 presigned upload0.5โ€“1 day$400โ€“$800
QR code scanner0.5 day$300โ€“$500
Full camera feature (capture + gallery + upload)1โ€“2 weeks$5,000โ€“$10,000

See Also


Working With Viprasol

We build React Native camera features for mobile applications โ€” from simple profile photo capture through document scanning, receipt OCR, and QR code-driven workflows. Our mobile team has shipped camera-enabled apps on iOS and Android with Expo SDK 50+.

What we deliver:

  • CameraView setup with permission handling (including "never ask again" state)
  • Front/back camera switching, flash modes, capture guide overlay
  • Gallery picker with multi-select and image compression
  • S3 presigned URL upload with progress tracking
  • QR code scanner with debounced re-scan

Explore our web development services or contact us to add camera features to your mobile app.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.