Responsive Images Done Right: Sizing for Every Screen
A 3000 px-wide hero image looks sharp on a desktop monitor but wastes bandwidth on a phone that displays it at 375 px. Responsive images solve this by serving different sizes to different devices. Here is how to implement them correctly.
The Problem
Without responsive images, every device downloads the same file. A 2400 px JPEG at 400 KB is standard for desktops, but a phone rendering it at 375 px wide has downloaded 3-4x more data than necessary. Over an entire page with multiple images, this adds up to megabytes of waste.
The srcset and sizes attributes fix this by letting the browser choose the most appropriate image variant.
srcset and sizes Explained
<img
src="product-800.jpg"
srcset="
product-400.jpg 400w,
product-800.jpg 800w,
product-1200.jpg 1200w,
product-1600.jpg 1600w
"
sizes="
(max-width: 640px) 100vw,
(max-width: 1024px) 50vw,
33vw
"
alt="Product photo"
/>
srcset lists available image files with their intrinsic widths (the w descriptor). The browser knows each file's pixel width before downloading it.
sizes tells the browser how wide the image will be displayed at each viewport breakpoint. On a 640 px phone, the image takes 100% of the viewport. On a tablet, 50%. On desktop, 33%.
The browser multiplies the display width by the device pixel ratio and picks the smallest srcset candidate that covers it. A 2x retina phone at 375 px viewport needs a 750 px image, so it selects product-800.jpg.
Generating Image Variants
You need to produce multiple sizes from a single source. In a Node.js build step, sharp handles this efficiently:
import sharp from "sharp";
const sizes = [400, 800, 1200, 1600];
async function generateVariants(inputPath: string, outputDir: string) {
for (const width of sizes) {
await sharp(inputPath)
.resize(width)
.webp({ quality: 80 })
.toFile(`${outputDir}/product-${width}.webp`);
}
}
This produces four WebP files from one source image. Run this as part of your build pipeline or as a pre-publish script.
Which Sizes to Generate
The right set of sizes depends on your layout. Here is a practical approach:
- Determine the maximum display width of the image in your layout (e.g., 1200 px on desktop).
- Multiply by 2 for retina screens: 2400 px is your largest variant.
- Create intermediate steps at roughly 1.5x intervals: 400, 600, 900, 1200, 1800, 2400.
For most sites, 4-5 variants per image provide good coverage without excessive build complexity.
Client-Side Resizing with Canvas
When you need to resize an image in the browser (for previews, uploads, or thumbnails), the Canvas API works well:
function resizeImage(
img: HTMLImageElement,
maxWidth: number,
maxHeight: number
): HTMLCanvasElement {
let { naturalWidth: w, naturalHeight: h } = img;
// Maintain aspect ratio
if (w > maxWidth) {
h = Math.round(h * (maxWidth / w));
w = maxWidth;
}
if (h > maxHeight) {
w = Math.round(w * (maxHeight / h));
h = maxHeight;
}
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, w, h);
return canvas;
}
For downscaling by more than 2x, consider stepping down in stages (halving each time) for better quality. Some browsers produce blurry results when scaling directly from a very large source to a small target.
The object-fit Property
CSS object-fit controls how an image fills its container without distortion:
.thumbnail {
width: 300px;
height: 200px;
object-fit: cover; /* fills container, crops overflow */
object-position: top; /* bias crop toward top */
}
| Value | Behavior |
|---|---|
cover |
Fills the container, crops excess |
contain |
Fits entirely within container, may letterbox |
fill |
Stretches to fill (distorts) |
none |
Original size, overflows if larger |
object-fit: cover is the most common choice for thumbnails and cards.
Lazy Loading
Combine responsive sizing with lazy loading to defer offscreen images:
<img
src="product-800.jpg"
srcset="product-400.jpg 400w, product-800.jpg 800w"
sizes="(max-width: 640px) 100vw, 50vw"
loading="lazy"
decoding="async"
alt="Product photo"
/>
The loading="lazy" attribute is supported natively and requires zero JavaScript. The decoding="async" attribute prevents the image decode from blocking the main thread.
Common Mistakes
- Using only
widthandheightattributes withoutsrcset. Fixed dimensions do not adapt to different viewports or pixel densities. - Setting
sizes="100vw"everywhere. This tells the browser the image is always full-width, causing it to download oversized files for layouts where the image only occupies a column. - Forgetting aspect ratio. Always include
widthandheightattributes to prevent layout shift (CLS). The browser uses these to reserve space before the image loads. - Upscaling small images. Never generate a 1600 px variant from a 800 px source. It adds file size without adding detail.
<!-- Prevent layout shift with explicit dimensions -->
<img
src="photo.jpg"
width="1200"
height="800"
style="width: 100%; height: auto;"
alt="Descriptive text"
/>
Measuring Impact
Use Chrome DevTools Network panel to verify which image variant the browser selected. Filter by "Img" type and check the file name and transfer size. Compare total image weight before and after implementing responsive images to quantify the savings.
Try our Image Resizer to resize images to exact dimensions instantly — right in your browser, no upload required.