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:

  1. Media query wins first. If the viewport does not satisfy the media attribute the source is skipped entirely β€” type is never evaluated.
  2. 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

Art Direction vs Resolution Switching Decision Flow A flowchart showing when to choose the picture element for art direction versus srcset for resolution switching based on whether the composition changes across viewports. Responsive image needed? Different crop / composition per BP? Yes Use <picture> art direction No Use srcset + sizes Different focal point, aspect ratio, or crop Same composition, different resolution Format-only <picture> type negotiation only

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

  1. Open DevTools β†’ Performance and record a page load with CPU throttling set to 4Γ— and network throttling set to Fast 3G.
  2. 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.
  3. If a shift occurs, inspect the element: the most common cause is a missing or mismatched width/height attribute on <img>, or an aspect-ratio override 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.