A Better Responsive Image Strategy in WordPress

WordPress has had support for responsive image markup for several releases now. To support a wide range of themes and layouts, the responsive image markup is deliberately broad in its coverage.

While this generalized approach might work great for simpler themes and smaller sites, more complex sites will certainly benefit from more control over this markup.

In this post, I'll describe the issue and how I implemented a more scalable approach that loads more appropriately sized images for a more complex layout.

WordPress Responsive Image Markup

The responsive image markup in WordPress relies upon several configuration options and makes assumptions about the general page layout. First, WordPress makes use of the predefined image sizes that are configured in the CMS dashboard, along with any other custom sizes by the theme or plugins.

The default sizes are as follows (courtesy of wpmudev):

  • 150px square for thumbnails
  • 300px width for medium images
  • 768px max width for medium_large images
  • 1024px max width for large images.
  • The full size of the originally uploaded image.

WordPress also assumes that the image will be displayed at 100% of the $content_width as defined in the theme. If you have $content_width = 1000 defined in the theme, that means your responsive image markup might come out looking like this:

<img src="..."
    srcset="/medium.jpg 300w,
            /medium_large.jpg 768w,
            /large.jpg 1024w"
    sizes="(max-width: 1000px) 100vw, 1000px">

This might look good initially, but there are some issues with it. One problem with this markup is that it results in too large of an image being served for most layouts that are full width on tablets but then change to a multi-column layout on desktop. Let's take a look at the following wireframe, which is fairly common in media and news sites:

Responsive Wireframe

For mobile and tablet, the top stories are displayed with their feature image at 100% of the viewport width. On desktop, however, the lead story occupies the left 2/3 of the layout and the other stories occupy the other 1/3 of the layout.

Mobile and Tablet Layout

With the markup that WordPress generates, iPads in portrait mode use the medium_large image variety at 768px wide, a perfect fit. However, since most phones are 320-360px wide in portrait mode, which exceeds the defined medium image width of 300px, they will also get the medium_large image, which means the image that's served is twice as large as the largest size that can be displayed.

Desktop Layout

In the desktop layout, we display the image for the first story much larger than the next two. If the max-width is set at 1060px, the largest that the image in the left 2/3 can be is about 700px, so medium_large image that is dialed up by the sizes attribute is just about the right size. However, the images in the right 1/3 of the layout will also use the 768px wide size because the default sizes attribute tells the browser to do so.

Similar to the problem in the mobile layout, the images in this area are much too large. The maximum width that the container can be is 353px wide. We have to request the medium_large image due to our tablet layout, but the sizes attribute does not account for this multi-column layout on desktop.

The easiest improvement would be to pass in an additional options array into the_post_image and get_the_post_image to specify the sizes attribute for each image size based on where it appears instead of relying on the defaults. The following code would solve our medium_large image sizing problem on both desktop and mobile IF there were images sizes that fit these parameters.

<?php echo get_the_post_image( get_the_ID(), 'medium_large', array(
        'sizes' => '(max-width: 768px) 100vw, (max-width: 1060px) 34vw, 355px'
    )
); ?> 

New Markup, New Issues

The problem that we just introduced is that we don't have an image that is available that fits these parameters. Desktop and mobile still would get the 768px wide medium_large image. So what can we do about it?

The best way, in my opinion would be to specify additional parameters in the get_the_post_image call to indicate the maximum and minimum image sizes that a position requires and the number of images you want to generate within this range.

<?php echo get_the_post_image( get_the_ID(), 'medium_large', array(
        'sizes' => '(max-width: 768px) 100vw, (max-width: 1060px) 34vw, 355px',
        'responsive_max_width' => 768,
        'responsive_min_width' => 360,
        'responsive_quantity' => 5,
        'loading' => 'lazy' // add native lazy loading for browsers that support it
    )
); ?>

We can then use this information in a WordPress hook to generate more image sizes. You could use WordPress to do this, but I suggest using a CDN service that lets you resize images by requesting a URL with specific parameters passed in. Some options are:

