Validating Image Dimensions Client-Side
How do you reject an avatar that is 80 × 80 pixels when you need at least 400 × 400, or a banner that is square when you need 3:1 — before the upload, and with a message that states both the required and the actual size? This recipe decodes the image with createImageBitmap(), reads its real, rotation-corrected dimensions, checks minimums, maximums and aspect ratio with a tolerance, and reports the verdict through setCustomValidity() on the file input so it behaves like any other rule in the Constraint Validation API flow.
When to Check Image Dimensions in the Browser
Dimension rules exist whenever an image will be displayed at a fixed size or printed: avatars, product photos, banners, identity documents, print-on-demand artwork. Checking them in the browser pays off when:
- The rule is strict, such as a marketplace requiring product photos of at least 1000 px on the long edge.
- The files are large, so uploading first and rejecting later wastes minutes on mobile.
- You can offer a fix — cropping to the right ratio in the browser — rather than only rejecting.
This check should run after the cheap size and type checks from the file upload validation pipeline, because decoding is the most expensive step. There is no reason to decode a 40 MB HEIC file you are about to reject for type.
Minimal Working Dimension Validator
export interface DimensionRules {
minWidth?: number;
minHeight?: number;
maxPixels?: number; // width × height
aspect?: { ratio: number; tolerance: number; label: string }; // e.g. 3 ± 0.05, "3:1"
}
export interface Measured { width: number; height: number }
export async function measureImage(file: File): Promise<Measured> {
// imageOrientation "from-image" applies EXIF rotation, so a portrait phone photo reports portrait sizes.
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
try {
return { width: bitmap.width, height: bitmap.height };
} finally {
bitmap.close(); // release decoded pixels immediately
}
}
export function dimensionError(name: string, m: Measured, r: DimensionRules): string {
const size = `${m.width} × ${m.height} px`;
if (r.minWidth && m.width < r.minWidth || r.minHeight && m.height < r.minHeight) {
return `${name} is ${size}. It needs to be at least ${r.minWidth ?? 0} × ${r.minHeight ?? 0} px.`;
}
if (r.maxPixels && m.width * m.height > r.maxPixels) {
return `${name} is ${size}, which is larger than we can process. Resize it to under ${Math.round(r.maxPixels / 1e6)} megapixels.`;
}
if (r.aspect) {
const ratio = m.width / m.height;
if (Math.abs(ratio - r.aspect.ratio) / r.aspect.ratio > r.aspect.tolerance) {
return `${name} is ${size}. It needs a ${r.aspect.label} shape — crop it or choose another image.`;
}
}
return "";
}
// Wiring
const AVATAR: DimensionRules = { minWidth: 400, minHeight: 400, maxPixels: 40e6, aspect: { ratio: 1, tolerance: 0.05, label: "square" } };
const input = document.querySelector<HTMLInputElement>("#avatar")!;
const status = document.querySelector<HTMLElement>("#avatar-status")!; // role="status"
let checking: Promise<void> = Promise.resolve();
input.addEventListener("change", () => {
const file = input.files?.[0];
if (!file) return input.setCustomValidity("");
input.setCustomValidity("Checking image…"); // block submit while decoding
status.textContent = `Checking ${file.name}…`;
checking = measureImage(file)
.then((m) => {
const error = dimensionError(file.name, m, AVATAR);
input.setCustomValidity(error);
status.textContent = error || `${file.name} is ${m.width} × ${m.height} px — looks good.`;
})
.catch(() => {
input.setCustomValidity(`We couldn't open ${file.name} as an image. Try a JPEG or PNG.`);
status.textContent = "";
});
});
input.form!.addEventListener("submit", async (event) => {
event.preventDefault();
await checking;
if (input.form!.reportValidity()) input.form!.submit();
});
Setting a temporary “Checking image…” custom validity while decoding is a small but important trick: if the user submits during the decode, reportValidity() has a truthful message to show instead of letting an unchecked image through.
Dimension Rule Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
minWidth / minHeight |
number (px) |
none | Quality floor for display or print |
maxPixels |
number |
40e6 |
Protects memory when decoding and processing |
aspect.ratio |
number |
none | Width divided by height (1 for square, 3 for 3:1) |
aspect.tolerance |
number |
0.05 |
Relative tolerance so 1000 × 990 still counts as square |
imageOrientation |
"from-image" |
on | Applies EXIF rotation before measuring |
| Pending message | string |
“Checking image…” | Keeps the input invalid while decoding |
A tolerance is essential for ratios. Phone cameras and cropping tools produce sizes like 1080 × 1079; an exact equality check rejects them for a one-pixel difference that nobody can see.
Verification Steps
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
test("landscape photo fails the square avatar rule", async ({ page }) => {
await page.goto("/profile");
await page.getByLabel("Profile photo").setInputFiles({
name: "wide.png",
mimeType: "image/png",
buffer: readFileSync("fixtures/1920x1080.png"),
});
await expect(page.getByRole("status")).toContainText("needs a square shape");
expect(await page.getByLabel("Profile photo").evaluate((el: HTMLInputElement) => el.validity.customError)).toBe(true);
});
Edge Cases and Failure Modes
EXIF rotation. Phones often store a portrait photo as landscape pixels plus an orientation tag. Without imageOrientation: "from-image", you measure the raw pixels and reject a perfectly good portrait photo as landscape. The option is supported wherever createImageBitmap accepts options; for the Image fallback, CSS image-orientation: from-image is the default in modern engines, so naturalWidth is already rotated.
Decompression bombs. A small PNG can declare 50,000 × 50,000 pixels and exhaust memory when decoded. Read the header dimensions first for PNG (bytes 16–23) and reject anything above maxPixels before calling createImageBitmap.
async function pngHeaderSize(file: File): Promise<Measured | null> {
const b = new DataView(await file.slice(0, 24).arrayBuffer());
if (b.getUint32(0) !== 0x89504e47) return null; // not a PNG
return { width: b.getUint32(16), height: b.getUint32(20) };
}
Formats the browser cannot decode. HEIC decodes only in Safari; createImageBitmap rejects elsewhere. Catch the rejection and say which formats work, rather than calling the image “corrupt”.
Animated images. GIF and animated WebP report the first frame’s size, which is almost always the size you want. If you need to forbid animation, that is a separate check on the file’s frame count.
Offering an In-Browser Crop Instead of Rejecting
For ratio failures, the most helpful response is often to fix the image rather than reject it. A simple crop-to-centre gives a usable square avatar from any photo in a few lines, and a crop UI lets users pick the region. Either way, the crop produces a new File that goes back into the input through DataTransfer, and validation runs again on the result — it should pass by construction, but running the same check keeps one source of truth.
async function cropToSquare(file: File, size = 800): Promise<File> {
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
const edge = Math.min(bmp.width, bmp.height);
const canvas = new OffscreenCanvas(size, size);
canvas.getContext("2d")!.drawImage(bmp, (bmp.width - edge) / 2, (bmp.height - edge) / 2, edge, edge, 0, 0, size, size);
bmp.close();
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.9 });
return new File([blob], file.name.replace(/\.\w+$/, "-square.jpg"), { type: "image/jpeg" });
}
async function replaceWithCrop(input: HTMLInputElement): Promise<void> {
const cropped = await cropToSquare(input.files![0]);
const dt = new DataTransfer();
dt.items.add(cropped);
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
}
Always ask before cropping — “Crop to a square from the centre?” with a preview — because automatic cropping can cut off exactly the part of the photo the user cared about. And keep the minimum-size rule firm: upscaling a 200 px image to 400 px passes the check while producing the blurry avatar the rule was meant to prevent.
Server-Side Dimension Checks
Dimensions are cheap for the server to verify with an image library that reads headers without decoding, and it must, because a request can bypass the browser. The server should also enforce maxPixels before full decoding to protect itself from decompression bombs, and re-encode accepted images, which strips metadata such as GPS coordinates that users rarely mean to publish. Keep the rule values in the shared module the client imports, so “at least 400 × 400” means the same thing on both sides — the same discipline described in enforcing file size limits before upload.
Frequently Asked Questions
How do I get an image's width and height in the browser before uploading?
Call createImageBitmap(file, { imageOrientation: "from-image" }) and read width and height, then call close() to free memory. The older approach loads an object URL into an Image and reads naturalWidth and naturalHeight.
Why does my portrait photo report landscape dimensions?
Phones often store portrait photos as landscape pixels with an EXIF orientation tag. Pass imageOrientation: "from-image" so the reported size reflects the rotated image users actually see.
How strict should an aspect ratio check be?
Allow a small relative tolerance, around 2 to 5 percent. Exact equality rejects images that are one pixel off, which no one can see.
Should I upscale images that are too small?
No. Upscaling passes the size check but produces the blurry result the minimum was meant to prevent. Ask for a larger image instead.
Related Guides
- File Upload Validation — the complete upload validation pipeline.
- Validating File Type with Magic Bytes — making sure the file is an image before decoding it.
- Validating Drag and Drop File Uploads — dropped images through the same checks.
- Showing a Loading State During Form Submission — feedback while decoding and uploading.