A practical walk-through of <.image> and <.picture> and the four URL builders that back them. For an overview of what the library does and the per-CDN feature gap table, see the README.
The two components
Image.Components exposes two function components designed to be imported at the top of any LiveView or HEEx-using module:
defmodule MyAppWeb.GalleryLive do
use MyAppWeb, :live_view
import Image.Components
def render(assigns) do
~H"""
<.image src="/uploads/cat.jpg" provider={:cloudflare} width={600} fit={:cover} />
<.picture
src="/uploads/cat.jpg"
provider={:cloudflare}
formats={[:avif, :webp]}
width={1200}
/>
"""
end
end<.image> renders a single <img>; <.picture> renders a <picture> with one <source srcset> per format in :formats (default [:avif, :webp]) plus a fallback <img>. There is no JavaScript, no LiveView-specific behaviour, and no runtime overhead beyond URL string construction — both compile to plain HTML.
How a request flows
┌─────────────────────────────┐
HEEx attribute set ─►│ Image.Components.image/1 │
│ build_pipeline/1 │
│ Image.Components.URL.…/2 │
└──────────────┬──────────────┘
│ src="/img/cdn-cgi/image/…/cat.jpg"
▼
┌─────────────────────────────┐
│ Browser fetches the URL │
└──────────────┬──────────────┘
▼
┌─────────────────────────────┐
│ image_plug forward route │
│ Provider parser │
│ → same Pipeline IR │
│ Interpreter │
│ → libvips transforms │
└──────────────┬──────────────┘
▼
transformed bytes backThe IR is the contract — the four URL builders project it to URLs; the four image_plug providers parse those URLs back to it; the interpreter executes it against Vix.Vips.Image. A round-trip from attribute set to served bytes is just two ends of the same struct.
Choosing a provider
provider is required and accepts one of :cloudflare, :cloudinary, :imgix, :imagekit. The four URL grammars are very different, but Image.Components papers over those differences: the same component call works against any of them.
<.image src="/cat.jpg" provider={:cloudflare} width={600} fit={:cover} />
<.image src="/cat.jpg" provider={:cloudinary} width={600} fit={:cover} />
<.image src="/cat.jpg" provider={:imgix} width={600} fit={:cover} />
<.image src="/cat.jpg" provider={:imagekit} width={600} fit={:cover} />The four src= URLs that result are different — but each one, served by a correctly mounted image_plug provider, produces the same transformed image.
Hosts and mounts
host= is prepended verbatim. Use it to point at a real CDN edge, or to scope under a path on your own domain:
<%!-- Self-hosted via image_plug, mounted at /img on this app --%>
<.image src="/cat.jpg" provider={:cloudflare} host="/img" />
<%!-- Real Cloudflare Images (or Cloudflare Workers) --%>
<.image src="/cat.jpg" provider={:cloudflare} host="https://images.example.com" />
<%!-- imgix source --%>
<.image src="/cat.jpg" provider={:imgix} host="https://my-source.imgix.net" />The Cloudflare URL projector adds /cdn-cgi/image/… after the host. The Cloudinary projector adds /<account>/image/upload/…. Read the per-provider grammar in the README's feature gap table for details.
Picking a face-aware crop
Use gravity={:face} together with face_zoom. face_zoom defaults to 0.0 which is a loose crop with full padding — usually visually indistinguishable from a centred crop. Set it to a non-zero value (Cloudflare's documented default is 0.6) to actually see the face-aware behaviour.
<.image
src="/portrait.jpg"
provider={:cloudflare}
width={300}
height={300}
fit={:cover}
gravity={:face}
face_zoom={0.6}
/>Behind the scenes, image_plug's Image.Plug.FaceAware.face_crop/2 invokes Image.FaceDetection.crop_largest/2 from image_vision. If image_vision is not in the consumer's deps, the request still succeeds — it falls through to the libvips attention-saliency crop. See image_plug's face-aware guide for the full story.
face_zoom projects to:
- Cloudflare:
face-zoom=<float> - Cloudinary:
z_<float> - ImageKit:
z-<float> - imgix: silently dropped (no equivalent in imgix's URL grammar)
Adjust effects: per-CDN encoding
brightness, contrast, saturation, gamma are all multipliers where 1.0 means "no change", but the four CDNs encode them very differently:
<%!-- Same intent, four different on-wire URLs --%>
<.image src="/cat.jpg" provider={:cloudflare} contrast={1.4} />
<%!-- → /cdn-cgi/image/contrast=1.4/cat.jpg (raw multiplier) --%>
<.image src="/cat.jpg" provider={:cloudinary} contrast={1.4} />
<%!-- → /demo/image/upload/e_contrast:40/cat.jpg (centred percentage) --%>
<.image src="/cat.jpg" provider={:imgix} contrast={1.4} />
<%!-- → /cat.jpg?con=40 (centred percentage) --%>
<.image src="/cat.jpg" provider={:imagekit} contrast={1.4} />
<%!-- → /demo/cat.jpg (silently dropped) --%>ImageKit's URL grammar has no parameterised contrast — only an unparameterised e-contrast toggle. Image.Components.URL.imagekit/2 faithfully drops the value rather than approximating it. If you need the same visual contrast across all four providers, ImageKit will be the odd one out.
Vignette and tint
These two are honest single-CDN features:
vignette={0.6}projects to Cloudinary'se_vignette:60. The other three providers drop it.tint="#80a0c0"projects to imgix'smonochrome=80a0c0. The other three providers drop it.
<.image src="/cat.jpg" provider={:cloudinary} vignette={0.6} />
<.image src="/cat.jpg" provider={:imgix} tint="#80a0c0" />tint accepts a hex string ("#aabbcc" or "aabbcc") or an [r, g, b] integer list. Both forms are normalised to [r, g, b] before they enter the IR, so the type invariant on Image.Plug.Pipeline.Ops.Tint.color ([non_neg_integer()]) holds regardless of how the component was called.
<.picture> for content negotiation
Use <.picture> when you want the browser to pick the best supported format from a short list:
<.picture
src="/photo.jpg"
provider={:cloudflare}
formats={[:avif, :webp]}
width={1200}
fit={:cover}
/>This emits one <source type="image/avif" srcset="…">, one <source type="image/webp" srcset="…">, and a fallback <img> whose src uses the format= you set explicitly (or the original format if you didn't). The browser walks the <source> rows in order and picks the first one whose MIME type it can decode.
The full transform set (width, height, blur, contrast, etc.) is shared across all rows — only the format differs.
Pre-computing pipelines without rendering
If you need the URL or pipeline without going through HEEx — for example, in a JSON API response or a job that pre-warms a CDN — call the URL builders directly:
alias Image.Components.URL
alias Image.Plug.Pipeline
alias Image.Plug.Pipeline.Ops
pipeline = %Pipeline{
ops: [%Ops.Resize{width: 600, fit: :cover, gravity: :face, face_zoom: 0.6}],
output: %Ops.Format{type: :webp, quality: 80}
}
URL.cloudflare(pipeline, source_path: "/cat.jpg", host: "/img")
# => "/img/cdn-cgi/image/width=600,fit=cover,gravity=face,face-zoom=0.6,format=webp,quality=80/cat.jpg"Or build the same pipeline from a flat attribute map via the @doc false Image.Components.build_pipeline helper:
pipeline =
Image.Components.build_pipeline(%{
width: 600,
fit: :cover,
gravity: :face,
face_zoom: 0.6,
format: :webp,
quality: 80
})build_pipeline/1 is what <.image> and <.picture> use internally. It's hidden from ExDoc because it's not part of the stable public API surface, but it's useful for callers that want the same attr-map → IR translation.
See also
- The README's feature gap table — what each CDN's URL grammar can carry.
Image.Components.URL— module docs for the four URL builders, including provider semantic differences.image_plug's usage guide — how to mount the four providers in a Phoenix endpoint or Plug.Router.image_plug's face-aware guide — the seam betweenimage_plugandimage_visionand what happens whenimage_visionis absent.image_playground— exercises every transform in this guide live, with sliders.