Any of these services will provide edge caching closer to your end users and also allow you to serve more progressive image formats, such as WebP, to the browsers who support it.

How Do the Extra Parameters Help?

The trick here is to use these parameters to generate urls for each image. The implementation is going to vary depending on what service you're using. This isn't meant to be paste and go code, so for the purposes of this article, let's make a few assumptions:

  • The image will maintain its aspect ratio based on the width argument
  • We're not concerned with images being scaled up.
<?php
// define the service url
define( 'MYNS_RESIZE_ENDPOINT', 'https://www.example.com' );

function myns_get_resized_image_url( $image_src, $width ) {
    $image_src = add_query_arg( array(
        'src' => urlencode( $image_src ),
        'w' => intval( $width, 10 ),
    ), MYNS_RESIZE_ENDPOINT );

    return $image_src;
}

/**
 * @param $attr array
 * @param $attachment object
 * @param $size string | array
 * @return array
 */
function myns_image_attachment_srcset_attribute( $attr, $attachment, $size ) {
    if ( !empty( $attr['responsive_image_minwidth'] ) && !empty( $attr['responsive_image_maxwidth'] ) ) {
        $min_width = intval( $attr['responsive_image_minwidth'], 10 );
        $max_width = intval( $attr['responsive_image_maxwidth'], 10 );

        // the `unset` calls remove the attribute so it isn't rendered in the img tag output.
        if ( !empty($attr['responsive_image_quantity'] ) ) {
            $quantity = intval( $attr['responsive_image_quantity'], 10 );
            unset( $attr['responsive_image_quantity'] );
        } else {
            $quantity = 5;
        }

        $src = wp_get_attachment_image_src( $attachment->ID, $size );

        if ( is_array( $src ) && count( $src ) === 4 ) {

            list( $image_src ) = $src;

            $calculated_widths = myns_calculate_intermediate_sizes( $min_width, $max_width, $quantity );
            $sizes_array = array();

            if ( count( $calculated_widths ) > 0 ) {
                foreach ( $calculated_widths as $calculated_width ) {
                    $sizes_array[] = myns_get_resized_image_url( $image_src, $calculated_width ) . ' ' . $calculated_width . 'w';
                }

                $attr['srcset'] = implode( ', ', $sizes_array );
            }

        }

        unset( $attr['responsive_image_minwidth'] );
        unset( $attr['responsive_image_maxwidth'] );
    }

    return $attr;
}
add_filter( 'wp_get_attachment_image_attributes', 'myns_image_attachment_srcset_attribute', 10, 3 );

/**
 * @param $min integer
 * @param $max integer
 * @param $quantity integer
 * @return array
 */
function myns_calculate_intermediate_sizes( $min, $max, $quantity ) {
    $sizes = array();

    // calculate the difference between min and max width and use the
    // quantity to make sure they are evenly dispersed.
    if( is_int( $min ) && is_int( $max ) && is_int( $quantity ) && $min < $max ) {
        if( $quantity >= 3 ) {
            // cap the max sizes to avoid too many images being created
            if( $quantity > 20 ) {
                $quantity = 20;
            }
            $range = $max - $min;
            $difference = intval( ceil($range / $quantity ), 10 );
            $current_val = $max;
            $sizes[] = $max;

            if( $quantity < $difference ) {
                for ($i = 1; $i <= ( $quantity - 2 ); $i++) {
                    $sizes[] = $current_val - $difference;
                    $current_val -= $difference;
                }
            }

            $sizes[] = $min;

        } elseif( $quantity === 2 ) {
            $sizes = array( $max, $min );
        }
    }

    return $sizes;
}

As I mentioned, this will need some tweaking based on the service that you choose, but it should be a great generalized starting point for you.

Conclusion

Getting images right is super important for page weight and overall site performance. Coupled with an image resizing service, you should see dramatic reductions in the image transfer size. This is because you can make sure appropriate images are being served in the layout slot that they occupy and that images are being served in more modern formats like WebP.

This is the general approach I followed recently and was able to reduce the image payload by over 50%.