Phoenix.Component wrappers (<.image> and <.picture>) that emit URLs in the documented URL grammars of four major image CDNs plus the IIIF Image API 3.0 standard:
- Cloudflare Images (
/cdn-cgi/image/<options>/<source>) - Cloudinary (
/<account>/image/upload/<options>/<source>) - imgix (
/<source>?<options>) - ImageKit (
/<endpoint>/tr:<options>/<source>) - IIIF (
/iiif/3/<id>/<region>/<size>/<rotation>/<quality>.<format>)
Point the host= attribute at your real Cloudflare / Cloudinary / imgix / ImageKit account and the URLs <.image> produces hit those services directly. There is no Elixir-side image processing in the request path, no proxy server you have to run, and no operational dependency on the rest of the elixir-image libraries — just URL string construction in your render template, exactly like every other Phoenix.Component.
<.image
src="/cat.jpg"
provider={:cloudflare}
host="https://imagedelivery.net/<your-account-hash>"
width={600}
fit={:cover}
format={:webp}
quality={80}
/>That's it — the rendered <img src="…"> URL is the one Cloudflare Images itself parses and transforms. Same template against any of the four providers; just change provider= and host=.
def deps do
[
{:image_components, "~> 0.1"}
]
endThat's the whole runtime requirement. image_components brings in phoenix_live_view (for the component machinery) and image_plug (for the canonical Pipeline IR struct that the URL builders consume — image_plug is not invoked at runtime, the struct is just a convenient data carrier shared with the server-side library).
If you also want to self-host the image-processing service — for development, for tests, or as your production origin — mount image_plug somewhere on your Phoenix endpoint and set host= accordingly. See Local server in dev, native CDN in prod for the recipe. The components don't care whether the URL ends up at the real CDN's edge or at your own image_plug mount; both speak the same URL grammar.
In a LiveView or function component:
defmodule MyAppWeb.PageLive do
use MyAppWeb, :live_view
import Image.Components
def render(assigns) do
~H"""
<.image
src="/uploads/cat.jpg"
provider={:cloudflare}
width={600}
fit={:cover}
format={:webp}
quality={80}
/>
<.picture
src="/uploads/cat.jpg"
provider={:cloudflare}
formats={[:avif, :webp]}
width={600}
/>
"""
end
endThe components render plain HTML (<img> and <picture>); the only "magic" is that src= (or each <source srcset=>) is built by Image.Components.URL.<provider>/2. There is no JavaScript and no LiveView-specific behaviour.
Each CDN needs a provider= and a host=. Two providers also need an account/endpoint segment in the URL path; the components default both to "demo" (a public test account on each service) so the quick-start examples Just Work, but you'll override them once you point at your own account.
| Provider | provider= |
host= (your CDN's edge) |
Account segment attribute |
|---|---|---|---|
| Cloudflare Images | :cloudflare |
"https://imagedelivery.net/<account-hash>" (hosted form) or "https://your-zone.example.com" (zone form) |
n/a — the account hash is in the host |
| Cloudinary | :cloudinary |
"https://res.cloudinary.com" |
cloudinary_account="<your-cloud-name>" |
| imgix | :imgix |
"https://<your-source>.imgix.net" |
n/a — the source is in the host |
| ImageKit | :imagekit |
"https://ik.imagekit.io" |
imagekit_endpoint="<your-endpoint>" |
| IIIF | :iiif |
"https://iiif.example.org" (your IIIF server's base) |
iiif_prefix="/iiif/3" (the version prefix the server publishes; default "/iiif/3") |
A typical app sets these via Application config so render templates stay clean:
# config/runtime.exs
config :my_app, :image_cdn,
provider: :cloudinary,
host: System.fetch_env!("CDN_HOST"),
cloudinary_account: System.fetch_env!("CLOUDINARY_CLOUD_NAME")…and read it in a thin per-app wrapper component:
defmodule MyAppWeb.Components.Image do
use Phoenix.Component
import Image.Components, only: [image: 1]
attr :src, :string, required: true
attr :rest, :global, include: ~w(width height fit gravity dpr face_zoom format
quality blur sharpen brightness contrast
saturation gamma vignette tint alt class srcset
sizes loading decoding)
def img(assigns) do
cdn = Application.fetch_env!(:my_app, :image_cdn)
assigns = assign(assigns,
provider: cdn[:provider],
host: cdn[:host],
cloudinary_account: cdn[:cloudinary_account] || "demo",
imagekit_endpoint: cdn[:imagekit_endpoint] || "demo"
)
~H"""
<.image
src={@src}
provider={@provider}
host={@host}
cloudinary_account={@cloudinary_account}
imagekit_endpoint={@imagekit_endpoint}
{@rest}
/>
"""
end
endThen everywhere else in your app:
<.img src="/cat.jpg" width={600} fit={:cover} alt="A cat" />The full per-environment recipe (different host= in dev/test/prod, conditionally mounting image_plug for local development) is in the environments guide.
If you only need URLs, skip the components and call the projector 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}],
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,format=webp,quality=80/cat.jpg"Adjust effects (brightness, contrast, saturation, gamma)
have one IR — multipliers where 1.0 = no change — but the four
CDNs encode them differently:
- Cloudflare takes the raw multiplier directly:
contrast=1.4. - Cloudinary and imgix take centred percentages in
-100..100:e_contrast:40/con=40(both equivalent to1.4). - ImageKit has no parameterised brightness/contrast/saturation/
gamma in its URL grammar — only an unparameterised
e-contrasttoggle. The IR multiplier cannot be faithfully expressed and is silently dropped. No approximation is performed; the resulting URL is what ImageKit can carry.
Similarly: vignette survives only into Cloudinary
(e_vignette:N); tint survives only into imgix
(monochrome=<hex>). The other CDNs drop these silently.
| IR op | Cloudflare | Cloudinary | imgix | ImageKit | IIIF |
|---|---|---|---|---|---|
Resize |
✓ | ✓ | ✓ | ✓ | ✓ (fit: :cover —) |
Format |
✓ | ✓ | ✓ | ✓ | ✓ (:auto →fallback) |
Adjust |
✓ (raw mult.) | ✓ (centred) | ✓ (centred) | — | gray only |
Blur |
✓ | ✓ | ✓ | ✓ | — |
Sharpen |
✓ | ✓ | ✓ | ✓ | — |
Vignette |
— | ✓ | — | — | — |
Tint |
— | — | ✓ (mono only) | — | — |
Rotate |
✓ | — | ✓ | ✓ | ✓ (any 0..360) |
Trim |
✓ | — | ✓ | — | — |
Background |
✓ | — | ✓ | ✓ | — |
face_zoom |
✓ | ✓ | — | ✓ | — |
Crop |
— | — | — | — | ✓ |
Posterize{2} |
— | — | — | — | ✓ (→ bitonal) |
Empty cells = no equivalent in that grammar. IIIF is the only entry that actually expresses sub-region cropping in URL form (region segment); CDN providers express crop indirectly via fit modes.
Renders a single <img> whose src is the projected URL.
<.image
src="/uploads/cat.jpg"
provider={:cloudflare}
host="/img"
width={600}
height={400}
fit={:cover}
gravity={:face}
face_zoom={0.6}
format={:webp}
quality={80}
blur={2.5}
brightness={1.1}
contrast={1.2}
alt="A cat"
class="rounded-lg"
/>See Image.Components.image/1 for the full attribute reference.
Renders a <picture> with one <source srcset=> per format in
:formats (default [:avif, :webp]) plus a fallback <img>.
<.picture
src="/uploads/cat.jpg"
provider={:cloudflare}
formats={[:avif, :webp]}
width={1200}
fit={:cover}
/>See Image.Components.picture/1 for the full attribute reference.
A provider is a single function from Image.Plug.Pipeline.t() plus an options keyword list to a URL string. To add a new CDN — say Bunny.net Image Optimizer, or an internal one — write a module with one public function per CDN you support, mirroring Image.Components.URL:
defmodule MyApp.URL do
alias Image.Plug.Pipeline
alias Image.Plug.Pipeline.Ops
@spec bunny(Pipeline.t(), keyword()) :: String.t()
def bunny(%Pipeline{} = pipeline, options \\ []) do
query = pipeline |> bunny_options() |> URI.encode_query()
source = Keyword.get(options, :source_path, "/sample.jpg")
host = Keyword.get(options, :host, "")
if query == "", do: "#{host}#{source}", else: "#{host}#{source}?#{query}"
end
defp bunny_options(pipeline) do
resize = Enum.find(pipeline.ops, &match?(%Ops.Resize{}, &1))
output = pipeline.output
[]
|> opt("width", resize && resize.width)
|> opt("height", resize && resize.height)
|> opt("aspect_ratio", resize && resize.fit && bunny_fit(resize.fit))
|> opt("quality", output && output.quality)
# …add per-op tokens as you support them.
end
defp opt(acc, _key, nil), do: acc
defp opt(acc, key, value), do: acc ++ [{key, to_string(value)}]
defp bunny_fit(:cover), do: "1:1"
defp bunny_fit(_), do: nil
endThen expose it through your own component, or extend the <.image> you wrap in your app:
defp build_url(:bunny, pipeline, options), do: MyApp.URL.bunny(pipeline, options)
defp build_url(other, pipeline, options), do: apply(Image.Components.URL, other, [pipeline, options])The provider behaviour is informal — there is no @behaviour to implement. Each builder takes (pipeline, options) and returns a string; the components dispatch on the provider= atom. Keep your builder in your app's namespace if it's app-specific, or release it as a small companion package that depends on image_components for the IR types and adds <provider>/2 to the surface.
When the new CDN's URL grammar can't faithfully express an IR op, drop it silently — every shipped builder does the same. Don't approximate; the provider you pick should be the contract, and the URL it produces should be the truth of what that CDN can carry.
If your new CDN warrants two-way compatibility (URL parsing as well as URL building) so the in-process image_plug can serve it during development, the parser side lives in image_plug — see its provider modules for examples of the inverse mapping.
-
Usage —
<.image>and<.picture>walk-through, host/mount configuration, face-aware crops, per-CDN encoding of adjust effects, vignette and tint,<.picture>content negotiation, pre-computing pipelines. -
Responsive
<picture>patterns — format negotiation, density (1×/2×/3×), width-basedsrcset+sizes, art direction with<source media>, and how to compose them. Includes worked recipes for each pattern as app-specific wrapper components. -
IIIF Image API 3.0 — the fifth provider,
:iiif. URL grammar, theregion=andiiif_quality=IIIF-specific attributes, server-prefix conventions, and the conformance limits IIIF imposes (no effects, no:coverfit, no per-channel adjust). -
Local server in dev, native CDN in prod — recipe for running an in-process
image_plugin development and test, then pointing at the real Cloudflare / Cloudinary / imgix / ImageKit edge in production.
For source resolution (file vs HTTP vs S3 vs custom), see image_plug's sources guide.
The test suite has three layers, each at a different point on the speed/coverage trade-off.
-
Default suite (
mix test) — unit tests forImage.Components.URLandbuild_pipeline/1, plus property-based round-trip tests that project a generatedPipelineto a URL via this library and parse it back via the matchingimage_plugprovider. Catches projector/parser drift inside the codebase. Fast (~2 s), no external dependencies. Tagged:round_trip. -
Cross-SDK validation (
mix test --include cross_sdk) — for each canonical intent, builds the URL viaImage.Components.URLAND via the official vendor SDK (Cloudinary, imgix, ImageKit), then compares as token / parameter sets (order-independent, SDK-tracking parameters filtered out). Confirms our URL grammar matches what the vendors themselves emit. Requires Node + annpm installintest/support/cross_sdk/. Cloudflare is not covered — Cloudflare doesn't ship a first-party URL builder. -
Live CDN integration (
mix test --include live_cdn) — fetches the URL from the real Cloudinary / imgix / ImageKit public demo endpoints and asserts the response is an image of approximately the requested dimensions. Highest-confidence verification; the actual edge service rendering our URLs. Slow (~3 s, network-dependent) and tagged:live_cdnso it doesn't run in normalmix test. Cloudflare is not covered — no public demo account.
For the cross-SDK suite, install the Node helper deps once:
cd test/support/cross_sdk && npm install
mix test --include cross_sdkRun all three layers together:
mix test --include cross_sdk --include live_cdnimage_playground is a Phoenix LiveView app that drives this library and the four provider mounts in image_plug. Drop an image, tweak transforms with sliders, and watch the four CDN URLs and the equivalent HEEx call update live next to a rendered preview.
Apache-2.0.