Media

Camera Capture

A real getUserMedia webcam capturer — live preview, countdown shutter, mirrored canvas export, and a keep/retake review step that hands back a JPEG Blob.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertTriangle, Camera, CameraOff, Check, Loader2, RotateCcw, SwitchCamera, X } from "lucide-react"
import { cn } from "@/lib/utils"

export type CameraCaptureStatus =
  | "unsupported"
  | "idle"
  | "requesting"
  | "ready"
  | "countdown"
  | "captured"
  | "denied"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/camera-capture.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CameraCapture" component on the real
browser getUserMedia + canvas APIs — a live camera preview that grabs a still
frame and hands the consumer a genuine image Blob.

Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: onCapture(blob: Blob, dataUrl: string) — required, fires when the
  user *keeps* a shot (both arguments are the same frame: the JPEG blob for
  upload, the data URL for instant preview); facingMode?: "user" |
  "environment" (default "user", the preference for the *first* stream only —
  after that the switch button owns which camera is live); aspect (default
  4/3, drives both the preview box and the exported image); outputWidth?
  (default: the native center-cropped width, i.e. no rescale; height follows
  aspect); quality (default 0.92, JPEG only); mirrored? (defaults to true
  while the front camera is live); countdown (default 0 = shoot immediately,
  otherwise N seconds of 3-2-1 before the frame is grabbed);
  showSwitchCamera (default true, but the button only renders when more than
  one video input actually exists); disabled; labels (partial copy override).
- Clamp every numeric prop before use: aspect <= 0 falls back to 4/3 (a 0
  aspect makes a 0px canvas), outputWidth is only honored at >= 16px and
  capped, quality is clamped into 0.01..1, countdown is floored to a whole
  number of seconds and capped — a fractional countdown would tick forever.

Behavior — state machine
- States: unsupported | idle | requesting | ready | countdown | captured |
  denied | error.
- unsupported is a capability read (`typeof
  navigator.mediaDevices?.getUserMedia === "function"`), not a state anything
  else transitions into. Read it with useSyncExternalStore and a `false`
  server snapshot: capability checks cannot happen during render — the server
  always renders "no camera", so reading navigator at render time would
  mismatch the markup on every browser that does have one.
- idle → "Start camera" → requesting → getUserMedia({video: {facingMode:
  {ideal}}, audio: false}). Resolve → attach the stream to a
  <video autoplay playsinline muted> via srcObject, await play(), read the
  granted track's getSettings() for the real facingMode/deviceId → ready.
  NotAllowedError/PermissionDeniedError/SecurityError → denied (with a Retry
  that really calls getUserMedia again); anything else → error (+ the caught
  message and a Retry).
- ready → shutter → if countdown is 0, grab the frame immediately; otherwise
  enter countdown, tick a 1s interval down to 0 and then grab. The remaining
  seconds live in a ref that the interval decrements, and only the *display*
  goes through setState — never decrement inside a setState updater, updaters
  must stay pure (StrictMode would run them twice). The countdown is
  cancellable and returns to ready.
- Grabbing a frame: read videoWidth/videoHeight (0 means no frame has arrived
  yet → error state, never a blank photo), center-crop that native frame to
  `aspect`, create an offscreen canvas of outputWidth × outputWidth/aspect,
  and — when mirrored — translate+scale(-1,1) before drawImage so the export
  matches the CSS-mirrored preview instead of being flipped. Then
  canvas.toDataURL for the review image and canvas.toBlob for the payload.
  toBlob can hand back null (encode failure) — that must set the error state,
  not silently do nothing.
- captured shows the still over the still-running preview with "Retake"
  (drops the shot, straight back to ready — the stream is deliberately kept
  alive so retaking never re-prompts) and "Keep photo" (calls
  onCapture(blob, dataUrl), then returns to ready for the next shot).
- Switching cameras: stop the old stream *before* requesting the new one —
  two live streams on one device fail on mobile and light two indicators on
  desktop. enumerateDevices only returns real deviceIds/labels *after* a
  grant, so call it once a stream is live and use the videoinput count to
  decide whether the switch button renders at all. Switch by cycling explicit
  deviceIds (facingMode is only a hint on desktop and would not actually
  change camera). The newly granted track's getSettings().facingMode, when
  present (mobile), updates the mirror default; desktop reports none, so the
  deviceId path keeps the current facing instead of guessing a flip —
  otherwise hopping between two webcams would silently un-mirror the preview.

Resource release — the part that must not be skipped
- One teardown function: stream.getTracks().forEach(t => t.stop()) plus
  clearing video.srcObject. It runs before every new stream, from "Stop
  camera", and again on unmount; after it the browser's camera indicator
  light must go out immediately.
