AboutPostsTalksOpen sourceProjectsPodcastsVideosToolsResumeContact

Improve Performances With Dynamic “content-visibility”

Using the power of the content-visibility CSS property on dynamic sized elements

Photo de Marc-Olivier Jodoin sur Unsplash
Photo de Marc-Olivier Jodoin sur Unsplash

Initial considerations

This blog post present few technologies that are not available on every browser. This should not be considered as the solution you can use to solve all your performances issues, but as the solution to improve some of your users' experience.

Also, you should consider using it more as an experiment than an ultimate performance savers that can help you understand how the rendering of a browser is working and what can be controlled on it.

Finally, the code could also be improved a lot by doing several enhancements -performance or features- to make it production ready. I use it on some websites, but it hasn't been tested at scale.

The content-visibility CSS property

When we have a big page with a huge amount of data, the browser will struggle on one of the steps it has on displaying the page because it asks for a lot of work.

The browser will go through the following step:

  • Loading
  • Scripting
  • Rendering
  • Painting

The bigger the page is, the slower these operations will be. But at this game, the rendering step is definitely an Achilles’ heel for everyone looking for speed. The browser makes a lot of calculations here to prepare the painting, and the more the page is complex, the more time it takes.

Unlike a house where you can do the painting once the construction is complete -and maybe once in a while for a deserved refreshing-, a browser has many reasons to make a lot of renderings over time. JavaScript DOM modifications, scrolls, dynamic carousels and so on.

To help developers taking more control over this, Chromium has introduced a new CSS property call content-visibility that aims to solve this performance by skipping this rendering part when it is not necessary, mainly because the concerned block is out of screen, which, in our single page applications, can happen a lot: have you ever see an e-commerce website without a vertical scroll?

This newly introduced property takes 3 possible values that are visible, hidden and auto. As you can imagine, the first one, visible exist to tell the browser that a block should always be rendered, no matter if it is on-screen or not, while the hidden value always hides the element from rendering. Since we want to improve our performances, the last one is the one that is the most interesting for us: it will make the browser looking at the supposed position of a given block and decide by itself if the block should be rendered or not.

How does it work?

Setting this CSS property to auto will prevent the browser to execute the rendering part of the displaying, saving a lot of execution time (rendering + painting) on all the hidden elements of the page. Then, as soon as the elements appears on the screen, the rendering will be made, and you can see your content properly.

However, if it hides the rendering, you should not be concerned about SEO issues or having accessibility consequences: all the document model -and therefore the accessibility tree- is available: web crawlers will not suffer for any issues and the screen readers will work as expected. You should also remember that this CSS property is experimental, so old-fashioned system will simply ignore it until correctly implemented.

Finally, once the element quits the screen, it is removed from your page rendering. It will then save another list of potential heavy calculations, such as if you have @keyframes animations, for example.

Setting the block size

As the content is not always rendered on your page, there is one thing that might look a bit strange : your window height will not be the full-rendered one, so scrollbars won't be able to so its proper height. With content removed from rendering as it leaves the page, this issue is not only a problem at the bottom, but also in the top: the upper part of the screen is removed from the page height as you scroll it out of the screen.

So, to make sure the space is reserved, you can use another CSS property there reserve this very space when using the content-visibility CSS property: it is contain-intrinsic-size that you can use to set the width and height of the elements. content-intrinsic-size: 400px 300px will tell the browser that the associated block is 400px width and 300px height.

But?

If you want to display a card, it is very easy to use as you have fixed dimensions, but sometimes, you might not know initially the size of the element that you are not rendering, such as in a blog post: paragraphs have various sizes, code blocks can also have differences in dimensions. And, blog posts are likely to be long and complicated -hello syntax highlighting on code blocks-.

It is a shame for our performances that won't have the amazing benefit of this powerful feature.

Hopefully, there are a few other amazing APIs in the browser that can come to the rescue!

The IntersectionObserver JavaScript object

The IntersectionObserver is an API that detects when a block gets visible or leave the screen viewport, which is pretty the same as, ⁣ butcontent-visibility this time, it is JavaScript, so it gets more interactive and customizable.

It has a lot of awesome usages, such as loading the next or previous pages in an infinite loading page as soon as the footer get into the viewport, pause a video if you scroll it out of the screen or lazy loading images -even tho you should use loading="lazy" instead.

How does it work?

To use an IntersectionObserver you will need three things:

  • A callback function that will be executed as the element enters or leave the visible part of the screen. This is where your magic will happen.
  • An observer object that will handle the configuration of observation, such as the sensibility of the detection. It also means that you can use the same configuration object for every block of your code you want to observe, reducing the impact on the memory.
  • An attachment to a DOM object that we will observe. In our scenario, it will be the block with a dynamic height on which we can’t use the content-visibility property.

Enough with the worlds, here is a very simple code example of how to get started:

