Art Direction with the HTML Picture Element
Art direction is a different problem than responsive scaling. Where srcset + sizes (covered in mastering srcset and sizes for responsive layouts) serves the same composition at different resolutions, the <picture> element lets you serve an entirely different crop, aspect ratio, or focal composition depending on the viewport. A landscape hero shot that works at 1440 px becomes an illegible strip of background at 320 px — art direction replaces that crop with a tightly framed portrait version. This guide, part of the Responsive Image & Video Delivery section, walks through the full engineering stack: browser selection mechanics, build-time crop automation, format fallback chains, parameter semantics, and validation.
How the Browser Selects a Source
The <picture> element is a selection hint container, not a display element. The browser evaluates <source> elements top-to-bottom and picks the first one whose media query and type MIME check both pass. The terminal <img> is always the fallback and is the element that actually paints.
<picture>
│
├─ <source media="(min-width: 1024px)" type="image/avif" srcset="..."> ← checked first
├─ <source media="(min-width: 1024px)" type="image/webp" srcset="..."> ← checked second
├─ <source media="(min-width: 640px)" type="image/avif" srcset="..."> ← checked third
├─ <source media="(min-width: 640px)" type="image/webp" srcset="..."> ← checked fourth
└─ <img src="fallback.jpg" ...> ← always rendered
Two evaluation rules govern source ordering:
- Media query wins first. If the viewport does not satisfy the
mediaattribute the source is skipped entirely —typeis never evaluated. - Type check eliminates unsupported formats. Safari 14 does not support
image/avif, so sources with that type are skipped; Safari 16+ passes both. Chrome 85+ passes both.
The result: serve AVIF for large viewports on modern browsers, WebP for medium viewports on older browsers, and a JPEG fallback universally — all in one markup block, zero JavaScript required.
Reading the same markup as a grid rather than as a list makes the fallback coverage auditable. Every cell below is a file that some real client will download, and any cell you cannot name is a gap in the chain:
The legacy column is the one that most often surprises reviewers. Because the terminal <img> carries a single src, browsers that ignore <source> receive whatever crop you chose for that element at every viewport width — a desktop visitor on an ancient engine gets the 480 × 640 portrait. That is usually the right trade (a tight crop degrades more gracefully when scaled up than a wide crop does when squeezed down), but it is a decision, and it should be a deliberate one.
Re-evaluation: the behaviour that separates <picture> from srcset
The spec detail that has the largest practical consequence is when selection re-runs. Source selection is part of the “update the image data” algorithm, and the browser re-enters it whenever the media conditions attached to the <source> elements change state. Cross a breakpoint by rotating a phone or dragging a desktop window, and the browser genuinely re-evaluates the source set and swaps to the newly matching <source> — issuing a second network request for the new crop.
This is the opposite of the srcset width-descriptor path, where the selected URL is sticky for the lifetime of the document and a resize never triggers a re-fetch. The asymmetry is deliberate: a width descriptor expresses a resolution preference, and downgrading resolution mid-session has no visual payoff, whereas a media attribute expresses an art-direction requirement, and continuing to show the wrong composition after a breakpoint change would be a correctness bug.
Two consequences follow. First, a user who resizes across your breakpoints downloads every crop they cross, so a five-breakpoint art-direction scheme can cost five images in one session on a desktop browser being dragged around. Keep the breakpoint count to three unless you have measured a reason for more. Second, a device-orientation change on mobile fires the same re-evaluation, which means media="(orientation: portrait)" conditions are re-tested on every rotation and will re-fetch. If your art direction only needs to distinguish phone from desktop, express it in min-width rather than orientation — the same visual result without the rotation churn.
Warning: the re-fetch is subject to the HTTP cache like any other request, but a cold cache plus an aggressive breakpoint set will show up in RUM as unexplained image bytes on desktop sessions. If your bandwidth numbers are worse than your markup predicts, log img.currentSrc on a resize-debounced handler to count how many distinct crops a session actually pulls.
Art Direction vs. Resolution Switching: When Each Applies
A second, format-only use of <picture> is valid: serve AVIF with a WebP fallback and no media attribute at all. The browser selects purely on type support, delivering the best-compressed variant without any art direction. This is the right pattern when your image composition is the same across viewports but your CDN does not perform automatic format negotiation via Vary: Accept.
Benchmark: File-Size Impact by Breakpoint and Format
The table below shows real-world output from a 5-MP editorial photograph processed with Sharp 0.33, covering the three canonical breakpoints used in the implementation examples.
| Breakpoint | Crop (px) | Format | File size | SSIM vs. JPEG | Notes |
|---|---|---|---|---|---|
| Large (≥1024px) | 1200×800 | AVIF q75 | 68 KB | 0.97 | AVIF — derived from the AV1 video codec — leads on compression |
| Large (≥1024px) | 1200×800 | WebP q80 | 102 KB | 0.96 | Fallback for Safari 14, Chrome <85 |
| Large (≥1024px) | 1200×800 | JPEG q80 | 188 KB | 1.00 | Baseline reference |
| Medium (640–1023px) | 800×600 | AVIF q75 | 44 KB | 0.97 | — |
| Medium (640–1023px) | 800×600 | WebP q80 | 68 KB | 0.96 | — |
| Small (<640px) | 480×640 | AVIF q78 | 32 KB | 0.97 | Portrait crop; tighter on face/subject |
| Small (<640px) | 480×640 | WebP q82 | 51 KB | 0.96 | — |
| Small (<640px) | 480×640 | JPEG q85 | 94 KB | 1.00 | <img> fallback |
AVIF at the large breakpoint delivers a 64% reduction over the JPEG baseline while holding SSIM above 0.97. For mobile, the combination of a tighter portrait crop and AVIF encoding reduces payload to 17% of the original landscape JPEG — a significant LCP gain on 4G connections where bandwidth is the bottleneck.
Step-by-Step Implementation
Step 1 — Generate breakpoint-specific crops with Sharp
Automating art-directed crops requires deterministic coordinate mapping in your CI/CD pipeline. Sharp generates breakpoint-specific crops alongside format variants in a single build step.
const sharp = require('sharp');
async function buildArtDirectedSet(inputPath, outputDir) {
// ── Large viewport: landscape 3:2 crop, AVIF primary + WebP fallback ──
await sharp(inputPath)
.resize({
width: 1200,
height: 800,
fit: 'cover', // crop to exact dimensions, no letterbox
position: 'entropy', // libvips 8.9+ — finds highest-detail region automatically
})
.toFormat('avif', {
quality: 75, // maps to encoder quantizer ~28 (lower = better in libavif)
effort: 6, // 0–9; 6 is the production sweet spot for encode time vs size
chromaSubsampling: '4:2:0', // safe for photographs; use 4:4:4 only for text-heavy images
})
.toFile(`${outputDir}/hero-lg.avif`);
await sharp(inputPath)
.resize({ width: 1200, height: 800, fit: 'cover', position: 'entropy' })
.toFormat('webp', {
quality: 80,
smartSubsample: true, // preserves fine detail in chroma channel; default is false
effort: 4, // libvips WebP effort; 4 balances speed vs compression
})
.toFile(`${outputDir}/hero-lg.webp`);
// ── Medium viewport: same landscape ratio, smaller dimensions ──
await sharp(inputPath)
.resize({ width: 800, height: 600, fit: 'cover', position: 'entropy' })
.toFormat('avif', { quality: 75, effort: 6, chromaSubsampling: '4:2:0' })
.toFile(`${outputDir}/hero-md.avif`);
await sharp(inputPath)
.resize({ width: 800, height: 600, fit: 'cover', position: 'entropy' })
.toFormat('webp', { quality: 80, smartSubsample: true, effort: 4 })
.toFile(`${outputDir}/hero-md.webp`);
// ── Small viewport: portrait 3:4 crop, centre-weighted for face/subject ──
await sharp(inputPath)
.resize({
width: 480,
height: 640,
fit: 'cover',
position: 'attention', // libvips face-detection heuristic; falls back to entropy
})
.toFormat('avif', { quality: 78, effort: 6, chromaSubsampling: '4:2:0' })
.toFile(`${outputDir}/hero-sm.avif`);
await sharp(inputPath)
.resize({ width: 480, height: 640, fit: 'cover', position: 'attention' })
.toFormat('webp', { quality: 82, smartSubsample: true, effort: 4 })
.toFile(`${outputDir}/hero-sm.webp`);
// ── Universal JPEG fallback for the <img> element ──
await sharp(inputPath)
.resize({ width: 480, height: 640, fit: 'cover', position: 'attention' })
.toFormat('jpeg', { quality: 85, progressive: true, mozjpeg: true })
.toFile(`${outputDir}/hero-fallback.jpg`);
}
Warning: position: 'attention' requires libvips 8.10+. Confirm your Node runtime ships the correct version with sharp.versions.vips before deploying. If the version is below 8.10, substitute position: 'entropy'.
Hash all output filenames (e.g. hero-lg.[hash].avif) so CDN edges cache them as immutable assets, then pair with a Cache-Control header of max-age=31536000, immutable.
The geometry the pipeline is executing is easier to hold in your head as a picture than as a parameter list: one master frame, one focal point, and three windows onto it, two of which share an aspect ratio and differ only in output resolution.
Deriving the crop rectangle from a stored focal point
entropy and attention are heuristics, and heuristics fail loudly on the images that matter most: a portrait where the subject is off-centre, a product shot with a busy background, a scene with a bright but irrelevant highlight. For anything an editor curates, store an explicit focal point as a normalised pair and derive the crop deterministically. Normalised coordinates survive a change of master resolution; pixel coordinates do not.
The derivation is a clamp around a scaled window. Given a master of W × H, a focal point (fx, fy) in the range 0–1, and a target aspect ratio a = w / h, take the largest window with ratio a that fits inside the master, centre it on the focal point, then push it back inside the frame:
// Deterministic art-direction crop from a stored focal point.
// Returns a Sharp-compatible extract region in master pixel coordinates.
function cropFor(master, focal, targetW, targetH) {
const { width: W, height: H } = master; // master intrinsic size in px
const a = targetW / targetH; // desired output aspect ratio
// Largest window of ratio `a` that still fits inside the master.
// If the master is wider than the target ratio, height is the binding
// constraint; otherwise width is.
const cw = (W / H > a) ? Math.round(H * a) : W;
const ch = (W / H > a) ? H : Math.round(W / a);
// Centre on the focal point, then clamp so the window stays in-frame.
// Clamping is what makes a focal point near an edge safe to author.
const left = Math.max(0, Math.min(W - cw, Math.round(focal.x * W - cw / 2)));
const top = Math.max(0, Math.min(H - ch, Math.round(focal.y * H - ch / 2)));
return { left, top, width: cw, height: ch };
}
// Worked example — 2736 × 1824 master, focal point (0.58, 0.34),
// small-breakpoint target 480 × 640 (ratio 0.75):
// a = 0.75, W/H = 1.5 → 1.5 > 0.75, so height binds
// cw = round(1824 × 0.75) = 1368, ch = 1824
// left = clamp(round(0.58 × 2736 − 684)) = clamp(903) = 903
// top = clamp(round(0.34 × 1824 − 912)) = clamp(−292) = 0
// The window is pushed flush to the top edge rather than running off it,
// and the subject sits slightly right of centre — exactly as authored.
Feed the returned region to sharp(input).extract(region).resize(targetW, targetH) in place of fit: 'cover'. The extract-then-resize order matters: resizing first throws away the pixels the crop was going to use, and on a 5 MP master that costs roughly a stop of effective resolution in the cropped region. Because the function is pure, the same focal point produces byte-identical output on every machine, which removes the libvips-version drift described in the tradeoffs section below and makes crops safe to content-hash. The equivalent build-tool wiring for a Vite project is covered in Vite imagetools responsive srcset generation.
Step 2 — Write the full <picture> markup
Source ordering matters: modern format first, legacy format second, wider viewport first, narrower viewport last. The <img> fallback carries the smallest crop because it will only render on browsers old enough to ignore <source> entirely.
<picture>
<!--
Large viewport (≥1024px): AVIF primary.
media evaluated before type — both must pass.
Chromium 85+, Firefox 93+, Safari 16+ will take this source.
-->
<source
media="(min-width: 1024px)"
srcset="/img/hero-lg.avif"
type="image/avif"
width="1200"
height="800"
>
<!--
Large viewport: WebP fallback for browsers that support WebP but not AVIF.
Safari 14+, Chrome <85, Edge 18+ land here.
-->
<source
media="(min-width: 1024px)"
srcset="/img/hero-lg.webp"
type="image/webp"
width="1200"
height="800"
>
<!-- Medium viewport AVIF + WebP pair -->
<source
media="(min-width: 640px)"
srcset="/img/hero-md.avif"
type="image/avif"
width="800"
height="600"
>
<source
media="(min-width: 640px)"
srcset="/img/hero-md.webp"
type="image/webp"
width="800"
height="600"
>
<!--
Small viewport AVIF + WebP pair (no media attr — catches everything below 640px
because the wider sources already claimed ≥640px).
-->
<source
srcset="/img/hero-sm.avif"
type="image/avif"
width="480"
height="640"
>
<source
srcset="/img/hero-sm.webp"
type="image/webp"
width="480"
height="640"
>
<!--
Terminal <img>: JPEG fallback; always rendered by the browser
as the display element regardless of which <source> was chosen.
width + height reserve layout space → CLS = 0.
loading="eager" + fetchpriority="high" for above-the-fold LCP candidates.
decoding="async" defers decode off the main thread after layout commit.
-->
<img
src="/img/hero-fallback.jpg"
alt="Aerial view of the city waterfront at dusk"
width="480"
height="640"
loading="eager"
decoding="async"
fetchpriority="high"
>
</picture>
Tradeoff: fetchpriority="high" — covered in depth at using fetchpriority to optimise critical media — starves other in-flight requests when applied to more than one image. Reserve it for the single above-the-fold LCP candidate.
Step 3 — Lock layout with CSS aspect-ratio
Explicit width/height attributes on <img> give browsers an intrinsic aspect ratio to reserve space before the image loads. Back this up with CSS to handle fluid containers:
/* Reserve space so the browser never shifts content on image load (CLS = 0) */
picture img {
width: 100%;
height: auto;
display: block;
/* aspect-ratio mirrors the <img> width/height attributes for each breakpoint */
aspect-ratio: 480 / 640; /* default: portrait mobile crop */
}
@media (min-width: 640px) {
picture img {
aspect-ratio: 800 / 600; /* medium: landscape */
}
}
@media (min-width: 1024px) {
picture img {
aspect-ratio: 1200 / 800; /* large: widescreen landscape */
}
}
Warning: If your CSS aspect-ratio does not match the width/height attributes on <img>, browsers will compute conflicting intrinsic sizes and CLS may still occur on first paint. Keep them in sync when crop dimensions change.
Step 4 — Wire into a Node.js/Eleventy build pipeline
A Nunjucks/Eleventy shortcode turns the multi-source markup into a single template call with automatic hash-based filenames:
// .eleventy.js — shortcode for art-directed hero images
const crypto = require('crypto');
const fs = require('fs');
module.exports = function(eleventyConfig) {
eleventyConfig.addNunjucksShortcode(
'artHero',
function({ src, alt, outputDir = 'dist/img' }) {
// Read the build manifest written by the Sharp pipeline above
const manifestPath = `${outputDir}/manifest.json`;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const base = src.replace(/\.[^.]+$/, '');
const get = (size, ext) => manifest[`${base}-${size}.${ext}`] ?? `${base}-${size}.${ext}`;
return `<picture>
<source media="(min-width: 1024px)" srcset="${get('lg', 'avif')}" type="image/avif" width="1200" height="800">
<source media="(min-width: 1024px)" srcset="${get('lg', 'webp')}" type="image/webp" width="1200" height="800">
<source media="(min-width: 640px)" srcset="${get('md', 'avif')}" type="image/avif" width="800" height="600">
<source media="(min-width: 640px)" srcset="${get('md', 'webp')}" type="image/webp" width="800" height="600">
<source srcset="${get('sm', 'avif')}" type="image/avif" width="480" height="640">
<source srcset="${get('sm', 'webp')}" type="image/webp" width="480" height="640">
<img src="${get('fallback', 'jpg')}" alt="${alt}" width="480" height="640"
loading="eager" decoding="async" fetchpriority="high">
</picture>`;
}
);
};
For responsive video delivery in Next.js and React, the equivalent pattern uses the Next.js <Image> component with a custom loader that maps the same crop dimensions to CDN query parameters.
Step 5 — Move the crops to the edge when the catalogue is large
Build-time generation is the right default for a fixed set of hero images. It stops being the right default once the image count is unbounded — a product catalogue, a user-generated feed, an editorial archive — because every new asset multiplies by the number of breakpoints and formats, and a full rebuild becomes an hours-long job. At that point the crop moves into the URL and the CDN performs it on first request.
The markup shape is unchanged; only the srcset values become parameterised URLs:
<picture>
<!--
Cloudflare Image Resizing syntax. Each source encodes the same crop
geometry the Sharp pipeline computed, but as URL parameters:
fit=cover crop to the exact box rather than letterboxing
gravity the stored focal point, normalised 0–1, as "x,y"
format=auto edge-side Accept negotiation; drops the need for a
separate AVIF/WebP source pair on CDNs that support it
quality per-breakpoint, higher on the small crop because a
tighter crop enlarges compression artefacts
-->
<source
media="(min-width: 1024px)"
srcset="/cdn-cgi/image/width=1200,height=800,fit=cover,gravity=0.58x0.34,format=auto,quality=75/master.jpg"
width="1200" height="800">
<source
media="(min-width: 640px)"
srcset="/cdn-cgi/image/width=800,height=600,fit=cover,gravity=0.58x0.34,format=auto,quality=75/master.jpg"
width="800" height="600">
<source
srcset="/cdn-cgi/image/width=480,height=640,fit=cover,gravity=0.58x0.34,format=auto,quality=78/master.jpg"
width="480" height="640">
<img src="/cdn-cgi/image/width=480,height=640,fit=cover,gravity=0.58x0.34,format=jpeg/master.jpg"
alt="Aerial view of the city waterfront at dusk"
width="480" height="640" loading="eager" decoding="async" fetchpriority="high">
</picture>
Two disciplines keep this from becoming a cost problem. First, treat the parameter string as a closed vocabulary: generate it from the same three-breakpoint table your templates use, never by hand. An open vocabulary means an unbounded number of distinct edge objects, each with its own cold-start transform, and the cache hit rate collapses. The full parameter semantics are catalogued in configuring Cloudflare Image Resizing URL parameters. Second, remember that format=auto only replaces the AVIF/WebP source pair on CDNs that negotiate correctly and set Vary: Accept; if you keep an explicit type on each <source> and use format=auto, the two negotiation systems can disagree and you will serve AVIF bytes under a type="image/webp" declaration.
Tradeoff: edge transforms shift cost from build minutes to per-transform billing and from deterministic output to cache-dependent latency. A first request for an uncached crop pays the full transform — typically 150–400 ms for a 5 MP master at AVIF quality 75 — on top of origin fetch. That is acceptable for the rarely-requested tail of a catalogue and unacceptable for a homepage hero, which is why most mature pipelines run both: build-time crops for the handful of LCP candidates, edge crops for everything else.
Parameter Reference
| Attribute / Option | Element | Purpose | Required? |
|---|---|---|---|
media |
<source> |
CSS media query that must match before the source is considered | No — omit on the last format pair |
srcset |
<source> |
One or more candidate URLs with optional width descriptors | Yes |
type |
<source> |
MIME type; browser skips source if format is unsupported | Strongly recommended |
width / height |
<source> |
Intrinsic dimensions; Safari uses these for aspect-ratio reservation | Yes for CLS = 0 |
src |
<img> |
Universal fallback URL; used when no <source> matches |
Yes |
alt |
<img> |
Text alternative; applies to whichever source is actually rendered | Yes |
loading |
<img> |
"eager" (default) or "lazy" — eager for LCP, lazy for below-fold |
Yes |
decoding |
<img> |
"async" defers decode off main thread after layout |
Recommended |
fetchpriority |
<img> |
"high" hints the preload scanner; use on at most one image per page |
Only for LCP |
quality (Sharp) |
— | 0–100 subjective quality; maps to encoder quantizers non-linearly | Yes |
effort (Sharp AVIF/WebP) |
— | Encode complexity 0–9; 6 is the production default | Optional |
position (Sharp) |
— | 'entropy', 'attention', 'center', or {left, top} gravity |
Yes for art direction |
chromaSubsampling (Sharp AVIF) |
— | '4:2:0' for photos; '4:4:4' for text/logos |
Optional |
sizes |
<source> |
Only meaningful when srcset carries w descriptors; ignored for plain-URL art-direction sources |
No |
srcset density form |
<source> |
"a.avif 1x, [email protected] 2x" — layer DPR switching on top of an art-directed crop |
Optional |
media with prefers-color-scheme |
<source> |
Selects a dark-mode-specific composition; re-evaluated when the OS theme flips | Optional |
extract() region (Sharp) |
— | {left, top, width, height} in master pixels; use instead of fit: 'cover' for focal-point crops |
For deterministic crops |
gravity (CDN transform) |
— | Normalised focal point x,y passed to an edge resizer in place of a build-time crop |
Edge pipelines only |
format=auto (CDN transform) |
— | Edge-side Accept negotiation; replaces the AVIF/WebP <source> pair |
Edge pipelines only |
Tradeoffs and Edge Cases
Safari 14 ignores type when media is also present on the same <source>.
Safari 14’s WebKit evaluates media first and then, if it matches, may skip the type check and attempt to decode whatever format is in srcset. If your AVIF source also has a media attribute, Safari 14 may request the AVIF and fail silently, rendering nothing until it falls through to <img>. Mitigation: always include a WebP <source> with the same media query immediately after each AVIF source, and verify with the approach in the validation section below.
position: 'entropy' is not deterministic across libvips versions.
The entropy-based focal point algorithm changed between libvips 8.9 and 8.12. If your CI and production servers run different libvips versions, crops will differ between environments. Pin the sharp package version and the underlying libvips version in your Docker base image to prevent this.
Mixing media and srcset width descriptors causes unexpected source selection.
Adding width descriptors (srcset="hero.avif 1200w") to a source that also has a media attribute combines two selection mechanisms. The browser uses width descriptors for density switching, but media already constrains which source is considered. Mixing them can produce nonsensical selections. For art direction, use plain URL srcsets (no w descriptors) and rely on media alone.
fetchpriority="high" starvation on multiple elements.
Setting fetchpriority="high" on more than one <picture>/<img> per page causes the browser’s preload scanner to treat all of them as equal priority, effectively cancelling the hint. The resource scheduler defaults back to standard priority ordering. Limit fetchpriority="high" to exactly one above-the-fold LCP image.
AVIF decode latency on low-end SoCs blocks INP.
AVIF uses a more complex entropy decoder than WebP or JPEG. On Snapdragon 665-class chips, a 1200×800 AVIF can take 40–80 ms to decode on the main thread even with decoding="async" — async decode defers the decode until after layout commit but does not move it off the main thread in all browsers. Monitor Core Web Vitals INP in field data segmented by device class to catch regressions.
One alt string has to describe every crop.
The alt attribute lives on the terminal <img> and cannot vary per <source>. Whichever crop the browser selects, the same text is announced. This breaks silently when the art direction is doing real editorial work: “Three chefs plating dishes in a busy kitchen” is accurate for the landscape crop and false for the portrait crop that contains one chef. Write alt against the smallest crop — the one with the least content — and let the wider compositions be described more richly than strictly necessary, or pull the differing detail into surrounding body text where it is available to every user regardless of viewport.
Dark-mode art direction re-fetches on theme change.
media="(prefers-color-scheme: dark)" is a legitimate <source> condition and is genuinely useful for images with baked-in backgrounds or light-on-dark diagrams. Because it participates in the same re-evaluation algorithm as min-width, flipping the OS theme mid-session triggers a source swap and a new network request. Keep the dark and light variants at identical dimensions so the swap cannot cause a layout shift, and prefer a transparent-background asset over a dual-variant pair whenever the artwork allows it.
<picture> inside content-visibility: auto defers selection entirely.
A <picture> in a subtree that is skipped by content-visibility: auto has no layout, so no media condition depending on layout state resolves and the browser defers the whole update-the-image-data step until the subtree is rendered. This is usually desirable — it is free lazy loading — but it means a <picture> you intended to be the LCP element will never be discovered by the preload scanner if any ancestor carries that property. Audit for it before concluding that fetchpriority="high" is being ignored; the same class of silent deferral is catalogued in native lazy loading for images and iframes.
SVG sources need no fallback pair but do need explicit dimensions.
type="image/svg+xml" is supported everywhere <picture> is, so an SVG <source> needs no companion raster source for capability reasons. It still needs width/height, because an SVG without an intrinsic viewBox-derived ratio collapses to zero height inside a <picture> and produces exactly the layout shift the element was chosen to prevent.
Debugging and Validation
Confirm which source was served
# Check that the correct Content-Type is returned for each breakpoint variant
# Replace User-Agent with one that matches your target browser tier
# Chromium 115 (AVIF + WebP support)
curl -sI "https://example.com/img/hero-lg.avif" \
-H "Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8" \
| grep -i "content-type"
# Expected: content-type: image/avif
# Safari 14 (WebP only)
curl -sI "https://example.com/img/hero-lg.webp" \
-H "Accept: image/webp,image/png,image/svg+xml,image/*;q=0.8,video/*;q=0.8,*/*;q=0.5" \
| grep -i "content-type"
# Expected: content-type: image/webp
# Verify your CDN sets Vary: Accept if it serves multiple formats from the same URL
curl -sI "https://example.com/img/hero-lg" \
| grep -i "vary"
# Expected: vary: Accept
# Missing Vary header causes CDN cache poisoning — all users receive the first-cached format
See MIME type configuration for modern media servers for setting the correct Content-Type headers on Nginx, Apache, and Caddy.
Check CLS in Chrome DevTools
- Open DevTools → Performance and record a page load with CPU throttling set to 4× and network throttling set to Fast 3G.
- In the Layout Shifts track, expand any shift events. A properly configured
<picture>element will show zero layout shifts from images — all space is reserved before the network response arrives. - If a shift occurs, inspect the element: the most common cause is a missing or mismatched
width/heightattribute on<img>, or anaspect-ratiooverride in CSS that contradicts the declared dimensions.
Lighthouse audit
# Run a Lighthouse audit targeting the page with art-directed images
npx lighthouse https://example.com/page-with-picture \
--only-categories=performance \
--output=json \
--output-path=./lh-report.json \
--chrome-flags="--headless"
# Check LCP and CLS from the report
node -e "
const r = require('./lh-report.json').audits;
console.log('LCP:', r['largest-contentful-paint'].displayValue);
console.log('CLS:', r['cumulative-layout-shift'].displayValue);
"
Target: LCP below 2.5 s on a mid-tier mobile device, CLS at 0.00.
WebPageTest filmstrip comparison
Load the page in WebPageTest using the Visual Comparison feature with two agents: one Chrome (AVIF path) and one Safari 14 (WebP path). Confirm both filmstrips show the correct crop rendering at the same visual milestone intervals. A diverging filmstrip at the 1-second mark typically indicates the AVIF source is being blocked while the WebP fallback loads.
Related
- Mastering srcset and sizes for responsive layouts — resolution switching and the
sizesattribute complement art direction in the same pipeline - How to calculate optimal sizes attribute values — precise
sizesvalues reduce over-fetching whensrcsetdescriptors are used alongside<picture> - AVIF vs WebP compression benchmarks — quantitative comparison guiding format selection in
<source type>chains - MIME type configuration for modern media servers — set correct
Content-Typeheaders so browsers accept AVIF and WebP sources - CSS container queries for dynamic media sizing — container-aware sizing as an alternative to viewport-based
mediaqueries in modular layouts - Using fetchpriority to optimise critical media — understand the starvation risk when combining
fetchpriority="high"with<picture>LCP candidates