I haven’t thought about HTML in a long time, but recently came across the usage of picture tag(s). I was so impressed that I wanted to blog about it. This is nothing new, and used a lot by the web already.

The picture tag gives us a lot of control over how images are loaded, solving two main problems: efficient formats and responsive sizing.

WebP Support

We all know WebP images are smaller and faster to load, but not every browser supported them initially (though support is great now). The picture tag allows us to serve WebP to browsers that support it, while falling back to PNG or JPEG for others.

1
2
3
4
5
<picture>
  <source srcset="image.webp" type="image/webp">
  <source srcset="image.jpg" type="image/jpeg">
  <img src="image.jpg" alt="My Image">
</picture>

The browser parses the <source> tags from top to bottom. The first one with a supported type is chosen. If none match, it falls back to the standard <img> tag.

Media Size (Responsive Images)

You don’t want to load a 4000px wide hero image on a mobile phone. It wastes bandwidth and slows down rendering. With the picture tag, we can switch images based on the viewport width using media queries.

1
2
3
4
5
<picture>
  <source media="(min-width: 800px)" srcset="large.jpg">
  <source media="(min-width: 400px)" srcset="medium.jpg">
  <img src="small.jpg" alt="My Responsive Image">
</picture>

In this case:

  • Screens wider than 800px get large.jpg.
  • Screens between 400px and 800px get medium.jpg.
  • Anything smaller falls back to small.jpg.

The Combination: Mobile vs. Desktop WebP

The real power comes when you combine both format switching and media queries. This allows for specific “Art Direction” where you might serve a cropped version of an image for mobile (to keep the subject visible) and a wide version for desktop, both in the most efficient format available.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<picture>
  <!-- Desktop: Wide aspect ratio, WebP -->
  <source media="(min-width: 1024px)" srcset="desktop-hero.webp" type="image/webp">
  <source media="(min-width: 1024px)" srcset="desktop-hero.jpg" type="image/jpeg">

  <!-- Tablet: Smaller wide aspect ratio, WebP -->
  <source media="(min-width: 768px)" srcset="tablet-hero.webp" type="image/webp">
  <source media="(min-width: 768px)" srcset="tablet-hero.jpg" type="image/jpeg">

  <!-- Mobile: Square crop, WebP -->
  <source srcset="mobile-hero.webp" type="image/webp">
  <img src="mobile-hero.jpg" alt="Hero Image">
</picture>

This setup ensures:

  1. Desktop users get high-res, wide images in efficient WebP format.
  2. Mobile users get a smaller, potentially cropped image (saving massive bandwidth) in WebP.
  3. Legacy browsers (on any device) fall back to the JPEG version appropriate for their screen size (if you add media queries to the JPEGs too) or just the default img.

Leveling Up: AVIF Support

If you want to go even further, AVIF (AV1 Image File Format) offers even better compression than WebP. The beauty of the picture tag is that you can just stack it on top. Browsers will grab the first format they understand.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<picture>
  <!-- 1. Try AVIF (Best Compression) -->
  <source srcset="image.avif" type="image/avif">

  <!-- 2. Try WebP (Great Compression, Wide Support) -->
  <source srcset="image.webp" type="image/webp">

  <!-- 3. Fallback to JPEG (Universal Support) -->
  <img src="image.jpg" alt="My Image">
</picture>

You can combine this with media queries too, creating a robust “waterfall” of options:

  1. Desktop AVIF -> Desktop WebP -> Desktop JPEG
  2. Mobile AVIF -> Mobile WebP -> Mobile JPEG

Order Matters: First Match Wins

It is crucial to understand that the browser parses the <source> tags from top to bottom and stops at the first one that matches both the media query (if present) and the type support.

There is no complex scoring system. If you put the JPEG source at the top, the browser will see it, say “I support JPEG”, and download it immediately, ignoring your fancy AVIF and WebP files below.

Always order your sources from “Most Preferred” (newest/best format) to “Least Preferred” (fallback).

Future-Proofing

The picture tag is essentially future-proof. When a new, even better image format comes out in 5 years (let’s call it .superimg), you won’t need to rewrite your site’s logic or wait for all browsers to support it. You just add a new <source> line at the top:

1
<source srcset="image.superimg" type="image/super">

Browsers that support it will take it; others will simply ignore it and move to the next line. This “progressive enhancement” built directly into HTML is why it’s often superior to complex JavaScript loading logic.

Doing it in Rails

In Rails, we can combine all of this—Art Direction, Format Switching (AVIF/WebP), and automatic variant generation—into a powerful, clean solution using ActiveStorage.

Let’s imagine we are building a product page. We want to show a high-quality, wide image for desktop users, and a smaller, square-cropped image for mobile users. And for each size, we want to prioritize AVIF, then WebP, then JPEG.

1. Define Variants in the Model

First, we tell ActiveStorage to pre-create these specific versions when a product image is uploaded. This ensures the images are ready to serve immediately, with no processing lag for the user.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Product < ApplicationRecord
  has_one_attached :main_image do |attachable|
    # --- Desktop Variants (Large, Wide) ---
    attachable.variant :desktop_avif, resize_to_limit: [1200, 800], format: :avif
    attachable.variant :desktop_webp, resize_to_limit: [1200, 800], format: :webp
    attachable.variant :desktop_jpg,  resize_to_limit: [1200, 800], format: :jpg

    # --- Mobile Variants (Small, Square) ---
    attachable.variant :mobile_avif, resize_to_fill: [400, 400], format: :avif
    attachable.variant :mobile_webp, resize_to_fill: [400, 400], format: :webp
    attachable.variant :mobile_jpg,  resize_to_fill: [400, 400], format: :jpg
  end
