Astro Picture component for AVIF and WebP

A single-format <Image> is fine when WebP alone covers your audience, but the moment you want the smaller AVIF with a safe fallback for browsers that lack it, you need Astro’s <Picture> — it emits a real <picture> element with one <source> per format and a fallback <img>, all with intrinsic dimensions baked in at build time. This guide is part of the Astro Image and Picture Components section within Framework & Build-Tool Media Integration, and shows the exact <Picture> invocation for an AVIF-first, WebP-second, JPEG-fallback cascade — with responsive widths, honest sizes, priority for the LCP case, and the verification steps that prove the encoder emitted what you asked for.

Prerequisite checklist

How the cascade resolves in the browser

<Picture formats={['avif','webp']}> emits <source type="image/avif"> first, <source type="image/webp"> second, and a fallback <img> (JPEG/PNG by default) last. The browser walks the sources top-to-bottom and takes the first type it can decode: AVIF for Chrome/Firefox and Safari 16+, WebP for Safari 14–15, and the JPEG <img> for anything older. Because the file for each width already exists on disk after the build, no runtime negotiation or server logic is involved — the browser simply picks a URL. The precise Safari version boundary and why AVIF-only markup breaks Safari 15 is detailed in how to configure AVIF fallbacks for Safari 14.

Astro Picture source cascade The build emits a picture with an avif source, a webp source, and a jpg fallback img. Chrome, Firefox and Safari 16+ match the avif source; Safari 14 and 15 match webp; Safari 13 and older browsers fall back to the jpg img. <picture> emitted formats={['avif','webp']} <source type="image/avif"> <source type="image/webp"> <img src="…jpg"> fallback Chrome · Firefox Safari 16+ Safari 14 – 15 Safari 13 · legacy smallest universal browser takes the first <source> type it can decode; order = formats array order

Exact solution

Step 1 — Author the <Picture> element

---
// hero.astro — build-time frontmatter
import { Picture } from 'astro:assets';
// Local import → typed object with intrinsic width/height. Astro reads these
// to set the fallback <img> dimensions automatically, so CLS is impossible.
import hero from '../assets/hero.jpg';
---

<!--
  formats order = <source> order. AVIF first so capable browsers get the
  smallest file; WebP second for Safari 14–15; the fallback <img> is JPEG.
  fallbackFormat controls that <img>'s format (defaults to the source's own,
  jpg here) — keep it a universally-decodable format.

  widths generates a responsive srcset for EACH format. sizes tells the
  browser the layout width so it can pick the right candidate — it must be
  honest about how much of the viewport the image really occupies.

  alt is REQUIRED; Astro fails the build without it.
-->
<Picture
  src={hero}
  formats={['avif', 'webp']}
  fallbackFormat="jpg"
  widths={[400, 800, 1200, 1600]}
  sizes="(max-width: 800px) 100vw, (max-width: 1400px) 66vw, 1200px"
  alt="Studio product hero on a neutral background"
  quality="mid"
/>

Step 2 — Mark the above-the-fold image as the LCP candidate

---
import { Picture } from 'astro:assets';
import hero from '../assets/hero.jpg';
---

<!--
  priority bundles loading="eager", decoding="sync", and fetchpriority="high"
  onto the fallback <img>. Use it on EXACTLY ONE image per page — the LCP
  candidate. Applying it to several images floods the high-priority fetch
  queue and starves CSS/fonts. Omit priority on below-fold Pictures so they
  default to loading="lazy".
-->
<Picture
  src={hero}
  formats={['avif', 'webp']}
  fallbackFormat="jpg"
  widths={[400, 800, 1200, 1600]}
  sizes="(max-width: 800px) 100vw, 1200px"
  alt="Studio product hero on a neutral background"
  priority
/>

Step 3 — The markup Astro emits

