EmpireUI
Get Pro
← Blog8 min read#react#webcam#camera

Camera and Webcam in React: Capture, Preview, Upload

Access webcam streams in React, capture frames, preview images, and upload to a server — all without a bloated library. Here's exactly how it works.

Close-up of a webcam on a desk with soft background blur, representing browser-based camera access in React

Why Most Camera Tutorials Get It Wrong

Honestly, most React webcam tutorials hand you react-webcam and call it a day. That library is fine for quick demos, but it's 40 kB minified, it abstracts away everything you'd want to control, and when something breaks you're stuck reading someone else's source code. If you're building a real feature — avatar upload, document scanner, ID verification — you need to understand the underlying browser APIs anyway.

The good news: the raw API is simpler than you think. getUserMedia, a <video> element, and a <canvas> for frame extraction. That's the whole stack. We're going to build the full flow from permission request to file upload in one component, with TypeScript throughout.

We'll also look at common edge cases — users who deny permissions, mobile rear cameras, stopping the stream correctly so you don't leave the red camera indicator on forever. These are the parts tutorials skip.

Requesting Camera Access with getUserMedia

The browser's MediaDevices.getUserMedia() API returns a Promise<MediaStream>. You attach that stream to a <video> element's srcObject and call .play(). That's it. The tricky part is lifecycle management inside a React component — you need to stop the stream on unmount or you'll leak active tracks.

Here's a minimal hook that handles the full lifecycle:

import { useEffect, useRef, useState } from 'react';

export function useWebcam() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [error, setError] = useState<string | null>(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    let stream: MediaStream | null = null;

    async function start() {
      try {
        stream = await navigator.mediaDevices.getUserMedia({
          video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
          audio: false,
        });
        if (videoRef.current) {
          videoRef.current.srcObject = stream;
          await videoRef.current.play();
          setReady(true);
        }
      } catch (err) {
        if (err instanceof DOMException && err.name === 'NotAllowedError') {
          setError('Camera permission denied. Please allow access and reload.');
        } else {
          setError('Could not start camera.');
        }
      }
    }

    start();

    return () => {
      stream?.getTracks().forEach(t => t.stop());
      setReady(false);
    };
  }, []);

  return { videoRef, ready, error };
}

Notice the cleanup function calls t.stop() on every track. If you skip that, the browser keeps the camera active even after the component unmounts. Users will see the active camera indicator and rightfully distrust your app. Small detail, big UX consequence.

Rendering the Live Preview with Correct Aspect Ratio

A <video> element with autoPlay and playsInline (required on iOS) is all you need for the preview. Set playsInline always — without it Safari on iPhone will fullscreen the video and break your layout entirely.

For styling, don't fight the browser's natural aspect ratio. Use object-fit: cover and a fixed container with aspect-ratio: 16/9. If you're building a square avatar cropper, switch to aspect-ratio: 1/1. With Tailwind v4.0.2 that's just aspect-video or aspect-square on the wrapper.

<div className="relative w-full max-w-xl mx-auto aspect-video bg-zinc-900 rounded-2xl overflow-hidden">
  <video
    ref={videoRef}
    autoPlay
    playsInline
    muted
    className="w-full h-full object-cover"
  />
  {!ready && (
    <div className="absolute inset-0 flex items-center justify-center text-zinc-400 text-sm">
      Starting camera…
    </div>
  )}
</div>

The muted attribute matters here too. Even though we're not capturing audio, some browsers treat an unmuted video element as a request for audio permissions. Muting it keeps the permission dialog clean — camera only.

Capturing a Frame to Canvas and Getting a Blob

When the user clicks the shutter button, you draw the current video frame onto a canvas, then call canvas.toBlob(). This is how you get a File or Blob you can upload. The key detail: set the canvas dimensions to match the video's actual stream resolution, not the CSS size of the element.

function captureFrame(
  video: HTMLVideoElement,
  mimeType = 'image/jpeg',
  quality = 0.92
): Promise<Blob | null> {
  return new Promise(resolve => {
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;   // real stream pixels, not CSS px
    canvas.height = video.videoHeight;
    const ctx = canvas.getContext('2d');
    if (!ctx) return resolve(null);
    ctx.drawImage(video, 0, 0);
    canvas.toBlob(resolve, mimeType, quality);
  });
}

Quality 0.92 gives you a good balance — roughly 60-80 kB for a 720p frame. Drop it to 0.75 if you're uploading avatars that'll be displayed at 128px anyway. Going above 0.95 for JPEG rarely improves visible quality but adds noticeable file size.

If you need PNG (lossless, for document scanning), pass 'image/png' and drop the quality argument — PNG ignores it. The file will be significantly larger. For most UI use cases — profile photos, selfies — JPEG at 0.92 is the right call.

Showing a Preview of the Captured Image Before Upload

After capture, show the user what they got before sending it to your server. Create an object URL from the blob with URL.createObjectURL(blob) and use that as an <img> src. Remember to revoke it on cleanup — otherwise you're leaking memory on every capture.

This pattern pairs well with a "retake" button that goes back to the live feed. Toggle a piece of state between 'preview' and 'live' and conditionally render either the <video> or an <img>. For the overlay effect on the capture moment, a quick flash animation — rgba(255,255,255,0.15) opacity burst over 120ms — gives tactile feedback that the photo was taken.

