Kipachu
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:

  • motion
  • lucide-react
  • ts-pattern
  • tailwindcss

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).
  • onPhotoCapture fires immediately after capture with a JPEG data URL, before the Blob is ready.
  • onPhoto fires 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>
);