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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/camera-capture.jsonPrompt
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 state —
unsupportedcomes from auseSyncExternalStoreread with afalseserver snapshot, so SSR and the first paint agree; no other state can transition into it, and no other state has to defend against a missingmediaDevices. - Permission lifecycle outlives the component —
getUserMediaresolves 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
MediaStreamis torn down before the newgetUserMediacall, 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.
Video Hover Preview
A thumbnail card that plays a muted clip on hover or keyboard focus — dwell-delayed lazy load, pointer-x scrubbing, hairline progress, and a poster fallback when the clip fails.
3D Marquee
A four-state image collection that drifts through staggered lanes on a responsive, perspective-tilted plane.