intersection-observer.js
const footer = window.document.getElementById('#footer');

const callback = function (entries, observer) {
  // Function executed when the intersection status changes
};

// The observer object. It can be applied to multiple DOM elements
const observer = new IntersectionObserver(callback, {
  rootMargin: "-10px 0px -10px 0px",
});

if (footer !== null) {
  // Listen changes on a single element
  observer.observe(footer);

  // Stop listening a single element
  observer.unobserve(footer);

  // Stop listening all elements.
  observer.disconnect();
}

Deep dive into the callback method

As you saw in the previous gist, the callback method takes two parameters.

The first one is the list of intersections that have just occurred. It is an object with many parameters that might be used in different use cases, but we can focus on the two most important for today:

  • isIntersecting which is a boolean set to true appears according to the configuration false otherwise. That’s the only information useful in our use case, as we want to know if the element has reached the viewport and thus has been rendered and now have a real height.
  • target which is the element that is currently intersecting. It will be the same as the DOM object we listened, but since a single IntersectionObserver can be used many times, this property is more reliable.

The second parameter is the observer instance. It allows us to start listening to another element if we are making an infinite loading system, but in our case, it gives us an opportunity to stop the observation as soon as we have the height collected.

Now, let’s see how we can link it to the content-visibilityfeature.

Handle blocks with dynamic height

Linking IntersectionObserver and content-visibilty is now very simple: we have every tool we need, and we just have to add the smart behavior in the callback method to make an overall improvement on our webpage:

  • First, we create an IntersectionObserver to detect visibility changes of a given block. When the detection is done, we will get into the callback method.
  • Inside the callback method, we can collect the given element actual size since it has been rendered by the browser. It is as simple as const { height } = entry.target.getBoundingClientRect();
  • We can now set the CSS properties on the object. It is a one-liner: entry.target.style.containIntrasicSize = ${height}px`` . There is no need to set the content-visibility: it should have already been assigned in your CSS to make this work. If you haven’t already, you would lose the benefit of make the rendering part of delayed.
  • Finally, since the height is now assigned, we don’t need the observation anymore so we can stop the observation on this block

Here is a code example merging these two features:

dynamic-height-intersection.ts
const elementsWithDynamicHeight: HTMLElement[] = [];
const footer = document.getElementById('footer');
if (footer) {
  elementsWithDynamicHeight.push(footer);
}

elementsWithDynamicHeight.forEach((elementWithDynamicHeight) => {
  const callback: IntersectionObserverCallback = (entries, observer) => {
    entries.forEach((entry) => {

      if (entry.isIntersecting) {
        const data = entry.target.getBoundingClientRect();

        elementWithDynamicHeight.style.containIntrinsicSize = `auto ${data.height}px`;
        elementWithDynamicHeight.style.contentVisibility = "visible";
        
        observer.unobserve(entry.target);
      }
    })
  };

  const observer = new IntersectionObserver(callback, {
    rootMargin: "-10px 0px -10px 0px",
  });

  observer.observe(elementWithDynamicHeight);
});

Handling responsiveness

There is one final touch we can bring to our code to make it more production ready: if the window gets resized, some blocks might have a changed height and thus our assigned value is not relevant anymore. And that's the beauty of responsive designs.

To ensure the height is always the right one, we can just observe the resize window event, remove the assigned height and bring the observer back to business. As soon the element will cross the visual part of the page, its height will be recalculated.

dynamic-height-intersection-with-resize.ts
const elementsWithDynamicHeight: HTMLElement[] = [];
const footer = document.getElementById('footer');
if (footer) {
  elementsWithDynamicHeight.push(footer);
}

function listenContentVisibility() {
  elementsWithDynamicHeight.forEach((elementWithDynamicHeight) => {
    const callback: IntersectionObserverCallback = (entries, observer) => {
      entries.forEach((entry) => {

        if (entry.isIntersecting) {
          const data = entry.target.getBoundingClientRect();

          elementWithDynamicHeight.style.containIntrinsicSize = `auto ${data.height}px`;
          elementWithDynamicHeight.style.contentVisibility = "visible";
          
          observer.unobserve(entry.target);
        }
      })
    };

    const observer = new IntersectionObserver(callback, {
      rootMargin: "-10px 0px -10px 0px",
    });

    observer.observe(elementWithDynamicHeight);
  });
}

listenContentVisibility();

window.addEventListener('resize', () => {
  listenContentVisibility();
});

Conclusion

We now are ready to make our pages a lot faster! With these two light APIs that allow the developer to be more in control of what the page needs to display at anytime. We can benefit from the power of content-visibility event on blocks with a dynamic height!

If, like me, you are working with frameworks, you can find at the end of this blog post a React hook and a Vue composable you can reuse in your applications.

Feel free to give me feedback on your Core Web Vitals improvements with this snippet!

