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.
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
| 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 |
See Also
- 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
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:
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.
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 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.
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.