Capturing Photos with the capture Attribute
Use <input type="file" accept="image/*" capture="environment"> to open the rear camera directly on phones (or capture="user" for the front camera), keep a second plain <input type="file" accept="image/*"> for choosing from the gallery, and treat the result exactly like any other File — it arrives as a full-resolution JPEG or HEIC with EXIF orientation, which you should downscale and normalise before upload.
The capture attribute is the lowest-effort way to get a camera into a web form: no permissions prompt, no getUserMedia stream, no custom viewfinder. It is also widely misunderstood. It is a hint that mobile browsers honour by skipping the chooser and opening the camera app; desktop browsers ignore it entirely; and it removes the option to pick an existing photo, which users often want. This page is part of mobile and camera capture uploads in upload fundamentals and browser APIs. What to do with the file once you have it is covered by resizing images in the browser with canvas.
When to use this approach
- A form needs a photo taken now — a receipt, a damaged parcel, an ID document, a meter reading — and a camera-first flow saves the user a tap.
- You do not need a live preview, overlays or burst capture inside the page; the native camera app’s UI is acceptable.
- You want zero permission prompts: the camera app runs outside the page, so the site never gets camera access.
Prerequisites
- A page served over HTTPS (not strictly required for
capture, but required for everything you will do with the file afterwards). - Real devices for testing: iOS Safari and Android Chrome behave differently, and emulators do not open a real camera app.
- A client-side resize step, because camera photos are 3–12 MB and 12–48 megapixels.
- Server-side validation that does not trust
file.type— see why browser MIME types are unreliable.
What each platform does with the attributes
Implementation
Two inputs, one handler, and the normalisation every camera photo needs:
<div class="photo-field">
<button type="button" data-open="camera-input">Take photo</button>
<button type="button" data-open="library-input">Choose from library</button>
<input id="camera-input" type="file" accept="image/*" capture="environment" hidden>
<input id="library-input" type="file" accept="image/*" hidden>
<img id="photo-preview" alt="" hidden>
<p id="photo-status" role="status" aria-live="polite"></p>
</div>
const MAX_EDGE = 2048; // long edge after downscale
const QUALITY = 0.85;
async function normalise(file: File): Promise<Blob> {
// createImageBitmap applies EXIF orientation with imageOrientation "from-image".
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2D canvas unavailable");
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close(); // free the full-resolution decode now
// Re-encoding drops EXIF entirely — including GPS — and fixes orientation in the pixels.
return canvas.convertToBlob({ type: "image/jpeg", quality: QUALITY });
}
function wire(): void {
const status = document.getElementById("photo-status")!;
const preview = document.getElementById("photo-preview") as HTMLImageElement;
document.querySelectorAll<HTMLButtonElement>("[data-open]").forEach((btn) => {
btn.addEventListener("click", () => {
(document.getElementById(btn.dataset.open!) as HTMLInputElement).click();
});
});
for (const id of ["camera-input", "library-input"]) {
const input = document.getElementById(id) as HTMLInputElement;
input.addEventListener("change", async () => {
const file = input.files?.[0];
input.value = ""; // allow re-selecting the same photo
if (!file) return; // user cancelled the camera
status.textContent = "Preparing photo…";
try {
const blob = await normalise(file);
if (preview.src) URL.revokeObjectURL(preview.src);
preview.src = URL.createObjectURL(blob);
preview.alt = "Photo to be uploaded";
preview.hidden = false;
status.textContent = `Ready: ${Math.round(blob.size / 1024)} KB (was ${Math.round(file.size / 1024)} KB)`;
await upload(blob, file.name.replace(/\.\w+$/, ".jpg"));
} catch (err) {
status.textContent = `Could not read this photo: ${(err as Error).message}`;
}
});
}
}
async function upload(blob: Blob, name: string): Promise<void> {
const body = new FormData();
body.append("photo", blob, name);
const res = await fetch("/api/photos", { method: "POST", body });
if (!res.ok) throw new Error(`upload failed (HTTP ${res.status})`);
}
wire();
Line-by-line on the parts that matter
- Two inputs instead of one. A single input with
capturelocks users into the camera; without it, the extra chooser tap is unavoidable. Two clearly labelled buttons give the fast path and the gallery path at once. - Buttons that call
input.click(). Styling a real file input is painful and inconsistent; a hidden input opened from a styled button works in every mobile browser, provided theclick()happens synchronously inside the user’s tap handler. imageOrientation: "from-image". Camera photos are stored sideways with an EXIF orientation tag.createImageBitmapapplies it, so the canvas receives upright pixels. Skip it and portrait photos upload rotated in some pipelines.- Downscale to a 2048-pixel long edge. A 48-megapixel photo decodes to about 190 MB of RGBA; drawing it to a smaller canvas and closing the bitmap immediately keeps peak memory manageable on mid-range phones. Most products never display more than 2000 pixels.
- Re-encode to JPEG. This drops EXIF (GPS coordinates, camera serials) and converts HEIC from iPhones into a format every server library reads. The privacy side is covered in stripping EXIF metadata before upload.
input.value = ""after reading. Otherwise choosing the same file twice does not firechange, which users experience as a broken retake button.
Memory on the way from camera to upload
Configuration gotchas
Android reloads the page after taking the photo. On low-memory devices the OS kills the browser while the camera app is in the foreground; when the user returns, the page reloads and the form is empty. Save form state (text fields, the step the user was on) to sessionStorage before opening the camera and restore it on load.
change never fires on iOS after cancelling. Cancelling the camera fires nothing. Do not show “waiting for photo” states that only clear on change; clear them on the next user interaction or listen for the cancel event, which newer browsers fire on file inputs.
file.type is empty or image/heic. iOS delivers HEIC from the library when the camera is set to “High Efficiency”, and some Android galleries report an empty type. Decode anyway — createImageBitmap handles HEIC in Safari — and let the re-encode normalise the format; for browsers that cannot decode HEIC, see converting HEIC images to JPEG in the browser.
capture on a multiple input. Camera capture returns one photo; multiple is ignored in capture mode on most devices. For batch shooting, loop: after each photo, offer “Take another”.
Designing the capture step for real users
The markup is the easy part; the flow around it decides whether people finish. Four details matter more than any attribute.
Label the buttons by outcome, not mechanism. “Take photo” and “Choose from library” test better than “Camera” and “Upload”, and far better than a single “Browse…” that hides the camera behind a chooser. On desktop, where capture does nothing, hide the camera button or relabel it — offering “Take photo” that opens a file picker confuses people.
Show the result immediately. Users need to see what they captured before they commit: blurred receipts and thumbs over the lens are common. Render the normalised image from an object URL the moment it is ready, with a clear “Retake” action. Because the preview comes from the processed file, what they approve is exactly what will be uploaded.
Start the upload early, commit late. Begin uploading as soon as the photo is normalised, while the user fills in the rest of the form; mark the server-side file as a draft until the form is submitted. The upload is usually finished before they press the final button, so the form feels instant. Drafts that are never submitted are cleaned up by a lifecycle rule, as in setting up S3 lifecycle rules for temporary uploads.
Handle several photos as a sequence. For multi-photo reports, let the user take one photo at a time and show a strip of thumbnails that grows, each with its own upload state. A single “select many” step looks efficient but runs straight into memory limits when a dozen full-resolution photos arrive at once, and it prevents retakes of individual shots.
When the capture attribute is not enough
The attribute opens the platform camera app, which means you get whatever it produces and no control over framing. Document scanning, guided capture (“fit the card inside the frame”) and in-page previews need getUserMedia and a <video> element instead, at the cost of a permission prompt and much more code.
For recording video rather than stills, the equivalent in-page path is covered in recording video with MediaRecorder for upload.
Verification
On real devices:
- iOS Safari: tap “Take photo” — the camera opens directly. Take a portrait photo; the preview is upright.
- Android Chrome: same, then “Choose from library” — the gallery opens, not the camera.
- Check what reached the server:
exiftool -Orientation -GPSLatitude -Model uploaded.jpg
# (no output: orientation baked into pixels, no GPS, no camera model)
identify -format '%w x %h %m\n' uploaded.jpg
# 1536 x 2048 JPEG
- Desktop: both buttons open the normal file picker, and the flow still works.
Frequently Asked Questions
Does capture need camera permission?
No. The page never touches the camera; the operating system’s camera app does, and hands back a file. That is why there is no prompt, and also why you cannot show anything over the viewfinder.
Can I force the gallery instead of the camera?
Omit capture. With just accept="image/*", mobile browsers show a chooser that includes the library. There is no attribute that opens the library directly without the chooser.
Why does the uploaded photo have no location data?
Re-encoding through a canvas discards all metadata. That is usually what you want for privacy. If your product needs location (for example, where a site-inspection photo was taken), read the GPS tags from the original File with an EXIF parser before re-encoding, ask the user’s consent, and send them as separate form fields.