Calculating 'contain-intrinsic-size' for 'content-visibility'

css contain-intrinsic-size

CSS property content-visibility can be used to easily improve rendering performance, but it should be used in conjunction with contain-intrinsic-size to be practical.

If you have looked into this before, you might know that using value auto for property content-visibility tells the browser to only render a row if it's inside of your viewport.

Basically it makes it possible to perform lazy-rendering, just like lazy-loading images.

Property contain-intrinsic-size provides an estimated height for rows that are not yet rendered, because those would otherwise have no height while outside of your viewport.

There is a problem, however.. How are you supposed to estimate the height of a row? It would be way too much work to manually measure the height of every row. Again and again after updating the contents of rows, too.

Contents

Requirements

I can not provide a ready-to-use solution, because every website uses a different design system and tool to manage their content. That's why I provide you a way to implement lazy-rendering into your own design. These are the requirements for implementation:

  • Viewport-based design or a predictable container-based design. If a container-based design also uses viewport-based sizing and/or sizes that vary in-between breakpoints that determine the width of containers, that will make the height of rows unpredictable. It will make it useless to cache the height of rows at certain container-widths, since caching the height of rows at certain container-widths is the way to provide an estimated height of a row by using contain-intrinsic-size.
  • Frontend editor that allows:
    • Custom JavaScript to load when editing a page.
    • Saving custom values to rows before saving a page.

Process of calculating contain-intrinsic-size

JS - Actions just before saving a page (inside editor)

  1. Measure height of rows at different devices (viewport-based design) or at multiple container widths (for container-based designs).
  2. Measure viewport width (viewport-based design) or container width (container-based).
  3. Save height of rows at certain viewport widths (for viewport-based designs) or save height of row at certain container-widths (for container-based designs). Save those values inside custom HTML data-attributes, which you will query using PHP.

PHP - Formulas for contain-intrinsic-size (outside editor)

  1. Query all rows with those HTML attributes, using PHP's built-in XML/HTML parser.
  2. Insert the formulas as CSS variables (inside style attribute).

CSS - Apply lazy-rendering, using the formulas (outside editor)

Query rows with the CSS variables. Apply content-visibility: auto.

If you use a viewport-based design

Apply CSS variables to calculate contain-intrinsic-size for current viewport-width.

If you use a container-based design

Apply CSS variables to set value for contain-intrinsic-size at current container-width.

css content-visibility auto

Homepage rendering performance with content-visibility: auto;

The following results are based on 20 tests, 5 without and 5 with lazy-rendering, for a desktop device and a mobile device ( (5 + 5) * 2 ).

The desktop device is nothing crazy, but I'd say it's powerful enough to provide consistent results. The mobile device is an iPhone X, emulated in Google Chrome DevTools on that same desktop device.

Desktop - No lazy-rendering

Loading22.8ms
Scripting92.8ms
Rendering173.8ms
Painting74ms
System57.2ms

Desktop - Lazy-rendering

Loading21.4ms-6.14%
Scripting96.2ms+3.66%
Rendering105.4ms-39.36%
Painting71ms-4.05%
System53.4ms-6.64%

Mobile - No lazy-rendering

Loading24ms
Scripting88.8ms
Rendering188.6ms
Painting19.2ms
System52.2ms

Mobile - Lazy-rendering

Loading19ms-20.83%
Scripting92.4ms+4.05%
Rendering114.2ms-39.45%
Painting14.6ms-23.96%
System51.8ms-0.77%

Is it worth it, doing all of this?

If you are up for a challenge.. it's worth it. Is it overkill? Yes. If you're not here for the challenge, then I suggest not to waste your time for such a minor improvement.

If you are up for the challenge or interested in how I managed to do so, please consider reading about how I managed to automatically calculate contain-intrinsic-size to be able to utilize content-visibility: auto; throughout my entire website.

You might stumble across some issues, that I faced as well.


If you are familiar with LiteSpeed Cache (a caching/performance plugin for WordPress) or any other solution to speed up your website, then you might know that it is possible to provide an image placeholder that shows whenever an image is not yet loaded.

It prevents the layout from shifting when an image loads it's dimensions change from 0x0 to, for instance, 500x800 in pixels. Which is annoying, by the way.

If you want to estimate the height of a row, you would have to measure the height at multiple screen sizes (and possibly screen heights, may your design use units based on viewport height).

