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

  1. A page served over HTTPS (not strictly required for capture, but required for everything you will do with the file afterwards).
  2. Real devices for testing: iOS Safari and Android Chrome behave differently, and emulators do not open a real camera app.
  3. A client-side resize step, because camera photos are 3–12 MB and 12–48 megapixels.
  4. Server-side validation that does not trust file.type — see why browser MIME types are unreliable.

What each platform does with the attributes

Platform behaviour of accept and capture on a file input With accept image only, iOS and Android show a chooser offering camera, photo library and files. Adding capture environment opens the rear camera directly on both and removes library access. Desktop browsers ignore capture and open the file picker. Same markup, three behaviours input attributes iOS Safari Android Chrome desktop accept="image/*" chooser: camera, library, files chooser: camera, gallery, files file picker + capture="environment" rear camera no library rear camera no gallery file picker (ignored) + capture="user" front camera front camera* file picker * Some Android camera apps ignore the facing hint and open whichever camera was last used. capture is a request, not a guarantee — never build a flow that breaks if the gallery opens instead.
Adding capture trades choice for speed; offer both buttons so users keep the choice.

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 capture locks 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 the click() happens synchronously inside the user’s tap handler.
  • imageOrientation: "from-image". Camera photos are stored sideways with an EXIF orientation tag. createImageBitmap applies 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 fire change, which users experience as a broken retake button.

Memory on the way from camera to upload

Memory and size at each stage of handling a camera photo A 12 megapixel camera photo is a 3.4 megabyte HEIC file, decodes to about 48 megabytes of pixels, is drawn to a 2048 pixel canvas of about 12 megabytes, and is encoded to a 520 kilobyte JPEG for upload. 12 MP photo: bytes held at each step HEIC file 3.4 MB decoded ≈ 48 MB RGBA 2048 canvas ≈ 12 MB JPEG upload ≈ 520 KB The decode is the peak. Close the ImageBitmap as soon as it is drawn, and never hold two decoded photos at once — batch selections are processed one at a time on phones. Upload shrinks 6.5×; the server receives an upright, metadata-free JPEG.
Processing one photo costs a brief memory spike and saves the user most of the 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.

capture attribute versus getUserMedia The capture attribute needs no permission prompt, uses the native camera app with its full resolution and features, and takes a few lines of code, but offers no overlay or live preview. getUserMedia gives a live preview in the page with overlays and guided capture, but needs a permission prompt, custom UI and limits resolution to the video stream. Native camera app vs in-page camera capture attribute no permission prompt full sensor resolution, HDR, focus five lines of HTML no overlay, no guidance getUserMedia live preview with overlays guided / automatic capture stays inside your page prompt, custom UI, stream resolution
Start with the attribute; move to an in-page camera only when the product needs guidance the native app cannot give.

For recording video rather than stills, the equivalent in-page path is covered in recording video with MediaRecorder for upload.

Verification

On real devices:

  1. iOS Safari: tap “Take photo” — the camera opens directly. Take a portrait photo; the preview is upright.
  2. Android Chrome: same, then “Choose from library” — the gallery opens, not the camera.
  3. 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
  1. 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.