If you want fancier UI affordances like a camera shutter overlay or frosted glass result card, check out the glassmorphism patterns in what is glassmorphism — the backdrop-filter: blur(12px) approach works well for the preview overlay without needing extra DOM elements.

Uploading the Captured Frame to Your Server

Upload the blob as multipart/form-data using FormData. Construct a File from your blob so the server receives a proper filename and MIME type — some server-side parsers reject blobs without a filename.

async function uploadCapture(blob: Blob, endpoint: string): Promise<{ url: string }> {
  const file = new File([blob], `capture-${Date.now()}.jpg`, { type: 'image/jpeg' });
  const form = new FormData();
  form.append('file', file);

  const res = await fetch(endpoint, { method: 'POST', body: form });
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
  return res.json();
}

What about progress tracking? fetch doesn't expose upload progress natively. If you need a progress bar — useful for large captures or slow connections — use XMLHttpRequest with the upload.onprogress event, or reach for a library like axios which wraps this. For most avatar/ID flows, the file is small enough that a spinner is sufficient.

On the server side, make sure you validate the MIME type and file size. Don't trust the type field from the client — re-validate with something like file-type in Node.js. Set a hard cap (say, 5 MB) to prevent abuse. If you're wiring this into a form with validation, react-hook-form handles file inputs cleanly and works well alongside a custom camera component.

Switching Between Front and Rear Camera on Mobile

On mobile, facingMode: 'user' gives you the selfie camera and facingMode: 'environment' gives you the rear camera. To switch, you have to stop the current stream and call getUserMedia again with the new constraint. You can't change facingMode on a running stream.

Store the current facingMode in state, add a toggle button, and re-run the useEffect when it changes. Pass facingMode as a dependency to the effect. The hook from the first section already handles cleanup on re-run, so this works automatically.

Worth noting: desktop browsers usually only have one camera, so facingMode: 'environment' on a laptop just falls back to the webcam. That's fine — the constraint is a hint, not a requirement. Only add the camera-switch UI if you're targeting mobile layouts specifically. You can detect mobile at runtime with navigator.mediaDevices.enumerateDevices() and only show the toggle if more than one video input exists.

Putting It All Together: The Full Camera Component

Here's how the pieces compose into a single <CameraCapture /> component. It handles stream start, live preview, capture, image preview, and the upload trigger. For a real product you'd split these into smaller components, but seeing the flow in one place makes the data flow clearer.

The state machine is simple: idlelivepreview → back to live on retake, or uploadingdone. Avoid null states — always know what phase you're in. This is the kind of explicit state modeling that TypeScript tips covers well with discriminated unions.

For notification feedback after upload succeeds or fails, dropping in a toast from react-toast-notifications is a two-line addition. Don't build your own in-component alert — reuse whatever notification system your app already has. The camera component shouldn't own that concern.

Is this more code than dropping in react-webcam? Yes. Is it worth it? For anything that goes to production: absolutely. You understand every layer, you control the quality settings, and you don't carry an extra dependency that you can't audit.

FAQ

Why does the camera permission prompt not appear on localhost?

It does on most browsers, but only over HTTPS or localhost. If you're testing on a local IP (like 192.168.x.x), browsers treat it as insecure and block getUserMedia entirely. Either use localhost, set up a self-signed cert, or tunnel with something like ngrok to get HTTPS.

How do I stop the camera indicator light after unmounting the component?

Call track.stop() on every track in the MediaStream inside your useEffect cleanup function. If you only clear videoRef.current.srcObject, the stream stays active. You need stream.getTracks().forEach(t => t.stop()) — then the indicator goes off.

Can I capture video clips, not just still images?

Yes, via the MediaRecorder API. Attach your MediaStream to a new MediaRecorder(stream, { mimeType: 'video/webm' }), collect chunks in the ondataavailable event, then assemble them into a Blob on onstop. It's more involved than still capture but uses the same stream you already have.

What's the best resolution to request for an avatar upload feature?

Request { width: { ideal: 640 }, height: { ideal: 640 } } for avatars. Anything higher is wasted since you'll display at 64–256px anyway. Smaller frame = smaller canvas = smaller JPEG blob = faster upload. Capture at JPEG quality 0.88–0.92 for a good result under 100 kB.

Does this work in a React Native app?

No — getUserMedia is a browser API. In React Native you'd use react-native-camera or expo-camera. The concepts are similar (stream, capture, upload) but the APIs are completely different. This article covers React for the web only.

Why does `canvas.toBlob()` return null sometimes?

Usually because the canvas is tainted by a cross-origin resource, or the getContext('2d') call failed (can happen if the browser is in a low-memory state). Also check that video.videoWidth is not 0 — if you call captureFrame before the stream is fully ready, the canvas will be 0x0 and toBlob returns null. Wait for the ready state from your hook before enabling the shutter button.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Signature Pad in React: Canvas Drawing ComponentReact Architecture & Patterns: The Complete 2026 GuideReact UI Components Complete Reference: 60+ Patterns with CodeBuilding Design Systems That Scale: Engineering Guide 2026