React hook

useContentVisibilityAutoSize.ts
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

interface IContentVisibilityStyle {
  containIntrinsicSize: string;
  contentVisibility: "visible" | "auto" | "hidden";
}

interface IContentVisibilityAutoSize {
  style: IContentVisibilityStyle;
  height: number;
}

export default function useContentVisibilityAutoSize(
  ref: React.MutableRefObject<HTMLElement>,
  observerMargin: number = -10
): IContentVisibilityAutoSize {
  const [height, setHeight] = useState(-1);
  const [hasCalculated, setHasCalculated] = useState(false);
  const observerRef = useRef<IntersectionObserver>(undefined);

  // When an intersection is detected, we assign the element height to the
  // element so we are able to fix the contain-intrinsic-size value
  const observerCallback = useCallback<IntersectionObserverCallback>(
    ([entry]) => {
      if (entry.isIntersecting) {
        const data = ref.current.getBoundingClientRect();

        setHeight(data.height);
        setHasCalculated(true);

        observerRef.current.unobserve(ref.current);
      }
    },
    [ref]
  );

  // The observer is created if the callback function changes or if the
  // detection margin is changed
  useEffect(() => {
    observerRef.current = new IntersectionObserver(observerCallback, {
      rootMargin: `${observerMargin}px 0px ${observerMargin}px 0px`,
    });

    return () => {
      observerRef.current.disconnect();
    };
  }, [observerCallback, observerMargin]);

  // When the window is resized we need to reinitialize the observer so the size
  // can be changed
  useEffect(() => {
    const reset = () => {
      setHasCalculated(false);
      observerRef.current.observe(ref.current);
    };

    window.addEventListener("resize", reset);
    reset();

    return () => {
      window.removeEventListener("resize", reset);
    };
  }, [observerRef, ref]);

  // We send the style to the calling component along with the height so it
  // might be reused for other purposes
  const style = useMemo<IContentVisibilityAutoSize>(
    () => ({
      height,
      style: {
        containIntrinsicSize: hasCalculated ? `auto ${height}px` : `auto`,
        contentVisibility: hasCalculated ? "visible" : "auto",
      },
    }),
    [hasCalculated, height]
  );

  return style;
}

// Usage:
// function MyComponent() {
//   const ref = useRef();
//   const { style } = useContentVisibilityAutoSize(ref, -10);

//   return (
//     <div ref={ref} style={style}>
//       Example
//     </div>
//   );
// }

Vue composable

useContentVisibilityAutoSize.ts
import { computed, watchEffect, ref, ComputedRef, Ref } from "vue";

interface IContentVisibilityStyle {
  containIntrinsicSize: string;
  contentVisibility: "visible" | "auto" | "hidden";
}

interface IContentVisibilityAutoSize {
  style: IContentVisibilityStyle;
  height: number;
}

export default function useContentVisibilityAutoSize(
  targetRef: Ref<HTMLElement>,
  observerMargin: number = -10
): ComputedRef<IContentVisibilityAutoSize> {
  const height = ref(-1);
  const hasCalculated = ref(false);
  const observerRef = ref(undefined);

  // When an intersection is detected, we assign the element height to the
  // element so we are able to fix the contain-intrinsic-size value
  const observerCallback: IntersectionObserverCallback = ([entry]) => {
    if (entry.isIntersecting) {
      const data = ref.value.getBoundingClientRect();

      height.value = data.height;
      hasCalculated.value = true;

      observerRef.value.unobserve(ref.value);
    }
  };

  // The observer is created if the callback function changes or if the
  // detection margin is changed
  watchEffect(() => {
    observerRef.value = new IntersectionObserver(observerCallback, {
      rootMargin: `${observerMargin}px 0px ${observerMargin}px 0px`,
    });

    return () => {
      observerRef.value.disconnect();
    };
  });

  // When the window is resized we need to reinitialize the observer so the size
  // can be changed
  watchEffect(() => {
    const reset = () => {
      hasCalculated.value = false;
      observerRef.value.observe(targetRef.value);
    };

    window.addEventListener("resize", reset);
    reset();

    return () => {
      window.removeEventListener("resize", reset);
    };
  });

  // We send the style to the calling component along with the height so it
  // might be reused for other purposes
  return computed<IContentVisibilityAutoSize>(() => ({
    height,
    style: {
      containIntrinsicSize: hasCalculated ? `auto ${height}px` : `auto`,
      contentVisibility: hasCalculated ? "visible" : "auto",
    },
  }));
}

// Usage:
// <script setup>
//   const myRef = ref(null);
//   const autoSize = useContentVisibilityAutoSize(myRef, -10);
// </script>
//
// <template>
//   <div ref="myRef" :style="autoSize.style">
//     Example
//   </div>
// </template>
You liked the post? Consider donating!
Become a patron
Buy me a coffee