Camera
A camera component for capturing photos with front/back camera toggle, mirror modes, and mask overlays.
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraDemo() {
return (
<div className="flex justify-center">
<Camera
autoStart={false}
onCapture={(imageData) => {
console.log("Photo captured:", imageData.slice(0, 50) + "...")
}}
onError={(error) => {
console.error("Camera error:", error)
}}
onStart={() => {
console.log("Camera started")
}}
/>
</div>
)
}
Installation
CLI
npx shadcn@latest add "https://acfui.com/r/camera"
pnpm dlx shadcn@latest add "https://acfui.com/r/camera"
yarn dlx shadcn@latest add "https://acfui.com/r/camera"
bun x shadcn@latest add "https://acfui.com/r/camera"
Manual
Install the following dependencies:
npm install class-variance-authority lucide-react
pnpm add class-variance-authority lucide-react
yarn add class-variance-authority lucide-react
bun add class-variance-authority lucide-react
Copy and paste the following code into your project.
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import {
Camera as CameraIcon,
CameraOff,
Download,
RotateCcw,
Volume2,
VolumeX,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const cameraVariants = cva("relative overflow-hidden bg-black rounded-lg", {
variants: {
size: {
sm: "w-64 h-48",
md: "w-80 h-60",
lg: "w-96 h-72",
xl: "w-[28rem] h-80",
full: "w-full h-full",
},
aspect: {
"4:3": "aspect-[4/3]",
"16:9": "aspect-video",
"1:1": "aspect-square",
},
},
defaultVariants: {
size: "md",
aspect: "4:3",
},
})
const maskVariants = cva("absolute inset-0 pointer-events-none z-10", {
variants: {
mask: {
none: "",
square:
"bg-gradient-to-r from-black/60 via-transparent to-black/60 before:absolute before:inset-0 before:bg-gradient-to-b before:from-black/60 before:via-transparent before:to-black/60 after:absolute after:top-1/2 after:left-1/2 after:-translate-x-1/2 after:-translate-y-1/2 after:w-48 after:h-48 after:border-2 after:border-white/80 after:border-dashed after:rounded-lg",
round:
"bg-gradient-to-r from-black/60 via-transparent to-black/60 before:absolute before:inset-0 before:bg-gradient-to-b before:from-black/60 before:via-transparent before:to-black/60 after:absolute after:top-1/2 after:left-1/2 after:-translate-x-1/2 after:-translate-y-1/2 after:w-48 after:h-48 after:border-2 after:border-white/80 after:border-dashed after:rounded-full",
card: "bg-gradient-to-r from-black/60 via-transparent to-black/60 before:absolute before:inset-0 before:bg-gradient-to-b before:from-black/60 before:via-transparent before:to-black/60 after:absolute after:top-1/2 after:left-1/2 after:-translate-x-1/2 after:-translate-y-1/2 after:w-56 after:h-36 after:border-2 after:border-white/80 after:border-dashed after:rounded-2xl",
},
},
defaultVariants: {
mask: "none",
},
})
export interface CameraProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onError">,
VariantProps<typeof cameraVariants> {
// Core callbacks
onCapture?: (imageData: string | File) => void
onError?: (error: string) => void
onStart?: () => void
// Behavior props
autoStart?: boolean
showControls?: boolean
allowDownload?: boolean
silent?: boolean
// Visual props
mirror?: boolean | "preview"
mask?: "none" | "square" | "round" | "card"
// Technical props
captureFormat?: "image/jpeg" | "image/png" | "image/webp"
captureQuality?: number
outputFormat?: "base64" | "file"
}
export interface CameraRef {
startCamera: () => Promise<void>
stopCamera: () => void
toggleCamera: () => Promise<void>
capturePhoto: () => string | File | null
isActive: boolean
currentFacingMode: "user" | "environment"
isSilent: boolean
}
const Camera = React.forwardRef<CameraRef, CameraProps>(
(
{
className,
size,
aspect,
onCapture,
onError,
onStart,
autoStart = true,
showControls = true,
allowDownload = true,
silent = false,
mirror = false,
mask = "none",
captureFormat = "image/jpeg",
captureQuality = 0.9,
outputFormat = "base64",
...props
},
ref
) => {
const videoRef = React.useRef<HTMLVideoElement>(null)
const canvasRef = React.useRef<HTMLCanvasElement>(null)
const streamRef = React.useRef<MediaStream | null>(null)
const [isActive, setIsActive] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const [facingMode, setFacingMode] = React.useState<"user" | "environment">(
"environment"
)
const [capturedImage, setCapturedImage] = React.useState<string | null>(
null
)
const [permissionState, setPermissionState] = React.useState<
"granted" | "denied" | "prompt"
>("prompt")
const [isSilent, setIsSilent] = React.useState(silent)
const startCamera = React.useCallback(async () => {
setIsLoading(true)
setError(null)
try {
// Stop existing stream
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
}
const constraints: MediaStreamConstraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
// Properly handle the play() Promise to avoid interruption errors
try {
const playPromise = videoRef.current.play()
if (playPromise !== undefined) {
await playPromise
}
} catch (playError) {
console.warn("Video play was interrupted:", playError)
// Don't throw here as the stream is still valid
}
}
setIsActive(true)
setPermissionState("granted")
onStart?.()
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to access camera"
setError(errorMessage)
setPermissionState("denied")
onError?.(errorMessage)
} finally {
setIsLoading(false)
}
}, [facingMode, onError, onStart])
const stopCamera = React.useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
// Also pause the video element to prevent play() interruption
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.srcObject = null
}
setIsActive(false)
}, [])
const toggleCamera = React.useCallback(async () => {
const newFacingMode = facingMode === "user" ? "environment" : "user"
setFacingMode(newFacingMode)
if (isActive) {
// Properly stop current camera before starting new one
stopCamera()
// Small delay to ensure cleanup is complete
await new Promise(resolve => setTimeout(resolve, 100))
await startCamera()
}
}, [facingMode, isActive, startCamera, stopCamera])
const playShutterSound = React.useCallback(() => {
if (!isSilent) {
// Create a simple click sound
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.frequency.value = 800
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.1
)
oscillator.start(audioContext.currentTime)
oscillator.stop(audioContext.currentTime + 0.1)
}
}, [isSilent])
const capturePhoto = React.useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isActive) {
return null
}
const video = videoRef.current
const canvas = canvasRef.current
const context = canvas.getContext("2d")
if (!context) return null
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Handle mirroring
const shouldMirror =
mirror === true || (mirror === "preview" && facingMode === "user")
if (shouldMirror && mirror !== "preview") {
context.scale(-1, 1)
context.drawImage(video, -canvas.width, 0)
context.scale(-1, 1)
} else {
context.drawImage(video, 0, 0)
}
// Play shutter sound
playShutterSound()
let result: string | File
if (outputFormat === "file") {
// Convert canvas to blob and create File object
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], `photo-${Date.now()}.${captureFormat.split("/")[1]}`, {
type: captureFormat,
})
setCapturedImage(URL.createObjectURL(blob))
onCapture?.(file)
}
}, captureFormat, captureQuality)
return null // File creation is async
} else {
// Return base64 string
const imageData = canvas.toDataURL(captureFormat, captureQuality)
result = imageData
setCapturedImage(imageData)
onCapture?.(result)
return result
}
}, [
isActive,
onCapture,
captureFormat,
captureQuality,
mirror,
facingMode,
playShutterSound,
outputFormat,
])
const downloadImage = React.useCallback(() => {
if (!capturedImage) return
const link = document.createElement("a")
link.download = `photo-${Date.now()}.${captureFormat.split("/")[1]}`
link.href = capturedImage
link.click()
}, [capturedImage, captureFormat])
const clearCapturedImage = React.useCallback(() => {
setCapturedImage(null)
}, [])
const toggleSilent = React.useCallback(() => {
setIsSilent((prev) => !prev)
}, [])
// Auto-start camera on mount
React.useEffect(() => {
if (autoStart) {
startCamera()
}
return () => {
// Ensure proper cleanup to prevent play() interruption errors
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.srcObject = null
}
stopCamera()
}
}, [autoStart, startCamera, stopCamera])
// Expose methods via ref
React.useImperativeHandle(
ref,
() => ({
startCamera,
stopCamera,
toggleCamera,
capturePhoto,
isActive,
currentFacingMode: facingMode,
isSilent,
}),
[
startCamera,
stopCamera,
toggleCamera,
capturePhoto,
isActive,
facingMode,
isSilent,
]
)
const getVideoStyle = () => {
const shouldMirror =
mirror === true || (mirror === "preview" && facingMode === "user")
return {
transform: shouldMirror ? "scaleX(-1)" : "none",
}
}
const renderContent = () => {
if (capturedImage) {
return (
<div className="relative h-full w-full">
<img
src={capturedImage}
alt="Captured"
className="h-full w-full object-cover"
/>
{showControls && (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 transform gap-2">
{allowDownload && (
<Button
size="icon"
variant="secondary"
onClick={downloadImage}
className="bg-black/80 text-white hover:bg-black/90"
>
<Download className="size-4" />
</Button>
)}
<Button
size="icon"
variant="secondary"
onClick={clearCapturedImage}
className="bg-black/80 text-white hover:bg-black/90"
>
<X className="size-4" />
</Button>
</div>
)}
</div>
)
}
if (error) {
return (
<div className="bg-muted text-muted-foreground flex h-full flex-col items-center justify-center">
<CameraOff className="mb-4 size-12" />
<p className="px-4 text-center text-sm">{error}</p>
{permissionState === "denied" && (
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={startCamera}
>
Try Again
</Button>
)}
</div>
)
}
if (isLoading) {
return (
<div className="bg-muted flex h-full items-center justify-center">
<div className="flex flex-col items-center">
<CameraIcon className="text-muted-foreground mb-2 size-12 animate-pulse" />
<p className="text-muted-foreground text-sm">
Starting camera...
</p>
</div>
</div>
)
}
if (!isActive) {
return (
<div className="bg-muted text-muted-foreground flex h-full flex-col items-center justify-center">
<CameraIcon className="mb-4 size-12" />
<p className="mb-4 text-sm">Camera not active</p>
<Button onClick={startCamera} size="sm">
Start Camera
</Button>
</div>
)
}
return null
}
return (
<div
className={cn(cameraVariants({ size, aspect, className }))}
{...props}
>
<video
ref={videoRef}
className={cn(
"h-full w-full object-cover",
(!isActive || error || capturedImage) && "hidden"
)}
style={getVideoStyle()}
muted
playsInline
/>
<canvas ref={canvasRef} className="hidden" />
{/* Mask overlay */}
{mask !== "none" && isActive && !error && !capturedImage && (
<div className={maskVariants({ mask })} />
)}
{renderContent()}
{showControls && isActive && !error && !capturedImage && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 transform">
<Button
size="icon"
variant="default"
onClick={capturePhoto}
className="size-12 bg-white text-black hover:bg-white/90"
title="Capture Photo"
>
<CameraIcon className="size-6" />
</Button>
</div>
)}
{showControls && isActive && !error && !capturedImage && (
<div className="absolute top-4 right-4 flex gap-2">
<Button
size="icon"
variant="secondary"
onClick={toggleCamera}
className="bg-black/80 text-white hover:bg-black/90"
title="Toggle Camera"
>
<RotateCcw className="size-4" />
</Button>
<Button
size="icon"
variant="secondary"
onClick={toggleSilent}
className="bg-black/80 text-white hover:bg-black/90"
title={isSilent ? "Enable Sound" : "Disable Sound"}
>
{isSilent ? (
<VolumeX className="size-4" />
) : (
<Volume2 className="size-4" />
)}
</Button>
<Button
size="icon"
variant="secondary"
onClick={stopCamera}
className="bg-black/80 text-white hover:bg-black/90"
title="Stop Camera"
>
<CameraOff className="size-4" />
</Button>
</div>
)}
</div>
)
}
)
Camera.displayName = "Camera"
export {
Camera,
cameraVariants,
}
Usage
import { Camera } from "@/components/ui/camera"
<Camera
onCapture={(imageData) => console.log('Photo captured:', imageData)}
onError={(error) => console.error('Camera error:', error)}
/>
Examples
Output Format Adapters
The Camera component supports different output formats to suit various use cases.
Base64 String Output (Default)
Perfect for immediate display, database storage, or simple image processing.
<Camera
outputFormat="base64"
captureFormat="image/jpeg"
captureQuality={0.8}
onCapture={(imageData) => {
// imageData is a base64 string
console.log('Base64 data:', imageData)
// Example: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD..."
// Direct usage in img src
setProfileImage(imageData)
}}
/>
File Object Output
Ideal for file uploads, FormData submissions, or when you need file metadata.
<Camera
outputFormat="file"
captureFormat="image/png"
captureQuality={0.9}
onCapture={(imageData) => {
if (imageData instanceof File) {
console.log('File object:', imageData)
// Example file properties:
// imageData.name: "photo-1703847234567.png"
// imageData.size: 245760 (bytes)
// imageData.type: "image/png"
// imageData.lastModified: 1703847234567
// Direct upload to server
uploadFile(imageData)
}
}}
/>
Complete Example with Both Formats
import { Camera } from "@/components/ui/camera"
import { useState } from "react"
export function CameraExample() {
const [capturedData, setCapturedData] = useState<{
type: "base64" | "file"
data: string | File
preview?: string
} | null>(null)
const onCaptureBase64 = (imageData: string | File) => {
if (typeof imageData === "string") {
setCapturedData({
type: "base64",
data: imageData,
preview: imageData
})
}
}
const onCaptureFile = (imageData: string | File) => {
if (imageData instanceof File) {
// Convert File to base64 for preview
const reader = new FileReader()
reader.onload = (e) => {
setCapturedData({
type: "file",
data: imageData,
preview: e.target?.result as string
})
}
reader.readAsDataURL(imageData)
}
}
const uploadFile = async (file: File) => {
const formData = new FormData()
formData.append('image', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
if (response.ok) {
console.log('File uploaded successfully')
}
} catch (error) {
console.error('Upload failed:', error)
}
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Base64 Output */}
<div>
<h3>Base64 Output</h3>
<Camera
size="sm"
outputFormat="base64"
onCapture={onCaptureBase64}
/>
</div>
{/* File Output */}
<div>
<h3>File Output</h3>
<Camera
size="sm"
outputFormat="file"
onCapture={onCaptureFile}
/>
</div>
{/* Display Results */}
{capturedData && (
<div className="col-span-full">
<h4>Captured {capturedData.type.toUpperCase()}</h4>
{capturedData.preview && (
<img src={capturedData.preview} alt="Captured" />
)}
<pre>{JSON.stringify({
type: capturedData.type,
...(capturedData.data instanceof File ? {
name: capturedData.data.name,
size: capturedData.data.size,
type: capturedData.data.type
} : {
length: capturedData.data.length
})
}, null, 2)}</pre>
</div>
)}
</div>
)
}
Privacy & Camera Permissions
Enhanced camera permission handling with custom modal and cross-browser compatibility.
"use client"
import { useState, useEffect } from "react"
import { Camera } from "@/components/ui/camera"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Shield, Camera as CameraIcon, Lock, Eye, CheckCircle, XCircle } from "lucide-react"
export function CameraPermissionDemo() {
const [permissionStatus, setPermissionStatus] = useState<"prompt" | "granted" | "denied" | "checking">("checking")
const [showPermissionModal, setShowPermissionModal] = useState(false)
const [showPrivacyInfo, setShowPrivacyInfo] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [cameraStarted, setCameraStarted] = useState(false)
// Check existing camera permission on component mount
useEffect(() => {
checkCameraPermission()
}, [])
const checkCameraPermission = async () => {
try {
// Method 1: Try navigator.permissions API (more reliable)
if ('permissions' in navigator) {
try {
const result = await navigator.permissions.query({ name: 'camera' as PermissionName })
console.log('Camera permission via Permissions API:', result.state)
setPermissionStatus(result.state as "prompt" | "granted" | "denied")
return
} catch (permApiError) {
console.log('Permissions API failed, trying alternative method')
}
}
// Method 2: Try getUserMedia with very short timeout (Safari/Firefox fallback)
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1, height: 1 },
audio: false
})
// If we get here, permission is already granted
stream.getTracks().forEach(track => track.stop()) // Clean up
setPermissionStatus("granted")
console.log('Camera permission: granted (via getUserMedia test)')
} catch (testError: any) {
if (testError.name === 'NotAllowedError') {
setPermissionStatus("denied")
} else {
setPermissionStatus("prompt")
}
console.log('Camera permission: not granted yet')
}
} catch (error) {
console.log('Permission check failed:', error)
setPermissionStatus("prompt")
}
}
const handleRequestCameraAccess = () => {
if (permissionStatus === "granted") {
// Already have permission, start camera directly
setCameraStarted(true)
} else {
// Show custom modal first
setShowPermissionModal(true)
}
}
const handlePermissionModalConfirm = () => {
setShowPermissionModal(false)
// Now trigger the actual camera start which will show native browser prompt
setCameraStarted(true)
}
const handleCameraStart = () => {
setPermissionStatus("granted")
setErrorMessage(null)
console.log("Camera started successfully")
}
const handleCameraError = (error: string) => {
setCameraStarted(false)
if (error.includes("Permission denied") || error.includes("NotAllowedError")) {
setPermissionStatus("denied")
setErrorMessage("Camera access denied. Please enable camera permissions in your browser settings.")
} else if (error.includes("NotFoundError")) {
setErrorMessage("No camera found. Please connect a camera device.")
} else {
setErrorMessage(error)
}
}
const resetCamera = () => {
setCameraStarted(false)
setErrorMessage(null)
checkCameraPermission()
}
return (
<div className="w-full max-w-2xl mx-auto">
<div className="min-h-[400px] max-h-[800px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 dark:scrollbar-thumb-gray-600 dark:scrollbar-track-gray-800">
<div className="space-y-6 p-1">
{/* Permission Status Header */}
<div className="flex justify-center">
<Badge
variant={
permissionStatus === "granted" ? "default" :
permissionStatus === "denied" ? "destructive" :
permissionStatus === "checking" ? "secondary" : "outline"
}
className="gap-2 px-3 py-1"
>
{permissionStatus === "granted" && <CheckCircle className="size-4" />}
{permissionStatus === "denied" && <XCircle className="size-4" />}
{permissionStatus === "checking" && <Shield className="size-4 animate-pulse" />}
{permissionStatus === "prompt" && <Shield className="size-4" />}
Camera: {
permissionStatus === "granted" ? "Access Granted" :
permissionStatus === "denied" ? "Access Denied" :
permissionStatus === "checking" ? "Checking..." : "Permission Required"
}
</Badge>
</div>
{/* Privacy Information Card */}
<Card className="border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Shield className="size-5 text-blue-600" />
<CardTitle className="text-lg">Privacy & Camera Access</CardTitle>
</div>
<CardDescription>
We respect your privacy. Camera access is used only for photo capture and is not recorded or stored.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="gap-1">
<Lock className="size-3" />
Local Processing
</Badge>
<Badge variant="secondary" className="gap-1">
<Eye className="size-3" />
No Recording
</Badge>
<Badge variant="secondary" className="gap-1">
<Shield className="size-3" />
Privacy First
</Badge>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowPrivacyInfo(!showPrivacyInfo)}
>
{showPrivacyInfo ? "Hide" : "Show"} Privacy Details
</Button>
{showPrivacyInfo && (
<div className="text-sm text-muted-foreground space-y-2 mt-3 p-3 bg-background/50 rounded-lg max-h-32 overflow-y-auto">
<p><strong>What we access:</strong> Your device camera for live preview and photo capture</p>
<p><strong>What we don't do:</strong> Record video, store images on servers, or share data</p>
<p><strong>Your control:</strong> You can revoke permissions anytime in browser settings</p>
<p><strong>Data handling:</strong> All processing happens locally on your device</p>
</div>
)}
</CardContent>
</Card>
{/* Permission Denied Instructions */}
{permissionStatus === "denied" && (
<Alert className="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/20">
<Shield className="size-4" />
<AlertDescription className="space-y-2">
<p><strong>Camera access denied</strong></p>
<p>To enable camera access, please:</p>
<ol className="list-decimal list-inside space-y-1 text-sm ml-4">
<li>Click the camera icon in your browser's address bar</li>
<li>Select "Allow" for camera access</li>
<li>Click "Try Again" below</li>
</ol>
<Button
variant="outline"
size="sm"
onClick={resetCamera}
className="mt-2"
>
Try Again
</Button>
</AlertDescription>
</Alert>
)}
{/* Error Messages */}
{errorMessage && (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
<CameraIcon className="size-4" />
<AlertDescription className="break-words">{errorMessage}</AlertDescription>
</Alert>
)}
{/* Camera Component or Start Button */}
<div className="flex justify-center">
{cameraStarted ? (
<div className="space-y-4 w-full max-w-md mx-auto">
<div className="overflow-hidden rounded-lg flex justify-center">
<Camera
onStart={handleCameraStart}
onError={handleCameraError}
onCapture={(imageData) => {
console.log("Photo captured with privacy protection:", imageData.slice(0, 50) + "...")
}}
autoStart={true}
/>
</div>
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={resetCamera}
>
Stop Camera
</Button>
</div>
</div>
) : (
<Button
onClick={handleRequestCameraAccess}
className="gap-2"
disabled={permissionStatus === "checking"}
>
<CameraIcon className="size-4" />
{permissionStatus === "checking" ? "Checking Permissions..." : "Start Camera"}
</Button>
)}
</div>
</div>
</div>
{/* Custom Permission Modal */}
<Dialog open={showPermissionModal} onOpenChange={setShowPermissionModal}>
<DialogContent className="sm:max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CameraIcon className="size-5" />
Camera Permission Required
</DialogTitle>
<DialogDescription className="space-y-3">
<p>
This application needs access to your camera to capture photos.
Your browser will ask for permission in the next step.
</p>
<div className="bg-muted p-3 rounded-lg space-y-2 max-h-40 overflow-y-auto">
<p className="font-medium text-sm">What happens next:</p>
<ol className="list-decimal list-inside text-sm space-y-1 ml-2">
<li>Click "Allow Camera Access" below</li>
<li>Your browser will show a permission prompt</li>
<li>Click "Allow" in the browser prompt</li>
<li>Camera will start automatically</li>
</ol>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Shield className="size-4" />
<span>Your privacy is protected - no data is stored or shared</span>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowPermissionModal(false)}
>
Cancel
</Button>
<Button
onClick={handlePermissionModalConfirm}
className="gap-2"
>
<CameraIcon className="size-4" />
Allow Camera Access
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
Permission Flow
The camera component includes an intelligent permission flow:
- Permission Check: Automatically detects if camera access is already granted
- Custom Modal: Shows user-friendly explanation before browser prompt (only when needed)
- Native Prompt: Triggers browser's permission dialog after user confirms
- Direct Access: If already granted, skips modal and starts camera immediately
Cross-Browser Support
- Chrome/Edge: Uses Permissions API for reliable permission detection
- Safari: Falls back to getUserMedia test for compatibility
- Firefox: Full Permissions API support with enhanced error handling
- Mobile Browsers: Optimized for touch interfaces and mobile permissions
Enhanced UX Features
- ✅ Smart Modal: Only shows when permission needed (not for returning users)
- ✅ Clear Instructions: Step-by-step guidance for granting permissions
- ✅ Recovery Flow: Easy retry mechanism for denied permissions
- ✅ Privacy Badges: Visual indicators of data protection policies
- ✅ Permission Status: Real-time display of current access level
Different Sizes
Choose from predefined size variants to fit your layout needs.
Default Size
Default Size
Standard camera size (320x240px) - perfect for most use cases
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraSizeDefaultDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Default Size</h3>
<p className="text-sm text-muted-foreground">
Standard camera size (320x240px) - perfect for most use cases
</p>
</div>
<Camera
size="md"
autoStart={false}
onCapture={(imageData) => {
console.log("Default size photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Small Size
Small Size
Compact camera size (256x192px) - ideal for thumbnails and tight spaces
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraSizeSmallDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Small Size</h3>
<p className="text-sm text-muted-foreground">
Compact camera size (256x192px) - ideal for thumbnails and tight spaces
</p>
</div>
<Camera
size="sm"
autoStart={false}
onCapture={(imageData) => {
console.log("Small size photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Large Size
Large Size
Large camera size (384x288px) - great for detailed photo capture
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraSizeLargeDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Large Size</h3>
<p className="text-sm text-muted-foreground">
Large camera size (384x288px) - great for detailed photo capture
</p>
</div>
<Camera
size="lg"
autoStart={false}
onCapture={(imageData) => {
console.log("Large size photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Extra Large Size
Extra Large Size
Extra large camera size (448x320px) - perfect for high-quality captures
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraSizeXlDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Extra Large Size</h3>
<p className="text-sm text-muted-foreground">
Extra large camera size (448x320px) - perfect for high-quality captures
</p>
</div>
<Camera
size="xl"
autoStart={false}
onCapture={(imageData) => {
console.log("XL size photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Aspect Ratios
Choose from different aspect ratios to fit your design requirements.
4:3 Traditional
4:3 Aspect Ratio
Traditional camera aspect ratio - ideal for standard photography
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraAspect43Demo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">4:3 Aspect Ratio</h3>
<p className="text-sm text-muted-foreground">
Traditional camera aspect ratio - ideal for standard photography
</p>
</div>
<Camera
aspect="4:3"
autoStart={false}
onCapture={(imageData) => {
console.log("4:3 aspect photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
16:9 Widescreen
16:9 Aspect Ratio
Widescreen format - perfect for cinematic and landscape shots
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraAspect169Demo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">16:9 Aspect Ratio</h3>
<p className="text-sm text-muted-foreground">
Widescreen format - perfect for cinematic and landscape shots
</p>
</div>
<Camera
aspect="16:9"
autoStart={false}
onCapture={(imageData) => {
console.log("16:9 aspect photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
1:1 Square
1:1 Aspect Ratio
Square format - ideal for profile photos and social media
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraAspect11Demo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">1:1 Aspect Ratio</h3>
<p className="text-sm text-muted-foreground">
Square format - ideal for profile photos and social media
</p>
</div>
<Camera
aspect="1:1"
autoStart={false}
onCapture={(imageData) => {
console.log("1:1 aspect photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Mirror Modes
Mirror mode allows you to flip the camera preview and/or the captured image.
No Mirror
No Mirror
Default behavior - no mirroring in preview or capture
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraMirrorFalseDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">No Mirror</h3>
<p className="text-sm text-muted-foreground">
Default behavior - no mirroring in preview or capture
</p>
</div>
<Camera
mirror={false}
autoStart={false}
onCapture={(imageData) => {
console.log("No mirror photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Mirror All
Mirror All
Mirror both preview and captured image - useful for selfies
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraMirrorTrueDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Mirror All</h3>
<p className="text-sm text-muted-foreground">
Mirror both preview and captured image - useful for selfies
</p>
</div>
<Camera
mirror={true}
autoStart={false}
onCapture={(imageData) => {
console.log("Mirror all photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Mirror Preview Only
Mirror Preview Only
Mirror preview but capture normal image - best user experience
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraMirrorPreviewDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Mirror Preview Only</h3>
<p className="text-sm text-muted-foreground">
Mirror preview but capture normal image - best user experience
</p>
</div>
<Camera
mirror="preview"
autoStart={false}
onCapture={(imageData) => {
console.log("Mirror preview photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Mask Overlays
Add overlay masks to guide photo composition for specific use cases.
Square Mask
Square Mask
Perfect for profile photos and square avatars
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraMaskSquareDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Square Mask</h3>
<p className="text-sm text-muted-foreground">
Perfect for profile photos and square avatars
</p>
</div>
<Camera
mask="square"
autoStart={false}
onCapture={(imageData) => {
console.log("Square mask photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Round Mask
Round Mask
Ideal for circular profile pictures and user avatars
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraMaskRoundDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Round Mask</h3>
<p className="text-sm text-muted-foreground">
Ideal for circular profile pictures and user avatars
</p>
</div>
<Camera
mask="round"
autoStart={false}
onCapture={(imageData) => {
console.log("Round mask photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Card Mask
Card Mask
Great for ID cards, licenses, and document photography
Camera not active
"use client"
import { Camera } from "@/components/ui/camera"
export function CameraMaskCardDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Card Mask</h3>
<p className="text-sm text-muted-foreground">
Great for ID cards, licenses, and document photography
</p>
</div>
<Camera
mask="card"
autoStart={false}
onCapture={(imageData) => {
console.log("Card mask photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
)
}
Manual Control
Camera not active
"use client"
import { useRef } from "react"
import { Camera, type CameraRef } from "@/components/ui/camera"
import { Button } from "@/components/ui/button"
export function CameraManualControlDemo() {
const cameraRef = useRef<CameraRef>(null)
const startCamera = () => {
cameraRef.current?.startCamera()
}
const stopCamera = () => {
cameraRef.current?.stopCamera()
}
const toggleCamera = () => {
cameraRef.current?.toggleCamera()
}
const capturePhoto = () => {
const imageData = cameraRef.current?.capturePhoto()
if (imageData) {
console.log("Photo captured manually:", imageData.slice(0, 50) + "...")
}
}
return (
<div className="w-full max-w-lg mx-auto">
<div className="space-y-4">
<div className="flex justify-center">
<Camera
ref={cameraRef}
autoStart={false}
showControls={false}
onCapture={(imageData) => {
console.log("Photo captured:", imageData.slice(0, 50) + "...")
}}
/>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={startCamera} variant="outline" size="sm">
Start Camera
</Button>
<Button onClick={stopCamera} variant="outline" size="sm">
Stop Camera
</Button>
<Button onClick={toggleCamera} variant="outline" size="sm">
Toggle Camera
</Button>
<Button onClick={capturePhoto} size="sm">
Capture Photo
</Button>
</div>
</div>
</div>
)
}
Custom Capture Handler
"use client"
import { useState } from "react"
import { Camera } from "@/components/ui/camera"
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
export function CameraCustomCaptureDemo() {
const [capturedPhotos, setCapturedPhotos] = useState<string[]>([])
const onCapture = (imageData: string) => {
setCapturedPhotos(prev => [...prev, imageData])
}
const removePhoto = (index: number) => {
setCapturedPhotos(prev => prev.filter((_, i) => i !== index))
}
const clearAll = () => {
setCapturedPhotos([])
}
return (
<div className="w-full max-w-4xl mx-auto">
<div className="max-h-[700px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 dark:scrollbar-thumb-gray-600 dark:scrollbar-track-gray-800">
<div className="space-y-6 p-1">
<div className="flex justify-center">
<Camera
onCapture={onCapture}
onError={(error) => {
console.error("Camera error:", error)
}}
mask="square"
captureQuality={0.8}
/>
</div>
{capturedPhotos.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">
Captured Photos ({capturedPhotos.length})
</h3>
<Button onClick={clearAll} variant="outline" size="sm">
Clear All
</Button>
</div>
<div className="max-h-80 overflow-y-auto rounded-lg border bg-muted/20 p-3">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{capturedPhotos.map((photo, index) => (
<div key={index} className="relative group">
<img
src={photo}
alt={`Captured photo ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border"
/>
<Button
onClick={() => removePhoto(index)}
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
Privacy & Security
Enhanced Camera Permission Flow
The camera component features an intelligent, privacy-first permission system:
Smart Permission Detection
// Automatic permission checking on component mount
useEffect(() => {
checkCameraPermission() // Multi-method browser detection
}, [])
Custom Modal Before Native Prompt
- When needed: Shows custom explanation modal before browser prompt
- When not needed: Direct camera access for returning users with permissions
- Cross-browser: Works consistently across Chrome, Safari, Firefox, Edge
Permission States Handled
checking
: Detecting current permission statusprompt
: Shows custom modal → triggers native browser promptgranted
: Direct camera access, no modal neededdenied
: Clear recovery instructions with retry mechanism
Camera Access Permission Flow
The camera component includes comprehensive permission handling with privacy-first approach:
- Clear Permission Requests: Custom modal explains camera usage before native prompt
- Privacy Information: Transparent communication about data handling
- Error Recovery: Graceful handling of permission denied states with retry options
- Local Processing: All camera processing happens client-side only
Privacy Features
- 🔒 Local Processing: No data sent to servers
- 🚫 No Recording: Only captures individual photos
- 👁️ Transparent Usage: Clear privacy information displayed
- ⚡ Instant Revocation: Users can disable camera access anytime
- 🛡️ Secure by Default: HTTPS required in production
- 🎯 Smart UX: Modal only shows when permission actually needed
// Enhanced privacy-focused camera usage
<Camera
onStart={() => console.log('Camera started with user consent')}
onError={(error) => {
// Handle permission denied gracefully with recovery options
if (error.includes('Permission denied')) {
showPermissionRecoveryFlow()
}
}}
autoStart={false} // Requires explicit user action
/>
API Reference
Camera
Prop | Type | Default | Description |
---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | "md" | Size variant of the camera component |
aspect | "4:3" | "16:9" | "1:1" | "4:3" | Aspect ratio of the camera |
onCapture | (imageData: string | File) => void | - | Callback when a photo is captured |
onError | (error: string) => void | - | Callback when an error occurs |
onStart | () => void | - | Callback when camera starts |
autoStart | boolean | true | Whether to automatically start the camera |
showControls | boolean | true | Whether to show camera controls |
allowDownload | boolean | true | Whether to allow downloading captured photos |
silent | boolean | false | Whether to disable shutter sound |
mirror | boolean | "preview" | false | Mirror mode: true (all), "preview" (preview only), false (none) |
mask | "none" | "square" | "round" | "card" | "none" | Overlay mask for guided composition |
outputFormat | "base64" | "file" | "base64" | Output format: base64 string or File object |
captureFormat | "image/jpeg" | "image/png" | "image/webp" | "image/jpeg" | Image format for capture |
captureQuality | number | 0.9 | Image quality (0.0 to 1.0) |
Output Format Comparison
Format | Type | Use Cases | Pros | Cons |
---|---|---|---|---|
base64 | string | Display, database storage, simple processing | Immediate usage, no conversion needed | Large string size, not ideal for uploads |
file | File | File uploads, FormData, server submissions | Proper file handling, metadata included | Requires conversion for display |
Example Values
Base64 Output
// Example base64 string (truncated)
"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
// Length: typically 50,000-200,000 characters
File Output
// Example File object properties
{
name: "photo-1703847234567.png",
size: 245760,
type: "image/png",
lastModified: 1703847234567,
// Methods: stream(), text(), arrayBuffer(), etc.
}
Size Variants
Size | Dimensions | Use Case |
---|---|---|
sm | 256×192px | Thumbnails, compact spaces |
md | 320×240px | Default, most use cases |
lg | 384×288px | Detailed captures |
xl | 448×320px | High-quality photos |
full | 100% container | Full-screen camera |
Browser Support
- Chrome: ✅ Full support
- Firefox: ✅ Full support
- Safari: ✅ Full support (iOS 11+)
- Edge: ✅ Full support
Note: Camera access requires HTTPS in production environments and user permission.
Accessibility
- Proper ARIA labels for camera controls
- Keyboard navigation support
- Screen reader announcements for state changes
- High contrast mode compatibility
Keyboard Interactions
Key | Description |
---|---|
SpaceEnter | When focus is on the capture button, takes a photo. |
Tab | Moves focus between camera controls (capture, toggle, sound, stop). |
Escape | Stops the camera when active. |
Examples in Production
Profile Photo Capture
<Camera
mask="square"
aspect="1:1"
mirror="preview"
onCapture={(imageData) => {
// Upload to your backend
uploadProfilePhoto(imageData)
}}
/>
ID Card Scanning
<Camera
mask="card"
aspect="16:9"
captureQuality={1.0}
onCapture={(imageData) => {
// Process ID card
processIDCard(imageData)
}}
/>
Avatar Creation
<Camera
mask="round"
aspect="1:1"
size="sm"
onCapture={(imageData) => {
// Create avatar
createAvatar(imageData)
}}
/>