<p>This post is the third installment of our five-part series on building GitHub’s new homepage: </p>
<p>How our globe is built<br />
How we collect and use the data behind the globe<br />
How we made the page fast and performant<br />
How our illustrators work with designers and engineers<br />
How we designed the homepage and wrote the narrative/ </p>
<p>Creating a page full of product shots, animations, and videos that still loads fast and performs well can be tricky. Throughout the process of building GitHub’s new homepage, we’ve used the Core Web Vitals as one of our North Stars and measuring sticks. There are many different ways of optimizing for these metrics, and we’ve already written about how we optimized our WebGL globe. We’re going to take a deep-dive here into two of the strategies that produced the overall biggest performance impact for us: crafting high performance animations and serving the perfect image.<br />
High performance animation and interactivity<br />
As you scroll down the GitHub homepage, we animate in certain elements to bring your attention to them: </p>
<p>Traditionally, a typical way of building this relied on listening to the scroll event, calculating the visibility of all elements that you’re tracking, and triggering animations depending on the elements’ position in the viewport:<br />
// Old-school scroll event listening (avoid)<br />
window.addEventListener(‘scroll’, () = > checkForVisibility)<br />
window.addEventListener(‘resize’, () = > checkForVisibility) </p>
<p>function checkForVisibility() {<br />
animatedElements.map(element = > {<br />
const distTop = element.getBoundingClientRect().top<br />
const distBottom = element.getBoundingClientRect().bottom<br />
const distPercentTop = Math.round((distTop / window.innerHeight) * 100)<br />
const distPercentBottom = Math.round((distBottom / window.innerHeight) * 100)<br />
// Based on this position, animate element accordingly<br />
}<br />
} </p>
<p>There’s at least one big problem with an approach like this: calls to getBoundingClientRect() will trigger reflows, and utilizing this technique might quickly create a performance bottleneck.<br />
Luckily, IntersectionObservers are supported in all modern browsers, and they can be set up to notify you of an element’s position in the viewport, without ever listening to scroll events, or without calling getBoundingClientRect. An IntersectionObserver can be set up in just a few lines of code to track if an element is shown in the viewport, and trigger animations depending on its state, using each entry’s isIntersecting method:<br />
// Create an intersection observer with default options, that<br />
// triggers a class on/off depending on an element’s visibility<br />
// in the viewport<br />
const animationObserver = new IntersectionObserver((entries, observer) = > {<br />
for (const entry of entries) {<br />
entry.target.classList.toggle(‘build-in-animate’, entry.isIntersecting)<br />
}<br />
}); </p>
<p>// Use that IntersectionObserver to observe the visibility<br />
// of some elements<br />
for (const element of querySelectorAll(‘.js-build-in’)) {<br />
animationObserver.observe(element);<br />
} </p>
<p>Avoiding animation pollution<br />
As we moved over to IntersectionObservers for our animations, we also went through all of our animations and doubled down on one of the core tenets of optimizing animations: only animate the transform and opacity properties, since these properties are easier for browsers to animate (generally computationally less expensive). We thought we did a fairly good job of following this principle already, but we discovered that in some circumstances we did not, because unexpected properties were bleeding into our transitions and polluting them as elements changed state.<br />
One might think a reasonable implementation of the “only animate transform and opacity” principle might be to define a transition in CSS like so:<br />
// Don’t do this<br />
.animated {<br />
opacity: 0;<br />
transform: translateY(10px);<br />
transition: * 0.6s ease;<br />
} </p>
<p>.animated:hover {<br />
opacity: 0;<br />
transform: translateY(0);<br />
} </p>
<p>In other words, we’re only explicitly changing opacity and transform, but we’re defining the transition to animate all changed properties. These transitions can lead to poor performance since other property changes can pollute the transition (you may have a global style that changes the text color on hover, for example), which can cause unnecessary style and layout calculations. To avoid this kind of animation pollution, we moved to always explicitly defining only opacity and transform as animatable:<br />
// Be explicit about what can animate (and not)<br />
.animated {<br />
opacity: 0;<br />
transform: translateY(10px);<br />
transition: opacity 0.6s ease, transform 0.6s ease;<br />
} </p>
<p>.animated:hover {<br />
opacity: 0;<br />
transform: translateY(0);<br />
} </p>
<p>As we rebuilt all of our animations to be triggered through IntersectionObservers and to explicitly specify only opacity and transform as animatable, we saw a drastic decrease in CPU usage and style recalculations, helping to improve our Cumulative Layout Shift score: </p>
<p>Lazy-loading videos with IntersectionObservers<br />
If you’re powering any animations through video elements, you likely want to do two things: only play the video while it’s visible in the viewport, and lazy-load the video when it’s needed. Sadly, the lazy load attribute doesn’t work on videos, but if we use IntersectionObservers to play videos as they appear in the viewport, we can get both of these features in one go:</p>