<!-- Approximate output of the <Picture> above (hashes abbreviated): -->
<picture>
  <source
    type="image/avif"
    srcset="/_astro/hero.a1.avif 400w, /_astro/hero.b2.avif 800w, /_astro/hero.c3.avif 1200w, /_astro/hero.d4.avif 1600w"
    sizes="(max-width: 800px) 100vw, 1200px">
  <source
    type="image/webp"
    srcset="/_astro/hero.e5.webp 400w, /_astro/hero.f6.webp 800w, /_astro/hero.g7.webp 1200w, /_astro/hero.h8.webp 1600w"
    sizes="(max-width: 800px) 100vw, 1200px">
  <!-- width/height come from the import; they set the box and prevent CLS. -->
  <img
    src="/_astro/hero.i9.jpg"
    alt="Studio product hero on a neutral background"
    width="1600" height="900"
    loading="eager" decoding="sync" fetchpriority="high">
</picture>

Verification

1. Confirm the source order and format types

# Extract the generated <picture> from the built page. The FIRST <source>
# must be type="image/avif"; if webp appears first, the formats array order
# is wrong and AVIF-capable browsers will waste bytes on WebP.
grep -oE '<(picture|source|img)[^>]*>' dist/index.html | head -n 12

2. Confirm every width was emitted per format

# Four widths × two next-gen formats = eight derivatives, plus one jpg fallback.
find dist/_astro -name 'hero.*' \( -name '*.avif' -o -name '*.webp' -o -name '*.jpg' \) \
  -printf '%f  %s bytes\n' | sort
# At each matching width the AVIF file should be clearly smaller than the WebP;
# if they are near-identical, the Sharp AVIF encoder did not run (check service).

3. Confirm the byte savings and the fallback

# The 1200w AVIF should be roughly 40–50% smaller than the 1200w JPEG.
ls -l dist/_astro/hero.*avif dist/_astro/hero.*jpg

Expected outcomes:

Check Expected Failure meaning
First <source> type="image/avif" Wrong formats order — WebP would win on AVIF browsers
Derivative files 8 next-gen + 1 jpg Missing files → a width or format dropped
AVIF vs JPEG (1200w) AVIF ~40–50% smaller Similar sizes → AVIF encode did not run
<img> attributes width, height, and (if priority) fetchpriority="high" Missing dims → CLS risk; missing priority → LCP not prioritised

Common mistakes and fixes

1. Wrong format order

Anti-pattern: formats={['webp', 'avif']}.

Effect: WebP’s <source> comes first, so every AVIF-capable browser matches WebP and never downloads the smaller AVIF — the compression win is silently discarded.

Fix: always list the smallest format first: formats={['avif', 'webp']}. Source order in the emitted <picture> mirrors the array order.

2. Missing or dishonest sizes

Anti-pattern: providing widths but no sizes, or a sizes="100vw" on an image that is actually half-width.

Effect: without sizes the browser assumes 100vw and over-downloads; with a dishonest sizes it picks a candidate too large or too small for the real box, wasting bytes or blurring.

Fix: always pair widths with a sizes that reflects the layout. Compute it with the method in how to calculate optimal sizes attribute values.

3. Remote image not authorised

Anti-pattern: <Picture src="https://cdn.example.com/hero.jpg" …> with the host missing from config.

Effect: Astro will not fetch and re-encode an arbitrary origin, so the image is emitted at its original bytes — no AVIF, no resizing.

Fix: add the host to image.domains or image.remotePatterns in astro.config.mjs, and pass explicit width/height (Astro cannot infer remote dimensions without inferSize).

4. Confusing densities with widths

Anti-pattern: using densities={[1, 2]} for a fluid hero that spans the viewport.

Effect: densities generates only 1x/2x candidates sized off the base width — fine for a fixed avatar, but for a fluid image it produces too few candidates, so large viewports get an upscaled, soft image.

Fix: use widths={[400, 800, 1200, 1600]} + sizes for fluid images; reserve densities for fixed-size elements. They are mutually exclusive.

5. Applying priority to several images

Anti-pattern: marking the hero, a logo, and the first card all priority.

Effect: each gets fetchpriority="high", flooding the high-priority queue and starving CSS/fonts — First Contentful Paint regresses for everyone.

Fix: reserve priority for the single LCP candidate. Every other <Picture> should omit it and default to lazy loading.