ACF UI
Components

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.

Camera: Checking...
Privacy & Camera Access
We respect your privacy. Camera access is used only for photo capture and is not recorded or stored.
Local ProcessingNo RecordingPrivacy First
"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:

  1. Permission Check: Automatically detects if camera access is already granted
  2. Custom Modal: Shows user-friendly explanation before browser prompt (only when needed)
  3. Native Prompt: Triggers browser's permission dialog after user confirms
  4. 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

Camera not active

"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 status
  • prompt: Shows custom modal → triggers native browser prompt
  • granted: Direct camera access, no modal needed
  • denied: 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

PropTypeDefaultDescription
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
autoStartbooleantrueWhether to automatically start the camera
showControlsbooleantrueWhether to show camera controls
allowDownloadbooleantrueWhether to allow downloading captured photos
silentbooleanfalseWhether to disable shutter sound
mirrorboolean | "preview"falseMirror 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
captureQualitynumber0.9Image quality (0.0 to 1.0)

Output Format Comparison

FormatTypeUse CasesProsCons
base64stringDisplay, database storage, simple processingImmediate usage, no conversion neededLarge string size, not ideal for uploads
fileFileFile uploads, FormData, server submissionsProper file handling, metadata includedRequires 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

SizeDimensionsUse Case
sm256×192pxThumbnails, compact spaces
md320×240pxDefault, most use cases
lg384×288pxDetailed captures
xl448×320pxHigh-quality photos
full100% containerFull-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

KeyDescription
SpaceEnterWhen focus is on the capture button, takes a photo.
TabMoves focus between camera controls (capture, toggle, sound, stop).
EscapeStops 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)
  }}
/>