That takes way too much time and effort.

How I managed to calculate the height

First, let me explain how the design of my website works. The design of my website adapts to 3 device categories:

  1. Mobile @media (max-width: 480px) { ... }
  2. Tablet @media (min-width: 481px) and (max-width: 1024px) { ... }
  3. Laptop & Desktop @media (min-width: 1025px) { ... }

Font-sizes and spaces (paddings and margins) are all stored in CSS variables. I designed this website in Adobe XD at a certain canvas size that I've then set as CSS variables to use in formulas (--designedForWidth and --designedForHeight).

Instead of storing all the different font-sizes and spaces as plain vw values, I decided to use a size in pixels in the formulas in combination with the canvas width (viewport width) for which the size is supposed to be.

The advantage is that you only have to design for the three device categories, because all font-sizes will scale up and down between device categories / media queries. Along with multipliers at mobile screens and at tablet screens for font size and for spacing, because scaling from desktop all the way down to mobile would make everything way too small.

Calculating contain-intrinsic-size for content-visibility

Now, as the sizes scale linearly, I decided to make sure that the page builder, that I've built for my website, saves the width of the viewport and the height of every single row by adding those values as HTML attributes (attributes for the viewport's width and attributes for the row's height). It does this for all three devices: desktop, tablet, and mobile.

This results in six attributes:

  1. data-cache-vw-d (viewport's width on desktop)
  2. data-cache-h-d (height of row on desktop)
  3. data-cache-vw-t (viewport's width on tablet)
  4. data-cache-h-t (height of row on tablet)
  5. data-cache-vw-m (viewport's width on mobile)
  6. data-cache-h-m (height of row on mobile)

Using PHP's built-in HTML parser I was able to seek for the rows and add CSS variables as an inline style to be used to calculate contain-intrinsic-size to be used in combination with content-visibility: auto:

$doc = new DOMDocument();
$doc -> loadHTML($buffer);
$xPath = new DOMXPath($doc);

if(!$twBuilderActive) {
  $rowsWithCachedHeightForDesktop = $xPath -> query('//*[@data-cache-h-d]');
  foreach($rowsWithCachedHeightForDesktop as $row) {
    if($row -> hasAttribute('data-cache-vw-d')) {
      if($row -> hasAttribute('style')) {
        $style = $row -> getAttribute('style');
      } else {
        $style = '';
      }
      		
      $row -> setAttribute('style', '--cisD:100vw/' . $row -> getAttribute('data-cache-vw-d') . '*' . $row -> getAttribute('data-cache-h-d') . ';' . $style);
      
      $row -> removeAttribute('data-cache-vw-d');
      $row -> removeAttribute('data-cache-h-d');
    }
  }
  
  // and versions for tablet and mobile
}

Resulting in the following inline CSS styles for every row found through PHP:

  --cisM: 100vw / 547.59375 * 4760;
  --cisT: 100vw / 730.125 * 1154.59375;
  --cisD: 100vw / 1920 * 1720.4271240234375;

What the formulas do is they scale the cached height of the row by dividing the current viewport width by the viewport width at the time of saving the page (with my page builder) and multiplying that by the height of the row at the time of saving the page.

In order to apply lazy-rendering properly, I decided it's best to only apply content-visibility: auto to the rows that have contain-intrinsic-size specified. The CSS is placed inline in the top of <head>, because it is critical CSS.

  @media (max-width: 480px) {
    body:not(.tw-builder) #wrapper:not(.disable-lazy-render) main#content .tw-builder-content > .row[style*='--cisM:']:not(.rendered),
    body:not(.tw-builder) #wrapper:not(.disable-lazy-render) footer#tw-footer .tw-builder-content > .row[style*='--cisM']:not(.rendered) {
      content-visibility: auto;
      contain-intrinsic-size: calc(var(--cisM));
    }
  }

  @media (min-width: 481px) and (max-width: 1024px) {
    body:not(.tw-builder) #wrapper:not(.disable-lazy-render) main#content .tw-builder-content > .row[style*='--cisT:']:not(.rendered),
    body:not(.tw-builder) #wrapper:not(.disable-lazy-render) footer#tw-footer .tw-builder-content > .row[style*='--cisT']:not(.rendered) {
      content-visibility: auto;
      contain-intrinsic-size: calc(var(--cisT));
    }
  }

  @media (min-width: 1025px) {
    body:not(.tw-builder) #wrapper:not(.disable-lazy-render) main#content .tw-builder-content > .row[style*='--cisD:']:not(.rendered),
    body:not(.tw-builder) #wrapper:not(.disable-lazy-render) footer#tw-footer .tw-builder-content > .row[style*='--cisD']:not(.rendered) {
      content-visibility: auto;
      contain-intrinsic-size: calc(var(--cisD));
    }
  }

