React Native Camera in 2026: Expo Camera, Photo Capture
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.
React Native Camera in 2026: Expo Camera, Photo Capture, and Image Picking
Quick answer. Expo Camera v14 (SDK 50+) replaced the Camera component with CameraView and switched to a hook-based permission model. Implementation covers requesting camera permissions, capturing photos, switching front/back cameras, flash modes, gallery picking via expo-image-picker, compression with expo-image-manipulator, and uploading to S3 with progress.
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 1000+ 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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Camera screen + permissions | 1โ2 days | $800โ$1,600 |
| Gallery picker + compression | 0.5โ1 day | $400โ$800 |
| S3 presigned upload | 0.5โ1 day | $400โ$800 |
| QR code scanner | 0.5 day | $300โ$500 |
| Full camera feature (capture + gallery + upload) | 1โ2 weeks | $5,000โ$10,000 |
Explore More
- React Native Push Notifications โ Notifying users after photo processing
- React Native Offline โ Queuing photo uploads when offline
- React Native Animations โ Shutter animation, preview transitions
- AWS Secrets Manager โ Securing S3 credentials
Viprasol in Action
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:
CameraViewsetup 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.
Handling Permissions with the useCameraPermissions Hook in expo-camera
The "usecamerapermissions" expo-camera hook is the cleanest way to request and track camera access in modern React Native apps. It returns a permission object and a request function, so you can check status, prompt the user, and re-render once access is granted, all without managing your own state. Pair it with the CameraView component to gate rendering behind a granted status, and remember to handle the undetermined and denied cases explicitly rather than assuming approval.
A few practical tips from shipping this in production: request permissions lazily, right before the camera screen mounts, not on app launch. Always provide a fallback UI that links to system settings when a user has previously denied access, since you cannot re-prompt natively. Our senior engineers build full camera flows like this end to end, with complete ownership of the codebase we hand over.
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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
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.