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.
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.
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.
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 |
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.
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