Using content-visibility: auto; while jumping to anchors or by specific amounts does not work properly, so I will apply the class disable-lazy-render to the body whenever the user wants to jump to a particular anchor, or when they use keyboard shortcuts like Page up, Page down, Home, and End. Here is how I did that:

function twParsedUrl(url) {
  var parser = document.createElement("a");
  parser.href = url;
  parser.href = parser.href;
  if (parser.host === "") {
    var newProtocolAndHost = window.location.protocol + "//" + window.location.host;
    if (url.charAt(1) === "/") {
      parser.href = newProtocolAndHost + url;
    } else {
      var currentFolder = ("/" + parser.pathname).match(/.*\//)[0];
      parser.href = newProtocolAndHost + currentFolder + url;
    }
  }
  var properties = ['host', 'hostname', 'hash', 'href', 'port', 'protocol', 'search'];
  for (var i = 0, n = properties.length; i < n; i++) {
    this[properties[i]] = parser[properties[i]];
  }
  this.pathname = (parser.pathname.charAt(0) !== "/" ? "/" : "") + parser.pathname;
}

window.addEventListener('DOMContentLoaded', function() {
  var wrapper = document.getElementById('wrapper');
  var keysThatDoJumps = ['Home', 'End', 'PageDown', 'PageUp'];
  function twDisableLazyRenderForJumpingButtons(e) {
    if(keysThatDoJumps.includes(e.key)) {
      wrapper.classList.add('disable-lazy-render');
    }
  }
  window.addEventListener('keydown', twDisableLazyRenderForJumpingButtons);
  
  var anchorLinks = [];
  function twJumpToAnchorOnClick() {
    var allPossibleAnchorLinks = document.querySelectorAll('a[href*='#']');
    var amountOfPossibleAnchorLinks = allPossibleAnchorLinks.length;
    var possibleAnchorLink, possibleAnchorLinkHref, possibleAnchorLinkURL;
    for(var iPossibleAnchorLink = 1; iPossibleAnchorLink <= amountOfPossibleAnchorLinks; iPossibleAnchorLink++) {
      possibleAnchorLink = allPossibleAnchorLinks.item(iPossibleAnchorLink - 1);
      
      if (
        !possibleAnchorLink.parentElement.parentElement.classList.contains('wc-tabs')
      ) {
        possibleAnchorLinkHref = possibleAnchorLink.href;
        possibleAnchorLinkURL = new twParsedUrl(possibleAnchorLinkHref);
  
        if (
          possibleAnchorLinkURL.hash
          &&
          possibleAnchorLinkURL.host == document.location.host
          &&
          possibleAnchorLinkURL.pathname == document.location.pathname
        ) {
          anchorLinks[possibleAnchorLinkHref] = possibleAnchorLinkURL;
          possibleAnchorLink.addEventListener('click', function(e) {
            var anchorLinkURL;
            var anchorLinkHref = this.href;
            if(anchorLinkURL = anchorLinks[anchorLinkHref]) {
              wrapper.classList.add('disable-lazy-render');
            }
          });
        }
      }
    }
  }
  twJumpToAnchorOnClick();
}

Note: wrapper is used in the CSS mentioned previously. You might want to use a different ID or class name both in CSS and JavaScript, to make it work for your design.

Accuracy of estimation

The estimations are accurate enough so that users will not notice the height of the scrollbar-thumb increasing and decreasing sometimes as different rows get rendered (thus taking up space according to the rendered height).

Just try it out for yourself by scrolling through my homepage.

If you pay very close attention then you might notice, but it's not spectacular.

Good to know

Things that are good to know before implementing content-visibility: auto; into your designs.

content-visibility: auto; behaves like overflow: hidden;

On this website I tend to make images bleed out of a certain width. I noticed that images that tried to reach beyond the boundaries, were cut off.

I managed to fix that by making the rows full-width and shaping the content-width by applying padding-left and padding-right instead of the default margin-left: auto;, margin-right: auto; and a declared width.

  body > .row {
    padding-left: calc((100% - var(--contentWidth)) / 2);
    padding-right: calc((100% - var(--contentWidth)) / 2);
  }

The padding basically allows elements to overflow the content-box and enter the padding-box without leaving the box model as a whole and getting cut off.

Rendered heights reset after scrolling out of range

After rendering a row that holds content-visibility: auto;, the rendered height will reset after scrolling out of range. That means that it will fall back to the height you've provided for contain-intrinsic-size. This can be solved by disabling lazy rendering for the rows that already entered the viewport, including the threshold of 50% of the viewport height. Here is how I managed to do so:

var windowInnerHeight = window.innerHeight;

function twInViewport(elem, threshold, elementBoundingClientRect) {
  if(typeof threshold === 'undefined') {
    var threshold = 0;
  }
  
  if(typeof elementBoundingClientRect === 'undefined') {
    var elementBoundingClientRect = elem.getBoundingClientRect();
  }
  
  var topY = elementBoundingClientRect.top;
  var bottomY = topY + elementBoundingClientRect.height; 
  
  if (
    topY <= windowInnerHeight
    &&
    bottomY >= 0
  ) {
    var inViewport = true;
  } else {
    var inViewport = false;
  }
  
  if (
    topY <= windowInnerHeight + threshold
    &&
    bottomY >= 0 - threshold
  ) {
    var inViewportIfThreshold = true;
  } else {
    var inViewportIfThreshold = false;
  }
  
  return [
    inViewport,
    inViewportIfThreshold
  ];
}

function twGetDeviceThroughMatchMedia() {
  if(window.matchMedia('(max-width: 480px)').matches) {
    return 'mobile';
  } else if (window.matchMedia('(min-width: 481px) and (max-width: 1024px)').matches) {
    return 'tablet';
  }
  
  return 'desktop';
}
var device = twGetDeviceThroughMatchMedia();

function twDisableLazyRenderForRowsThatGetIntoView() {
  var allLazyRenderableRows = document.querySelectorAll (
    'body:not(.tw-builder) #wrapper:not(.disable-lazy-render) main#content .tw-builder-content > .row[style*=\'--cis' + device.substr(0,1).toUpperCase() + ':\']:not(.rendered), body:not(.tw-builder) #wrapper:not(.disable-lazy-render) footer#tw-footer .tw-builder-content > .row[style*=\'--cis' + device.substr(0,1).toUpperCase() + '\']:not(.rendered)'
  );
  var iLazyRenderableRow, lazyRenderableRow;
  for(iLazyRenderableRow = 0; iLazyRenderableRow < allLazyRenderableRows.length; iLazyRenderableRow++) {
    lazyRenderableRow = allLazyRenderableRows.item(iLazyRenderableRow);
    if(twInViewport(lazyRenderableRow, windowInnerHeight / 2)[1]) {
      lazyRenderableRow.classList.add('rendered');
    }
  }
}

function twRenderedRowsIntersectionObserver(entries, observer) {
  entries.forEach (
    function(entry) {
      if(twInViewport(entry.target, windowInnerHeight / 2)[1]) {
        entry.target.classList.add('rendered');
      }
    }
  );
}

var renderedRowsObserver;
window.addEventListener('load', function() {
  renderedRowsObserver = new IntersectionObserver(twRenderedRowsIntersectionObserver, {
    root: null,
    rootMargin: '0px',
    threshold: 0
  });
  document.querySelectorAll('body:not(.tw-builder) #wrapper:not(.disable-lazy-render) main#content .tw-builder-content > .row[style*=\'--cis' + device.substr(0,1).toUpperCase() + ':\']:not(.rendered), body:not(.tw-builder) #wrapper:not(.disable-lazy-render) footer#tw-footer .tw-builder-content > .row[style*=\'--cis' + device.substr(0,1).toUpperCase() + '\']:not(.rendered)').forEach(function(el) {
    renderedRowsObserver.observe(el);
  });
  window.addEventListener('scroll', twDisableLazyRenderForRowsThatGetIntoView);
});

Important note: if you want to use this code, be sure to modify the class names and media queries to fit the design of your own website.


I hope this inspired you to come up with your own solution for calculating contain-intrinsic-size for content-visibility: auto, or to try an approach similar to mine — a design system that linearly scales sizes and spacings throughout three types of devices: desktop, tablet, and mobile.