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 size for rows that are not yet rendered, because those would otherwise have no size while outside of your viewport.
While property contain-intrinsic-size is actually a shorthand for properties contain-intrinsic-height and contain-intrinsic-width, you are most likely not going to bother about contain-intrinsic-width, as most web designs do not scroll horizontally.
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
Rendered size vs intrinsic size
What is the difference between rendered size and intrinsic size?
Let's start with intrinsic size. The intrinsic size of an element is determined by the contents of an element, without any CSS styles that affect it's final size (rendered size).
Let's take an image as an example: it's original/intrinsic size is 400x400 pixels.
Let's say, the following CSS applies to the image: max-width: 100%
. If that image is shown on a smartphone which's viewport width is 360 pixels, then it's final size won't be 400x400 pixels, but 360x360 pixels at most - because of the maximum width by the CSS.
That very final size of 360x360 or smaller is called the rendered size of the image.
Now that you know the difference between the two, you could probably understand why CSS property contain-intrinsic-size is called the way it is: it provides a placeholder value for elements that have no rendered size (due to content-visibility: auto).
Browser support for CSS properties content-visibility and contain-intrinsic-size
Can I use content-visibility?
Yes, but it's not supported by all browser versions. You can use content-visibility in the following, earliest supporting, browser versions (as of November 18, 2021):
- Google Chrome version 85
- Chrome for Android version 95
- Microsoft Edge version 85
- Android Browser version 95
- Samsung Internet version 14.0
- Opera version 71
- Opera Mobile version 64
Can I use contain-intrinsic-size?
Yes, but not all browsers support contain-intrinsic-size. Logically, this CSS property is supported by the same browser versions as is the case with the CSS property that goes hand-in-hand with this property: content-visibility.
You can use CSS property contain-intrinsic-size in the following, earliest supporting, browser versions (as of November 18, 2021):
- Google Chrome version 83
- Chrome for Android version 95
- Microsoft Edge version 83
- Android Browser version 95
- Samsung Internet version 13.0
- Opera version 69
- Opera Mobile version 64
Requirements for calculating contain-intrinsic-size
I can not provide a ready-to-use solution, because every website uses a different web design system and tool to manage their content. That's why I provide you a way to implement lazy-rendering into your own web design.
These are the requirements for implementation:
- Viewport-relative web design or a predictable container-based web design. If a container-based web design also uses viewport-relative sizing and/or sizes that vary in-between breakpoints that determine the width of containers, that will make the height of rows unpredictable. That will make it useless to cache the height of rows at certain container-widths, since then the row's height can have thousands of outcomes.
- 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)
- Measure height of rows at different devices (for viewport-relative web designs) or at multiple container widths (for container-based web designs).
- Measure viewport width (viewport-relative) or container width (container-based).
- Save height of rows at certain viewport widths (for viewport-relative web designs) or save height of row at certain container-widths (for container-based web designs). Save those values inside custom HTML data-attributes, which you will query using PHP.
PHP - Formulas for contain-intrinsic-size (outside editor)
- Query all rows with those HTML attributes, using PHP's built-in XML/HTML parser.
- 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-relative web design
Apply CSS variables to calculate contain-intrinsic-size for current viewport-width.
If you use a container-based web design
Apply CSS variables to set value for contain-intrinsic-size at current container-width.
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
Loading | 22.8ms |
---|---|
Scripting | 92.8ms |
Rendering | 173.8ms |
Painting | 74ms |
System | 57.2ms |
Desktop - Lazy-rendering
Loading | 21.4ms | -6.14% |
---|---|---|
Scripting | 96.2ms | +3.66% |
Rendering | 105.4ms | -39.36% |
Painting | 71ms | -4.05% |
System | 53.4ms | -6.64% |
Mobile - No lazy-rendering
Loading | 24ms |
---|---|
Scripting | 88.8ms |
Rendering | 188.6ms |
Painting | 19.2ms |
System | 52.2ms |
Mobile - Lazy-rendering
Loading | 19ms | -20.83% |
---|---|---|
Scripting | 92.4ms | +4.05% |
Rendering | 114.2ms | -39.45% |
Painting | 14.6ms | -23.96% |
System | 51.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 web 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 web design of my website works. The web design of my website adapts to 3 device categories:
- Mobile
@media (max-width: 575.98px) { ... }
- Tablet
@media (min-width: 576px) and (max-width: 1199.98px) { ... }
- Laptop & Desktop
@media (min-width: 1200px) { ... }
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:
data-cache-vw-d
(viewport's width on desktop)data-cache-h-d
(height of row on desktop)data-cache-vw-t
(viewport's width on tablet)data-cache-h-t
(height of row on tablet)data-cache-vw-m
(viewport's width on mobile)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);
/* Variable $buffer is collected by calling ob_get_clean() after
* collecting HTML by calling ob_start() prior to echoing any HTML */
$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
}
$html = '<!DOCTYPE html>' . $doc -> saveHTML($doc -> documentElement);
$html = preg_replace('/</wbr>/is', '', $html);
$html = preg_replace('/</source>/is', '', $html);
echo $html;
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: 575.98px) {
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: auto calc(var(--cisM));
}
}
@media (min-width: 576px) and (max-width: 1199.98px) {
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: auto calc(var(--cisT));
}
}
@media (min-width: 1200px) {
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: auto 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 web 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 web 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: 575.98px)').matches) {
return 'mobile';
} else if (window.matchMedia('(min-width: 576px) and (max-width: 1199.98px)').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 web 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 web design system that linearly scales sizes and spacings throughout three types of devices: desktop, tablet, and mobile.
2 Comments
If you have any questions, be sure to leave a comment!
Wow, this is extremely thorough. Bookmarked for later when I have the time to work out a solution for some of my own sites. Thank you!