How do you create responsive images in your Phoenix apps?

How do you create responsive images with phoenix?
I’d love to merge my separate 11ty/netlify landing page into the Phoenix app.

Responsive images are the last big stumbling block for me. Most JAMStack frameworks have good out-of-the-box solutions, e.g. next.js and gatsby.

It would be great to have an easy to use, performant solution for Phoenix.
That doesn’t impact page loading speeds/page rank.

Here’s my best solution so far. Resized using Image at compile time.
Stored in priv/static/resized, served and cached via normal Plug.Static setup.

Would love to get some feedback and to hear about how you guys handle this.

Usage:

use ResponsiveImage

~H(<img src={src("input.jpg", 300)} />)
~H(<img srcset={srcset("input.jpg", [300, 600, 900])} src={...} sizes="50vw" />)

Module:

defmodule ResponsiveImage do
  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)
      Module.register_attribute(__MODULE__, :image, accumulate: true)
      @before_compile unquote(__MODULE__)
    end
  end

  defmacro src(path, width) do
    Module.put_attribute(__CALLER__.module, :image, {path, width})

    quote do
      unquote(img_src(path, width))
    end
  end

  defmacro srcset(path, widths) do
    for width <- widths, do: Module.put_attribute(__CALLER__.module, :image, {path, width})

    quote do
      unquote(widths |> Enum.map(&"#{img_src(path, &1)} #{&1}w") |> Enum.join(", "))
    end
  end

  defmacro __before_compile__(_env) do
    {duration, _} =
      :timer.tc(fn ->
        ResponsiveImage.resize(Module.get_attribute(__CALLER__.module, :image))
      end)

    IO.puts("Image resize took #{duration / 1_000_000}s")
  end

  def resize(attr) when is_list(attr) do
    for {path, width} <- attr, do: resize(path, width)
  end

  def resize(path, width) do
    out_path = out_path(path, width)

    if not File.exists?(out_path) do
      IO.puts("Writing #{out_path}")
      out_path |> Path.dirname() |> File.mkdir_p!()

      path
      |> in_path()
      |> Image.open!()
      |> then(&Image.resize!(&1, width / Image.width(&1)))
      |> Image.write!(out_path)
    end
  end

  defp without_ext(path), do: "#{Path.dirname(path)}/#{Path.basename(path, Path.extname(path))}"
  defp in_path(path), do: "priv/static/images/#{path}"
  defp img_src(path, width), do: "/resized/#{without_ext(path)}_#{width}.avif"
  defp out_path(path, width), do: "priv/static#{img_src(path, width)}"
end
2 Likes

I use a mix compiler to resize images:

I also have played with on demand resizing behind a cdn using image in a plug:

8 Likes

Thanks for sharing!

This looks like a nice & clean solution if you usually need the same standard sizes everywhere. Pro: Resize on dev machine, commit resized images → saves pipeline time.
I have more variance in the required image sizes. Then I think its nicer to collocate the template and which image sizes I require.

Do I understand correctly that you have a minimal Elixir/Image implementation of the Thumbor “path protocol”? And put a CDN in front to handle caching? Also clever, I quite like that idea.