Responsive <picture> patterns

Copy Markdown View Source

Modern responsive images solve four distinct problems, and a single <picture> element can address any combination of them. This guide shows how to express each pattern with Image.Components — what the library does for free, what you assemble from the URL builders, and when to drop down to writing the HTML by hand.

The four problems:

  • Format negotiation — serve AVIF to browsers that decode it, WebP to those that don't, JPEG/PNG as the universal fallback.
  • Density selection — serve a 1×, 2×, or 3× pixel-density variant based on the device's display resolution.
  • Width selection — serve an image whose pixel width matches the layout slot the image occupies, so a 320 px-wide <img> slot doesn't download a 1200 px image.
  • Art direction — show a different crop (not just a different size) at different viewport sizes — e.g. a tight portrait crop on phones, a wide landscape crop on desktop.

Format and density are about the same image at different bitrates. Width is about the same image at different pixel sizes. Art direction is about different images. The HTML element you reach for differs by case.

Pattern 1: Format negotiation (the easy one)

This is what <.picture> does for you. Pass :formats and the component emits one <source type="image/X" srcset="…"> per format plus a fallback <img>:

<.picture
  src="/uploads/cat.jpg"
  provider={:cloudflare}
  formats={[:avif, :webp]}
  width={800}
  fit={:cover}
/>

Renders to:

<picture>
  <source type="image/avif" srcset="/cdn-cgi/image/width=800,fit=cover,format=avif/uploads/cat.jpg" />
  <source type="image/webp" srcset="/cdn-cgi/image/width=800,fit=cover,format=webp/uploads/cat.jpg" />
  <img src="/cdn-cgi/image/width=800,fit=cover/uploads/cat.jpg" />
</picture>

The browser walks the <source> rows top-down and serves the first format whose type it can decode. Chrome and modern Safari take the AVIF row; older Safari takes WebP; legacy browsers fall through to the fallback <img>.

Order matters: put the most aggressive format first (smallest files for browsers that support it), the next-best second, and so on. AVIF before WebP is the conventional order — both render identically when both are supported, but the AVIF byte count is typically 30–50% smaller.

The fallback <img> uses whatever you set with format= if you set it, otherwise the original encoding. If you don't pass format=, browsers that fall through to the fallback get the original (often a JPEG); if you want them to also pick up a transform like quality=80, pass format={:jpeg} explicitly.

When <.picture> is the wrong choice

If you only want format negotiation and you're using format={:auto}, you don't need <picture> at all — the server does the negotiation via Vary: Accept. A bare <.image src="…" format={:auto}> is simpler:

<.image src="/uploads/cat.jpg" provider={:cloudflare} width={800} fit={:cover} format={:auto} />

The server reads the Accept header on the request and picks AVIF / WebP / JPEG accordingly. This works for any provider whose URL grammar supports format=auto (all four ship that). The downside is that the URL is opaque — you can't tell from the markup which format the browser actually got — and CDN caching needs Vary: Accept configured correctly (see image_plug's CDN-origin guide).

<.picture> is the right choice when you want explicit, inspectable format selection and you don't want to depend on the cache layer respecting Vary.

Pattern 2: Density selection (1x / 2x / 3x)

For Retina-class displays you want to send a 2× pixel-density variant of the same logical image. The HTML pattern is srcset with density descriptors:

<img src="/cat-400.jpg"
     srcset="/cat-400.jpg 1x, /cat-800.jpg 2x, /cat-1200.jpg 3x" />

