A single <img src="hero.jpg"> used to be the only option. It still works — but it means serving a 2400px image to a device with a 375px screen, and serving that same image as a JPEG to a browser that would have accepted AVIF at a third of the file size. The browser has had the primitives to do better than this for years. Most sites don’t use them correctly.
This is a reference for the full set of responsive image techniques in HTML — resolution switching, art direction, format negotiation, and the UX and SEO details that tie it together.
Resolution Switching
The srcset attribute tells the browser about the available sizes of an image. The sizes attribute tells it how wide the image will be rendered on screen. With both, the browser can select the most appropriate source itself, factoring in viewport width, device pixel ratio, and — in some implementations — network conditions.
<img
src="hero-800.jpg"
srcset="
hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1600.jpg 1600w,
hero-3200.jpg 3200w
"
sizes="
(max-width: 600px) 100vw,
(max-width: 1200px) 80vw,
1200px
"
alt="A photograph of the coastline at dawn"
>
The w descriptors are intrinsic widths — the actual pixel width of each file. The browser uses these alongside the sizes hint to calculate which source to request before the layout is known, as part of speculative loading.
The sizes attribute is a comma-separated list of media conditions paired with display widths. The browser evaluates them top to bottom and uses the width from the first matching condition. The final entry has no media condition and acts as the default. In the example above: on a viewport under 600px, the image occupies the full width; between 600–1200px, 80% of the viewport; above 1200px, a fixed 1200px regardless of viewport width.
The browser applies device pixel ratio automatically. On a 2× retina display with a 400px viewport, 100vw resolves to 800 CSS pixels — so the browser selects the hero-800.jpg source rather than hero-400.jpg.
x descriptors
A simpler alternative for images that are always displayed at a fixed size in CSS:
<img
src="logo.png"
srcset="[email protected] 2x, [email protected] 3x"
alt="Company logo"
width="200"
height="60"
>
x descriptors are appropriate for fixed-size elements — icons, logos, avatars. Use w descriptors with sizes for anything that scales with the viewport.
Art Direction
Resolution switching gives the browser freedom to pick between images of the same subject at different resolutions. Art direction is the opposite — you, not the browser, decide which image to serve based on a media condition. The composition or crop changes between breakpoints.
The <picture> element handles this:
<picture>
<source
media="(min-width: 1024px)"
srcset="hero-landscape.jpg"
>
<source
media="(min-width: 600px)"
srcset="hero-square.jpg"
>
<img src="hero-portrait.jpg" alt="Model wearing the spring collection">
</picture>
The browser evaluates <source> elements in order and uses the first matching media condition. The <img> at the end is required — it’s the fallback for browsers that don’t support <picture>, and it’s where the alt, width, height, and loading attributes live.
A common use case: a product hero image cropped landscape for desktop (emphasising context and setting), cropped square for tablet, and portrait for mobile (maximising the product in a narrow column). These are different crops of the same photograph — resolution switching alone can’t handle this because the subject matter is framed differently, not just scaled.
Art direction and resolution switching can be combined on the same <source>:
<picture>
<source
media="(min-width: 1024px)"
srcset="hero-landscape-1600.jpg 1600w, hero-landscape-3200.jpg 3200w"
sizes="100vw"
>
<img
src="hero-portrait.jpg"
srcset="hero-portrait-800.jpg 800w, hero-portrait-1600.jpg 1600w"
sizes="100vw"
alt="Model wearing the spring collection"
>
</picture>
Next-Generation Format Support
WebP is supported everywhere that matters. AVIF has now crossed the threshold where defaulting to it is reasonable — support landed in Safari 16, which shipped in late 2022. Both formats offer substantial compression improvements over JPEG for most photographic content, with AVIF typically outperforming WebP on the same quality setting.
The <picture> element’s type attribute handles format negotiation:
<picture>
<source type="image/avif" srcset="hero.avif">
<source type="image/webp" srcset="hero.webp">
<img src="hero.jpg" alt="Hero image">
</picture>
The browser picks the first <source> whose type it supports. If it understands AVIF, it takes the AVIF source and ignores the rest. If it supports WebP but not AVIF, it takes the WebP source. If it supports neither, it falls through to the <img> and loads the JPEG.
The order of <source> elements is significant — always list more preferred formats first. Listing JPEG before WebP would mean browsers that support both take the JPEG.
Combining format switching with resolution switching
<picture>
<source
type="image/avif"
srcset="hero-800.avif 800w, hero-1600.avif 1600w, hero-3200.avif 3200w"
sizes="(max-width: 768px) 100vw, 1200px"
>
<source
type="image/webp"
srcset="hero-800.webp 800w, hero-1600.webp 1600w, hero-3200.webp 3200w"
sizes="(max-width: 768px) 100vw, 1200px"
>
<img
src="hero-1600.jpg"
srcset="hero-800.jpg 800w, hero-1600.jpg 1600w, hero-3200.jpg 3200w"
sizes="(max-width: 768px) 100vw, 1200px"
alt="Hero image"
width="1600"
height="900"
>
</picture>
This is verbose, but it is the full solution. A browser on a 2× retina mobile gets the 1600w AVIF source — the right resolution for the device, in the best format it supports.
Generating srcset Programmatically
Writing these attributes by hand for every image is not sustainable. A helper that generates srcset strings from a base URL and a set of widths is worth having:
/**
* Generate a srcset string for a set of image widths.
* Assumes images are named with a width suffix: hero-800.jpg, hero-1600.jpg
*/
function buildSrcset(basePath, widths, ext = 'jpg') {
const base = basePath.replace(/\.[^.]+$/, ''); // strip extension
return widths
.map(w => `${base}-${w}.${ext} ${w}w`)
.join(', ');
}
const widths = [400, 800, 1200, 1600, 2400];
console.log(buildSrcset('/images/hero.jpg', widths));
// /images/hero-400.jpg 400w, /images/hero-800.jpg 800w, ...
console.log(buildSrcset('/images/hero.jpg', widths, 'avif'));
// /images/hero-400.avif 400w, /images/hero-800.avif 800w, ...
For Cloudinary specifically, the transformation API makes this cleaner — you store one source and derive the width variants by URL:
/**
* Build a Cloudinary srcset using the w_<width> transformation parameter.
*/
function cloudinarySrcset(cloudName, publicId, widths, format = 'auto') {
return widths
.map(w => {
const url = `https://res.cloudinary.com/${cloudName}/image/upload/f_${format},q_auto,w_${w}/${publicId}`;
return `${url} ${w}w`;
})
.join(', ');
}
const srcset = cloudinarySrcset('my-cloud', 'products/jacket', [400, 800, 1200, 1600]);
// Result:
// https://res.cloudinary.com/my-cloud/image/upload/f_auto,q_auto,w_400/products/jacket 400w,
// https://res.cloudinary.com/my-cloud/image/upload/f_auto,q_auto,w_800/products/jacket 800w, ...
f_auto tells Cloudinary to select the best format the requesting browser supports — effectively delegating format negotiation to the CDN rather than maintaining separate AVIF, WebP, and JPEG sources in markup. This is a legitimate alternative to the explicit <picture> approach when you control the delivery layer.
User Experience Details
Prevent layout shift with width and height
Always declare width and height on the <img> element, even if you intend to override both with CSS. Modern browsers use these attributes to reserve space for the image before it loads, preventing Cumulative Layout Shift (CLS).
<img
src="product.jpg"
width="1200"
height="800"
style="width: 100%; height: auto;"
alt="..."
>
The browser calculates a aspect-ratio from the declared width and height and uses it to allocate space. The CSS width: 100%; height: auto; then scales it within that reserved space. Without the declared dimensions, the browser has no way to know how tall the image will be before it arrives, and the layout shifts around it as it loads.
Lazy loading
<img
src="below-fold.jpg"
loading="lazy"
decoding="async"
width="800"
height="600"
alt="..."
>
loading="lazy" defers loading until the image is near the viewport. decoding="async" tells the browser it can decode the image off the main thread, reducing jank during page load.
Do not apply loading="lazy" to images that are above the fold on most viewports — typically the hero image and any images visible on initial load. Lazy loading these delays them unnecessarily and can hurt LCP. The attribute is for images that are genuinely below where the user will first look.
Priority loading for the hero
The inverse applies to your LCP candidate. The browser’s speculative loader starts fetching images early, but for the single most important image on the page, you can signal priority explicitly:
<img
src="hero.jpg"
fetchpriority="high"
decoding="async"
width="1600"
height="900"
alt="..."
>
Or preload it in the <head> — useful if the image is a CSS background that the speculative loader won’t discover automatically:
<link
rel="preload"
as="image"
href="hero-1600.jpg"
imagesrcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
imagesizes="100vw"
>
imagesrcset and imagesizes on a preload link correspond to srcset and sizes on the <img> — the browser preloads the right variant rather than the href fallback.
Aspect ratio in CSS
For responsive images where content reflow is a concern, the CSS aspect-ratio property is a clean companion to declared width and height:
.hero-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover; /* crop to fill rather than stretch */
}
Combined with declared width and height on the <img>, this gives you layout-stable, responsive images with controlled cropping.
SEO and Core Web Vitals
Google’s Core Web Vitals have made image performance a ranking signal. The relevant metrics:
LCP — Largest Contentful Paint is the time until the largest visible content element has rendered. For most pages this is the hero image. LCP below 2.5 seconds is the target threshold. The common failure modes are: no preload hint for the hero image, the LCP image discovered late because it’s in CSS or JavaScript, oversized source file, format choice leaving file size on the table.
CLS — Cumulative Layout Shift is the sum of layout shifts caused by elements moving after initial render. Images without declared width and height are the leading cause of CLS. Setting both attributes costs nothing and eliminates the problem for images loaded in HTML.
INP — Interaction to Next Paint is less directly related to images, but heavy image decoding on the main thread contributes to jank. decoding="async" on non-critical images moves decoding off the main thread.
Alt text
alt is not decoration. It is the text fallback when the image fails to load, the description that screen readers announce, and the signal search engines use to understand the image’s subject. Be specific and accurate. alt="A photograph of a chiffon dress on a model, front view, pale pink" is better than alt="dress". Empty alt="" is correct for genuinely decorative images — it tells screen readers to skip the element entirely.
Structured data for products
For product images specifically, schema.org structured data makes the image eligible for rich results in search:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Chiffon dress",
"image": [
"https://example.com/product-1600.jpg",
"https://example.com/product-800.jpg"
]
}
</script>
Google’s image search and Shopping surfaces draw from this data. Providing multiple resolutions in the image array gives Google options for different display contexts.
When Not to Use All of This
A blog post hero that exists in one size and format, used once, doesn’t need a six-<source> <picture> element. The overhead of maintaining multiple format variants and the markup complexity is only justified when images are a meaningful share of page weight, displayed across a wide range of viewport sizes, or on paths where LCP is an active concern.
The right minimum is usually: width and height always, loading="lazy" on everything below the fold, fetchpriority="high" on the LCP image, and a basic srcset with two or three width variants for any image used responsively. The full <picture> with format variants is the right solution when you control asset generation — either through a build step or a DAM with a transformation API — and the performance difference is worth the complexity.
The browser is good at making these decisions when you give it the information it needs. The srcset and sizes combination is designed to let it do exactly that.