end

2. The View Implementation

Now we can use the picture_tag helper to orchestrate all these sources. Rails will handle the URLs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<%= picture_tag(@product.main_image.variant(:mobile_jpg)) do %>
  <%# --- Desktop: 1200px Wide (AVIF -> WebP -> JPG) --- %>
  <%= tag.source media: "(min-width: 1024px)", srcset: url_for(@product.main_image.variant(:desktop_avif)), type: "image/avif" %>
  <%= tag.source media: "(min-width: 1024px)", srcset: url_for(@product.main_image.variant(:desktop_webp)), type: "image/webp" %>
  <%= tag.source media: "(min-width: 1024px)", srcset: url_for(@product.main_image.variant(:desktop_jpg)), type: "image/jpeg" %>

  <%# --- Mobile: 400px Square (AVIF -> WebP) --- %>
  <%= tag.source srcset: url_for(@product.main_image.variant(:mobile_avif)), type: "image/avif" %>
  <%= tag.source srcset: url_for(@product.main_image.variant(:mobile_webp)), type: "image/webp" %>

  <%# --- Fallback: Mobile JPG (defined in the picture_tag argument) --- %>
<% end %>

How this works in the browser:

  1. Desktop User (Chrome/Firefox/Safari):

    • Browser sees media="(min-width: 1024px)". It matches.
    • It checks the first source: type="image/avif". If supported, it downloads the Desktop AVIF.
    • If not, it checks type="image/webp". If supported, it downloads the Desktop WebP.
    • If neither, it falls back to the Desktop JPEG.
  2. Mobile User:

    • Browser ignores the (min-width: 1024px) sources.
    • It sees the next source: type="image/avif" (no media query, so it applies). It downloads the Mobile AVIF.
    • If AVIF isn’t supported, it tries the Mobile WebP.
    • Finally, if all else fails (or on very old browsers), it loads the standard Mobile JPEG via the <img> tag.

This setup ensures every single user gets the absolute best image for their specific device and browser capabilities, all managed cleanly within Rails.

3. Cleaning Up with Helpers or Components

Writing that big block of HTML every time is tedious and error-prone. We can encapsulate this logic to keep our views clean.

Option A: A Simple Rails Helper

You can create a helper method that takes the attachment and handles the boilerplate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# app/helpers/images_helper.rb
module ImagesHelper
  def responsive_image_tag(attachment, alt_text: "")
    picture_tag(attachment.variant(:mobile_jpg), alt: alt_text) do
      safe_join([
        # Desktop
        tag.source(media: "(min-width: 1024px)", srcset: url_for(attachment.variant(:desktop_avif)), type: "image/avif"),
        tag.source(media: "(min-width: 1024px)", srcset: url_for(attachment.variant(:desktop_webp)), type: "image/webp"),
        tag.source(media: "(min-width: 1024px)", srcset: url_for(attachment.variant(:desktop_jpg)), type: "image/jpeg"),

        # Mobile
        tag.source(srcset: url_for(attachment.variant(:mobile_avif)), type: "image/avif"),
        tag.source(srcset: url_for(attachment.variant(:mobile_webp)), type: "image/webp")
      ])
    end
  end
end

Then in your view:

1
<%= responsive_image_tag(@product.main_image, alt_text: "Product Shot") %>

Option B: ViewComponent

If you prefer a more object-oriented approach (or if the logic gets more complex), a ViewComponent is perfect for this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# app/components/responsive_image_component.rb
class ResponsiveImageComponent < ViewComponent::Base
  def initialize(attachment, alt:)
    @attachment = attachment
    @alt = alt
  end

  def call
    picture_tag(@attachment.variant(:mobile_jpg), alt: @alt) do
      safe_join([
        desktop_sources,
        mobile_sources
      ])
    end
  end

  private

  def desktop_sources
    safe_join([
      source_tag(:desktop_avif, "image/avif", "(min-width: 1024px)"),
      source_tag(:desktop_webp, "image/webp", "(min-width: 1024px)"),
      source_tag(:desktop_jpg,  "image/jpeg", "(min-width: 1024px)")
    ])
  end

  def mobile_sources
    safe_join([
      source_tag(:mobile_avif, "image/avif"),
      source_tag(:mobile_webp, "image/webp")
    ])
  end

  def source_tag(variant, type, media = nil)
    tag.source(srcset: url_for(@attachment.variant(variant)), type: type, media: media)
  end
end

And in your view:

1
<%= render ResponsiveImageComponent.new(@product.main_image, alt: "Product Shot") %>

Both approaches drastically reduce noise in your templates and ensure consistent image handling across your entire application.

Why is this better than JavaScript?

You might argue that we could do all this with a bit of JavaScript. While true, the picture tag has significant advantages:

  1. Performance: The browser’s pre-parser can scan HTML and start downloading images before JavaScript and CSS are fully parsed and executed.
  2. No JS Dependency: It works even if JavaScript is disabled or fails to load.
  3. Semantics: It’s standard HTML, making it easier for search engines and accessibility tools to understand.
  4. Native Optimization: Browsers are highly optimized to handle these selections efficiently without the overhead of script execution.

It’s a robust, native solution that handles complexity declaratively.