The browser picks based on window.devicePixelRatio. The <img> element is laid out at its CSS dimensions (src's natural width by default, or whatever width= / CSS sets); the URL chosen from srcset controls resolution, not layout size.

<.image> doesn't yet ship a high-level helper for density variants, but the srcset attribute passes through via the :rest global and you can build the value by calling the URL projector directly:

defmodule MyAppWeb.Components.RetinaImage do
  use Phoenix.Component
  import Image.Components, only: [image: 1]

  alias Image.Components.URL
  alias Image.Plug.Pipeline
  alias Image.Plug.Pipeline.Ops

  attr :src, :string, required: true
  attr :width, :integer, required: true
  attr :rest, :global

  def retina(assigns) do
    base = %Pipeline{
      ops: [%Ops.Resize{width: assigns.width, fit: :contain}],
      output: %Ops.Format{type: :auto, quality: 80}
    }
    base_2x = put_in(base.ops, [%Ops.Resize{width: assigns.width * 2, fit: :contain}])
    base_3x = put_in(base.ops, [%Ops.Resize{width: assigns.width * 3, fit: :contain}])

    assigns =
      assign(assigns, :srcset,
        "#{URL.cloudflare(base, source_path: assigns.src)} 1x, " <>
        "#{URL.cloudflare(base_2x, source_path: assigns.src)} 2x, " <>
        "#{URL.cloudflare(base_3x, source_path: assigns.src)} 3x"
      )

    ~H"""
    <.image src={@src} provider={:cloudflare} width={@width} srcset={@srcset} {@rest} />
    """
  end
end

Use the wrapper:

<.retina src="/uploads/cat.jpg" width={400} alt="A cat" />

The width= you pass is the layout width in CSS pixels; the srcset provides 1×, 2×, and 3× resolution variants. The browser picks whichever matches the display.

If you don't want the abstraction layer, write the srcset inline — the URL projector is a plain function call and the result is just a string.

Pattern 3: Width selection (fluid images with srcset + sizes)

When the image's layout slot changes width across viewports — e.g. it's full-width on phones but half-width on desktop — the right pattern is srcset with width descriptors plus a sizes hint:

<img src="/cat-800.jpg"
     srcset="/cat-400.jpg 400w,
             /cat-800.jpg 800w,
             /cat-1200.jpg 1200w,
             /cat-1600.jpg 1600w"
     sizes="(max-width: 768px) 100vw, 50vw" />

The browser combines sizes (which tells it the image's slot width at the current viewport) with devicePixelRatio to compute the ideal pixel width, then picks the closest entry from srcset. On a 768 px-wide phone with DPR 2 the ideal is 768 * 2 = 1536 px, so the 1600w row wins; on a 1440 px desktop displaying the image at 50vw with DPR 1 the ideal is 720 px, so the 800w row wins.

The same wrapper pattern as the density example, with width descriptors instead:

defmodule MyAppWeb.Components.FluidImage do
  use Phoenix.Component
  import Image.Components, only: [image: 1]

  alias Image.Components.URL
  alias Image.Plug.Pipeline
  alias Image.Plug.Pipeline.Ops

  @breakpoints [400, 800, 1200, 1600]

  attr :src, :string, required: true
  attr :sizes, :string, required: true
  attr :base_width, :integer, default: 800
  attr :rest, :global

  def fluid(assigns) do
    srcset =
      Enum.map_join(@breakpoints, ", ", fn w ->
        pipeline = %Pipeline{
          ops: [%Ops.Resize{width: w, fit: :contain}],
          output: %Ops.Format{type: :auto, quality: 80}
        }
        "#{URL.cloudflare(pipeline, source_path: assigns.src)} #{w}w"
      end)

    assigns = assign(assigns, srcset: srcset)

    ~H"""
    <.image
      src={@src}
      provider={:cloudflare}
      width={@base_width}
      srcset={@srcset}
      sizes={@sizes}
      {@rest}
    />
    """
  end
end

Use it:

<.fluid
  src="/uploads/cat.jpg"
  sizes="(max-width: 768px) 100vw, 50vw"
  alt="A cat"
/>

Picking breakpoints

[400, 800, 1200, 1600] is a reasonable starting set. Cloudinary, imgix, and Cloudflare all recommend powers-close-to-2 spacing (each step roughly doubling) — that gives the browser useful options without over-fragmenting your CDN cache. Five steps is usually enough; go finer than that and the cache fragmentation outweighs the bandwidth savings.

If you serve high-DPR devices commonly, add a 2400w or 3200w step. If your design caps image display width at 600 px, drop the 1200w and 1600w rows.

sizes accuracy matters

The browser trusts sizes. If you tell it (max-width: 768px) 100vw, 50vw but the image actually occupies (max-width: 768px) 100vw, 33vw, you'll over-download. Get sizes right or browsers will pick a higher-resolution row than they need.

A common shortcut: when the slot width is fixed across all viewports (e.g. an avatar that's always 64 px), skip width-srcset entirely and use density-srcset (Pattern 2) instead. Width-srcset is for fluid layouts; density-srcset is for fixed layouts.

Pattern 4: Art direction

Art direction is for when the image content should differ across viewports — typically a tighter crop on phones (where there's less room for context) and a wider crop on desktop (where there is). The HTML pattern uses <source media="…"> inside <picture>:

<picture>
  <source media="(max-width: 768px)"
          srcset="/cat-portrait.jpg" />
  <source media="(min-width: 769px)"
          srcset="/cat-landscape.jpg" />
  <img src="/cat-landscape.jpg" alt="A cat" />
</picture>

The browser evaluates each <source>'s media query and uses the first match. The fallback <img> runs if no <source> matches (rare with sensible queries) or if the browser doesn't understand <picture>.

<.picture> doesn't currently emit media attributes, so for art direction you call the URL builders directly and write the <picture> markup yourself:

<picture>
  <source
    media="(max-width: 768px)"
    srcset={Image.Components.URL.cloudflare(
      %Image.Plug.Pipeline{
        ops: [%Image.Plug.Pipeline.Ops.Resize{
          width: 600, height: 800, fit: :cover, gravity: :face, face_zoom: 0.7
        }],
        output: %Image.Plug.Pipeline.Ops.Format{type: :auto, quality: 80}
      },
      source_path: "/uploads/cat.jpg"
    )}
  />
  <source
    media="(min-width: 769px)"
    srcset={Image.Components.URL.cloudflare(
      %Image.Plug.Pipeline{
        ops: [%Image.Plug.Pipeline.Ops.Resize{
          width: 1600, height: 600, fit: :cover, gravity: :center
        }],
        output: %Image.Plug.Pipeline.Ops.Format{type: :auto, quality: 80}
      },
      source_path: "/uploads/cat.jpg"
    )}
  />
  <.image src="/uploads/cat.jpg" provider={:cloudflare} width={1600} fit={:cover} alt="A cat" />
</picture>

That's verbose. If you need art direction more than once, wrap it:

defmodule MyAppWeb.Components.HeroImage do
  use Phoenix.Component
  import Image.Components, only: [image: 1]

  alias Image.Components.URL
  alias Image.Plug.Pipeline
  alias Image.Plug.Pipeline.Ops

  attr :src, :string, required: true
  attr :alt, :string, required: true

  def hero(assigns) do
    portrait =
      URL.cloudflare(
        %Pipeline{
          ops: [%Ops.Resize{width: 600, height: 800, fit: :cover, gravity: :face, face_zoom: 0.7}],
          output: %Ops.Format{type: :auto, quality: 80}
        },
        source_path: assigns.src
      )

    landscape =
      URL.cloudflare(
        %Pipeline{
          ops: [%Ops.Resize{width: 1600, height: 600, fit: :cover, gravity: :center}],
          output: %Ops.Format{type: :auto, quality: 80}
        },
        source_path: assigns.src
      )

    assigns = assign(assigns, portrait: portrait, landscape: landscape)

    ~H"""
    <picture>
      <source media="(max-width: 768px)" srcset={@portrait} />
      <source media="(min-width: 769px)" srcset={@landscape} />
      <.image src={@src} provider={:cloudflare} width={1600} fit={:cover} alt={@alt} />
    </picture>
    """
  end
end

Use it:

<.hero src="/uploads/banner.jpg" alt="Hero banner" />

The face-aware portrait crop on phones; the centred wide crop on desktop. One source image, two crops, one component.

Combining patterns

The four patterns compose. The fully-loaded <picture> for a hero image might do all three of format negotiation, art direction, and width selection:

<picture>
  <!-- Phone: portrait crop, AVIF -->
  <source media="(max-width: 768px)"
          type="image/avif"
          srcset="/.../portrait-400.avif 400w, /.../portrait-800.avif 800w"
          sizes="100vw" />
  <!-- Phone: portrait crop, WebP -->
  <source media="(max-width: 768px)"
          type="image/webp"
          srcset="/.../portrait-400.webp 400w, /.../portrait-800.webp 800w"
          sizes="100vw" />
  <!-- Desktop: landscape crop, AVIF -->
  <source media="(min-width: 769px)"
          type="image/avif"
          srcset="/.../landscape-1200.avif 1200w, /.../landscape-1600.avif 1600w"
          sizes="100vw" />
  <!-- Desktop: landscape crop, WebP -->
  <source media="(min-width: 769px)"
          type="image/webp"
          srcset="/.../landscape-1200.webp 1200w, /.../landscape-1600.webp 1600w"
          sizes="100vw" />
  <!-- Universal fallback -->
  <img src="/.../landscape-1200.jpg" alt="Hero banner" />
</picture>

This is six <source> rows for two crops × three formats × N widths each. Unwieldy to write inline; build it with the same wrapper pattern as HeroImage above, only with the source URLs computed from a list of formats and widths:

defp source_rows(crop_pipeline, src, formats, widths) do
  for format <- formats do
    srcset =
      widths
      |> Enum.map_join(", ", fn w ->
        pipeline = put_format(put_width(crop_pipeline, w), format)
        "#{Image.Components.URL.cloudflare(pipeline, source_path: src)} #{w}w"
      end)

    %{type: "image/#{format}", srcset: srcset}
  end
end

…and render the rows with <source :for={s <- @sources} type={s.type} srcset={s.srcset} sizes="100vw" />.

This is the point where it's worth stepping back. A six-source <picture> typically saves <5% bandwidth over a three-source one (browser already picks the best format and width from a single AVIF row). Pick the patterns that match how your site varies, and don't add complexity for compression gains under 10%.

Choosing a pattern

You want…Use
Same image, AVIF/WebP fallback to JPEG<.picture formats={[:avif, :webp]}>
Same image, format chosen server-side<.image format={:auto}> (rely on Vary: Accept)
Same image, 2× variant for Retina<.image srcset={"<1x> 1x, <2x> 2x"}>
Same image, sized to layout slot<.image srcset={…widths…} sizes={"…"}>
Different crop per breakpointHand-rolled <picture> with <source media>
All of the aboveHand-rolled <picture> with sources × formats × widths

The components are a building block, not a framework. For trivial cases use them as-is; for non-trivial cases, treat them as the leaf and build your own per-app wrapper that emits the markup you need.

Performance hints

Independent of <picture> structure, three <img> attributes have outsized impact:

  • loading="lazy" — defer fetch until the image scrolls near the viewport. Apply to anything below-the-fold. Harmful on above-the-fold images (LCP), so the rule is: lazy by default, explicit loading="eager" for the hero.

  • decoding="async" — let the browser decode off the main thread. No reason not to use this everywhere; the only downside is a tiny chance of layout flash if you're not also setting width/height for the layout dimension.

  • fetchpriority="high" — boost the priority of a critical above-the-fold image. Use sparingly; promoting too many images defeats the purpose.

All three pass through via :rest on both <.image> and <.picture>:

<.image
  src="/uploads/hero.jpg"
  provider={:cloudflare}
  width={1600}
  fit={:cover}
  loading="eager"
  decoding="async"
  fetchpriority="high"
  alt="Hero banner"
/>

<.image
  src="/uploads/thumb.jpg"
  provider={:cloudflare}
  width={400}
  fit={:cover}
  loading="lazy"
  decoding="async"
  alt="Article thumbnail"
/>

Always set width and height

Whether you use <.image> or <.picture>, set the layout width and height (in CSS pixels) so the browser reserves the correct space before the image decodes. Without it, you get layout shift as each image loads — bad for users and bad for Core Web Vitals (CLS).

Your width= attribute serves two purposes in <.image>: it goes into the URL (/cdn-cgi/image/width=400/…) and it sets the <img width="…"> HTML attribute. The second use prevents layout shift; the first ensures the byte payload matches the layout. Don't skip either.

For art-directed <picture> cases where the crop's aspect ratio differs across breakpoints, set the dimensions in CSS — <img> HTML width/height describe a single intrinsic ratio, which is wrong for the multi-aspect case.