Article Last Updated: 29 Dec 2020
We’ve implemented LazySizes library on our website, and it is working great! In return, you will receive higher PageSpeed and Lighthouse scores, respectively, which is important SEO optimization. However, one negative side-effect surfaced: text reflow on scroll / image loading (CLS = Cumulative Layout Shift), as the dummy 1×1 pixel placeholder gets replaced with the actual image (which is annoying to say at least).
Now, we wouldn’t have any issues if the image thumbnails were static, fixed in size and non-responsive. It is relatively easy to keep scaled/resized image aspect ratio in CSS, but how do you set a universal placeholder to behave exactly the same? Blog is not a thing set in stone, articles are not typical product-like pages with template galleries! Unfortunately, this cannot be done purely in CSS yet (there is no support for AR), and some CSS+JS combination is required.
Lazy Load Responsive Adaptive Image Placeholders Height Implementation Code
This tutorial uses already mentioned LazySizes library, but you can try anything else, really (with appropriate code modifications). It is versatile and very easy to implement in WordPress theme, for example. In default setup, simply include the library in the footer/header, and append lazyload class to your target images – plugin does the rest automatically.
One important thing to note with LazySizes JS Library is that it processes all images with .lazyload class present. During the loading of original images, that class is removed and replaced with temporary .lazyloading class. Finally, once the loading of original image is completed, .lazyloading is replaced with .lazyloaded class. We exploit all 3 classes in our CSS later!
LazySizes.js Image Classes
1. requires .lazyload class @ image placeholders
2. appends/replaces .lazyload with .lazyloading class during image loading process
3. appends/replaces .lazyloading with .lazyloaded class when image is finished loading
HTML @ Header
<!-- LazySizes.js -->
<script src="lazysizes.min.js" async=""></script>
<!-- LazySizes.js Hide Image Lazy Load Placeholder @ nojs -->
<noscript><style>img.lazyload{display:none !important;}</style></noscript>
HTML @ Body
this is example only – it is completely generated on server-side
<div id="attachment_xyz" class="image-wrapper">
<a href="" target="_blank" rel="noopener">
<img class="lazyload" src="" alt="" data-src="" data-srcset="" width="X" height="Y" aria-label="" />
<noscript>
<img src="" alt="" width="X" height="Y" srcset="" aria-label="" />
</noscript>
</a>
</div>
CSS
selectors are generic / universal, adapt them to target article / blog posts content only
div[id^='attachment_'] {
display:block;
margin:1% auto;
padding:1%;
width:96% !important;
height:100%;
overflow:hidden;
box-sizing:border-box;
}
@media(max-width:768px) {
div[id^='attachment_'] {
display:block;
width:100% !important;
height:100% !important;
margin:0;
padding:0;
}
}
/* generic | noscript | no lazyload */
img {
display:block;
width:auto;
height:auto;
margin:5px auto;
border:2px solid #EEE;
box-sizing:border-box;
background-color:#FFF;
background-repeat:no-repeat;
background-image:none;
}
/* lazyloading placeholder */
img.lazyload,
img.lazyloading {
width:auto;
height:auto;
background-color:#EEE;
}
/* lazyloading finished */
img.lazyloaded{
width:auto !important;
height:auto !important;
}
@media(max-width:768px) {
/* generic | noscript | no lazyload */
img {
display:block;
width:100%;
margin:0 auto;
box-sizing:border-box;
}
/* lazyloading placeholder */
img.lazyload,
img.lazyloading{
width:100%;
background-color:#EEE;
}
/* lazyloaded finished */
img.lazyloaded{
width:100% !important;
height:auto !important;
}
}
PHP @ functions.php
<?php
###################
# IMAGE LAZY LOAD #
###################
## HTML REPLACEMENT FUNCTIONS
function imagelazyload_preg_replace_html($content, $tags) {
// define i/o containers
$search = array();
$replace = array();
// attributes to search for
$attrs_array = array('src','srcset','sizes');
// elements requiring 'src' attribute to be valid HTML
$src_req = array('img','video');
// loop through $tags
foreach($tags as $tag) {
// is the tag self closing?
$self_closing = in_array($tag, array('img','embed','source'));
// set tag end depending on if it's self-closing
$tag_end = ($self_closing) ? '\/' : '<\/'.$tag;
// look for img tag in content
preg_match_all('/<'.$tag.'[\s\r\n]([^<]+)('.$tag_end.'>)(?!<noscript>|<\/noscript>)/is',$content,$matches);
$n=1;
$countMatches = count($matches[0]);
// if tags exist loop through $matches[] array and perform regex search & replace
if ($countMatches) {
foreach ($matches[0] as $match) {
// skip first img element | do not lazyload first img element
if ($n > 1) {
// set original version for <noscript>
$original = $match;
// append original into $search array
$search[] = $original;
// look for img class attribute
$img_classes = preg_match('/[\s\r\n]class=[\'"](.*?)[\'"]/', $match, $classes);
// extract img classes
if (isset($classes[1])) {
$img_classes_extract = $classes[1];
} else {
$img_classes_extract = '';
}
// bypass* lazy load elements with special class
$noLazyClassPattern = 'nolazyload';
if (strpos($img_classes_extract, $noLazyClassPattern) === FALSE) {
// insert lazy load class
$img_classes_insert = $img_classes_extract . ' ' . 'lazyload';
// replace img class @ $match
$match = str_replace($img_classes_extract, $img_classes_insert, $match);
// if element requires 'src'
// set src container
$src = '';
// option #1: set src to default image loader.gif
##$src = (in_array($tag, $src_req)) ? ' src="'.get_template_directory_uri().'/images/loader.gif"' : '';
// option #2: set src to default transparent image
$src = (in_array($tag, $src_req)) ? ' src="'.get_template_directory_uri().'/images/1x1-transparent.png"' : '';
// option #3: remove src default blank image space holder
##$src = (in_array($tag, $src_req)) ? ' ' : '';
// set replace html container
$replace_markup = $match;
##################
# REGEX EXAMPLES #
##################
# src => data-src (for lazysizes.js library)
// $replace_markup = preg_replace('/[\s\r\n]('.$attrs.')?=/', $src.' data-$1=', $replace_markup);
# src => data-echo (for echo.js library)
// $replace_markup = preg_replace('/[\s\r\n]('.$attrs.')?=/', $src.' data-echo=', $replace_markup);
// replace attr with data-attr
// set replacement counter | insert replacement src @ first tag only | prevents cascading replacement of src tags
$i=1;
foreach ($attrs_array as $attr) {
if ($i==1) {
$replace_markup = preg_replace('/[\s\r\n]('.$attr.')?=/', $src.' data-$1=', $replace_markup);
} else {
$replace_markup = preg_replace('/[\s\r\n]('.$attr.')?=/', ' data-$1=', $replace_markup);
}
$i++;
}
// add original html markup in as <noscript>
$replace_markup .= '<noscript>'.$original.'</noscript>';
// append replacement into $replace array
$replace[] = $replace_markup;
} else {
// append original* into $replace array
$replace[] = $original;
}
}
$n++;
}
}
}
// replace all $search items with $replace items
$newcontent = str_replace($search, $replace, $content);
return $newcontent;
}
function imagelazyload_filter_html($content) {
// feed bypass
if (is_feed()) {
return $content;
}
// amp page bypass
if (is_amp_page()) {
return $content;
}
// opera mini bypass
$useragent = (isset($_SERVER['HTTP_USER_AGENT'])) ? $_SERVER['HTTP_USER_AGENT'] : '';
if (stripos($useragent, 'Opera Mini') == TRUE) {
return $content;
}
// set new content
$newcontent = $content;
// replace img 'src' with 'data-src' attribute
$newcontent = imagelazyload_preg_replace_html($newcontent, array('img'));
// return modified content
return $newcontent;
}
## HTML REPLACEMENT FILTERS
// replace 'src' in the_content
// priority = 100 is important in order to process full the_content() composite and prevent html structure distortion (namely, <p> caption tags)!
add_filter('the_content', 'imagelazyload_filter_html', 100);
?>
PHP does a massive job here, providing core HTML processing (static caching recommended) on-the-fly, of course. It replaces images in posts and provides crucial image data which we later cleverly use in frontend through JavaScript to build our placeholders with dynamic width and height. Also, it provides bypass for WordPress AMP plugin. Also, if you wish to bypass lazyloading for certain media files, simply append nolazyload class to it’s source code and it will be skipped.
JavaScript (jQuery) @ footer
<script type="text/javascript">
function lazyLoadPlaceholders() {
let Images = $("img.lazyload");
for (let i = 0; i < Images.length; i++) {
let W = $(Images[i]).attr('width');
let H = $(Images[i]).attr('height');
let R = W/H;
let Width = $(Images[i]).width();
let Height = $(Images[i]).height();
if (window.innerWidth < 768) {
$(Images[i]).width('100%');
$(Images[i]).height(Width/R);
} else {
$(Images[i]).width(W);
$(Images[i]).height(W/R);
}
}
}
$(document).ready(function() {
lazyLoadPlaceholders();
});
$(window).resize(function() {
lazyLoadPlaceholders();
});
</script>
In JS we’ve added resize window event support, thus, when user resizes desktop browser window size (less likely) or rotates mobile device in landscape or portrait position (much more likely), placeholders will be dynamically recalculated and scaled appropriately. For this reason, we also had to add CSS overrides once lazyloading is done, avoiding squashed images.
UPDATE TIP: pure CSS only aspect ratio based smart image placeholders
While JavaScript (jQuery) code to create preloading image placeholders presented above is simple, fast and reliable, with a wide range of older browsers support, you can now use a modern CSS-only approach to create aspect-ratio locked image placeholders, as an alternative to traditional javascript solution. Modern browsers can now set default aspect ratio of images based on image’s width and height attributes (remember, WP PHP stage already provides the necessary attributes for us!):
CSS
img {
aspect-ratio:attr(width) / attr(height);
}
Keep in mind that this solution might not always work out-of-the-box and may require additional tweaks in nested elements, which can still cause a reflow to occur.