- getUserMedia only resolves once the user answers the permission prompt,
  which easily outlives the component. Guard the resolution with a mounted
  ref and, if the component is gone, stop the returned tracks right there —
  the unmount cleanup already ran and could not see a stream that did not
  exist yet, so a late resolve would otherwise leave the camera on until the
  page reloads. Re-arm that ref inside the mount effect, or StrictMode's
  mount → cleanup → mount leaves it false for the live instance. Re-check it
  after the `await video.play()` too.
- Bump a start-token counter on every start and on unmount; a resolve
  carrying a stale token belongs to a superseded request (double-tapped
  switch, Retry pressed while requesting) and must stop its own tracks
  instead of becoming a second live camera.
- Clear the countdown interval on unmount, on cancel, and before any new
  stream. The review image is the data URL that onCapture needs anyway, so
  the component creates no object URL at all — nothing to revoke. Consumers
  who prefer URL.createObjectURL(blob) own that URL's lifetime.

Rendering & styling
- Semantic tokens only: bg-card + border for the shell, bg-muted for the
  preview box and its offline/denied/error overlays, bg-primary +
  text-primary-foreground for the shutter and "Keep photo",
  text-muted-foreground for supporting copy, text-destructive +
  bg-destructive for the error icon and the live dot, bg-background/60 for
  the countdown scrim. cn() merges the consumer's className.
- The preview box is a relative aspect-ratio container; the <video> fills it
  with object-cover and gets -scale-x-100 while mirrored, so the CSS preview
  and the canvas export agree pixel for pixel.
- Accessibility: the <video> carries an aria-label naming the live preview;
  the countdown digit is aria-live="assertive" aria-atomic (a countdown is
  exactly the interruption case assertive exists for); a separate sr-only
  role="status" aria-live="polite" region announces "Photo captured — W×H"
  once per shot; every icon-only button (switch / stop / shutter) has an
  aria-label, and all buttons take focus-visible:ring-2 ring-ring.
- Reduced motion: the live dot pulse and the countdown digit pulse are
  motion-safe: only, and the requesting spinner is
  motion-reduce:animate-none — nothing functional depends on any of them.
- disabled dims the shell and blocks start/shutter/switch/retake/keep, but
  never "Stop camera": a live camera must always be releasable.

Customization levers
- Framing: aspect (4/3, 1 for a square avatar shot, 16/9 for a document
  strip) drives the preview box and the center-crop identically — change one
  number and both follow.
- Output weight: outputWidth (omit for native resolution, 640/1080 to cap
  upload size) and quality (0.6–0.8 for thumbnails, 0.92 default). Swap the
  "image/jpeg" constant for "image/webp" if you only target modern browsers —
  keep it lossy or quality becomes a no-op.
- Shutter feel: countdown (0 = instant, 3 = classic selfie timer); drop the
  countdown branch entirely if your flow never needs it.
- Camera choice: facingMode for the initial preference, showSwitchCamera to
  suppress the switch affordance even on multi-camera devices, mirrored to
  force mirroring on/off instead of following the front/rear default.
- Review step: to auto-keep instead of asking, call onCapture straight from
  the toBlob callback and skip the captured state — the rest of the machine
  is unaffected.
- Copy: labels overrides any subset (idle/requesting/ready/captured/denied/
  deniedHint/error/unsupported/preview/shotPreview/start/capture/cancel/
  retake/keep/retry/stop/switchCamera/frameNotReady/encodeFailed) for i18n or
  brand voice.
- Palette: the shell/overlay/shutter tokens are independent — e.g. move the
  shutter to bg-secondary for a quieter kiosk skin without touching layout.

Concepts

  • Capability probe, not a reachable stateunsupported comes from a useSyncExternalStore read with a false server snapshot, so SSR and the first paint agree; no other state can transition into it, and no other state has to defend against a missing mediaDevices.
  • Permission lifecycle outlives the componentgetUserMedia resolves only after the user answers the prompt. A mounted-ref plus a start-token guard catch both the "component already unmounted" and the "a newer request replaced me" cases, and stop the tracks they were handed instead of installing a second, unreachable camera.
  • Stop-then-start on every switch — the old MediaStream is torn down before the new getUserMedia call, so a device is never asked to serve two streams at once; a live camera is also always releasable from the UI, which is what actually turns the hardware indicator light off.
  • Grant-gated device enumerationenumerateDevices() only reports real deviceIds and labels once permission has been granted, so the camera list (and therefore whether the switch button exists at all) is refreshed after each successful stream rather than guessed up front.
  • Mirror parity between CSS and canvas — the preview is flipped with a CSS transform for the front camera; the export applies the same flip to the 2D context before drawImage, so what the user framed is exactly what the blob contains.
  • Review-before-commit — a captured frame lives in local state (as a blob plus its data URL) until the user keeps it; the preview stream keeps running underneath so "Retake" is instant and never re-prompts for permission.

On This Page