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…

Dependencies
lucide-react
Registry dependencies
utils
"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 `null`
  ("not known yet") server snapshot: capability checks cannot happen during
  render, since the server has no navigator. While the value is null, render
  the neutral idle overlay with the Start button aria-disabled (not dimmed),
  so the static HTML never tells every visitor "unsupported"; only a client
  snapshot of false shows the unsupported copy.
- 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 encode once:
  canvas.toBlob for the payload, and the review image's data URL read back
  from that blob with FileReader.readAsDataURL. A synchronous toDataURL would
  encode the full-resolution frame a second time on the main thread. toBlob
  can hand back null (encode failure) and the read can fail; both 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 and a `live` state flag. 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.
- "Stop camera" renders whenever `live` is true, in every phase (ready,
  countdown, captured, and a capture-time error such as "no frame yet"), and
  is never gated by `disabled`, so a live camera can always be released.
- 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 is announced through a permanently mounted sr-only
  aria-live="assertive" aria-atomic region (a countdown is exactly the
  interruption case assertive exists for; a region mounted together with its
  first digit gets that digit skipped), while the visible digit is
  aria-hidden; 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.
- Focus hand-off: every phase change unmounts the button just pressed, so
  name a successor — Start/Retry → shutter once ready (Switch → the switch
  button), a failed start or capture → Retry, shutter → Cancel countdown or
  Keep photo, Cancel countdown / Keep / Retake → shutter (Start when no stream
  is left), Stop → Start. Write the target into a ref in the handler, bump a
  tick of state, and focus it in an effect after the commit, only when
  document.activeElement is <body> or inside the root.
- 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, including in
  the captured phase where Retake and Keep are disabled.

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 state — unsupported comes from a useSyncExternalStore read with a null ("not known yet") server snapshot, so SSR and the first paint agree on a neutral idle overlay instead of telling every visitor the camera is unsupported; no other state can transition into it, and no other state has to defend against a missing mediaDevices.
  • Permission lifecycle outlives the component — getUserMedia 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 enumeration — enumerateDevices() 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