Components
Camera
In-browser camera with capture preview, confirm, and switch mode.
Overview
Camera provides a self-contained camera view that requests getUserMedia, shows a live feed, lets users switch between front and rear cameras, captures a still, and confirms the result. It exposes both a data URL preview callback and a Blob callback for uploads.
Installation
Just copy and paste
Dependencies used by the camera:
motionlucide-reactts-patterntailwindcss
Usage
"use client";
import Camera from "@/components/camera/camera";
export function CameraUsage() {
return (
<div className="h-[520px] w-full">
<Camera
onPhotoCapture={(dataUrl) => {
console.log("Preview data URL:", dataUrl);
}}
onPhoto={(blob) => {
// Upload the blob, persist it, etc.
console.log("Blob size:", blob.size);
}}
/>
</div>
);
}Notes:
- The component needs a parent with an explicit height. It fills the available space.
- Browser camera access requires user permission and a secure context (https or localhost).
onPhotoCapturefires immediately after capture with a JPEG data URL, before the Blob is ready.onPhotofires after the user confirms the photo.
Demo
Source
camera.tsx
import { useEffect, useRef, useState } from "react";
import { Button } from "../ui/button";
import {
Aperture,
Check,
SwitchCamera,
Loader2,
AlertCircle,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { match } from "ts-pattern";
import { cn } from "@/lib/utils";
type CameraState = "initing" | "ready" | "switching-mode" | "error";
type TCameraProps = {
onPhoto?: (data: Blob) => void;
onPhotoCapture?: (data: string) => void;
} & React.HTMLAttributes<HTMLDivElement>;
const Camera: React.FC<TCameraProps> = ({
className,
onPhoto,
onPhotoCapture,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageBlob = useRef<Blob | null>(null);
const [cameraState, setCameraState] = useState<CameraState>("initing");
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [facingMode, setFacingMode] = useState<"user" | "environment">("user");
const stopStream = () => {
const stream = videoRef.current?.srcObject as MediaStream | null;
stream?.getTracks().forEach((track) => track.stop());
if (videoRef.current) {
videoRef.current.srcObject = null;
}
};
useEffect(() => {
setCameraState("initing");
navigator.mediaDevices
.getUserMedia({
video: {
facingMode,
width: { ideal: 1920, max: 3840 },
height: { ideal: 1080, max: 2160 },
frameRate: { ideal: 30, max: 60 },
},
audio: false,
})
.then((stream) => {
if (!videoRef.current) return;
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => {
setCameraState("ready");
};
})
.catch((err) => {
console.error("Camera access failed:", err);
setCameraState("error");
});
return stopStream;
}, [facingMode]);
const switchCamera = () => {
stopStream();
setCameraState("switching-mode");
setFacingMode((prev) => (prev === "user" ? "environment" : "user"));
};
const takePhoto = async () => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
imageBlob.current = null;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
onPhotoCapture?.(dataUrl);
setCapturedImage(dataUrl);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.9)
);
if (blob) imageBlob.current = blob;
};
const confirm = () => {
if (!imageBlob.current) return;
onPhoto?.(imageBlob.current);
imageBlob.current = null;
setCapturedImage(null);
};
const rotation = Math.random() > 0.5 ? 10 : -10;
return (
<div
className={cn(
"relative w-full h-full rounded-[32px] overflow-hidden bg-background",
className
)}
>
{/* Status overlays */}
<AnimatePresence initial={false} mode="wait">
{match(cameraState)
.with("initing", () => (
<StatusOverlay status="loading" text="Initializing camera..." />
))
.with("switching-mode", () => (
<StatusOverlay status="loading" text="Switching camera..." />
))
.with("error", () => (
<StatusOverlay status="error" text="Camera unavailable" />
))
.otherwise(() => null)}
</AnimatePresence>
{/* Captured preview */}
<AnimatePresence>
{capturedImage && (
<motion.img
key={capturedImage}
src={capturedImage}
initial={{ opacity: 0, y: 20, scale: 0.5 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
rotate: rotation,
}}
exit={{ opacity: 0, y: 20, scale: 0.5 }}
className="absolute aspect-3/4 top-4 left-1/2 -translate-x-1/2 z-10 w-24 rounded-xl border-4 border-foreground object-cover"
/>
)}
</AnimatePresence>
{/* Camera feed */}
<video
ref={videoRef}
autoPlay
playsInline
className="absolute inset-0 my-0! w-full h-full object-cover"
/>
{/* Switch camera */}
<Button
variant="outline"
aria-label="Switch camera"
onClick={switchCamera}
className="absolute top-4 right-4 bg-background/30 backdrop-blur-xs"
>
<SwitchCamera className="size-5" />
</Button>
<canvas ref={canvasRef} hidden />
{/* Controls */}
<motion.div className="absolute bottom-6 inset-x-0 grid grid-cols-3 place-items-center">
<motion.button
type="button"
aria-label="Take photo"
onClick={takePhoto}
disabled={cameraState !== "ready"}
whileTap={{ scale: 1.1 }}
className="col-start-2 flex items-center h-10 px-4 rounded-lg border bg-background/30 backdrop-blur-xs disabled:opacity-50"
>
<Aperture className="size-12" />
</motion.button>
{capturedImage && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<Button size="lg" aria-label="Confirm photo" onClick={confirm}>
<Check />
Confirm
</Button>
</motion.div>
)}
</motion.div>
</div>
);
};
export default Camera;
type StatusOverlayProps = {
status: "loading" | "error";
text: string;
};
const StatusOverlay = ({ text, status }: StatusOverlayProps) => (
<motion.div
className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-inherit z-20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{match(status)
.with("loading", () => <Loader2 className="size-8 animate-spin" />)
.with("error", () => <AlertCircle className="size-8 text-destructive" />)
.exhaustive()}
<span className="text-sm">{text}</span>
</motion.div>
);