When you navigate to github.com (you must be logged out because otherwise you'll enter the GitHub app), you'll be greeted by this landing page.
It's a well-designed centered hero design with a sign-up box. It stands out that the hero text fades away while moving behind the code editor once you scroll down. How can you achieve such an effect?
I was curious and kicked off a quick reverse engineering session to discover that everything's built with React, and the application listens to scroll
events. When scrolling, the JS updates the hero text's scale
and opacity
properties in a requestAnimationFrame
loop. In short, a lot is going on to create this effect!
In this article, I will explain how to achieve a similar effect with modern (and future) CSS and zero JavaScript.
If you want to look at the final example, check a deployed demo version online!
To have some code ready, I quickly designed a hero section in Figma and used the Builder Figma plugin to generate some React code with Visual Copilot.
A reasonable first step to recreate the GitHub effect is to slide the hero image over the hero text.
To do this, we can stick the hero text at the top while scrolling. By using position: sticky
, we can place an element inside a container and "make it stick" somewhere when people scroll around.
To give the hero text some air, we'll also add some space using the top CSS property.
.hero-text {
/* Make the element sticky */
position: sticky;
/* Give the element some space at the top */
top: 1rem;
}
When we add position: sticky
, we'll discover that the hero text scrolls on top of the other scrolling elements. This is exactly what you want in most sticky scenarios, but in our case, we want the sticky element to go below the rest.
To make the hero text "slide under", we need to create a new stacking context for the other elements. There are many ways to do this, but I like to set isolation: isolate
to create a new stacking context because it's a nifty one-liner, and more people should know about the isolation
CSS property.
.hero-image {
/* Create a new stacking context */
isolation: isolate;
}
And voila — look at this!
That's not too shabby, isn't it? Now, we only need to add the scroll-fade behavior. How can we add it?
If you're reading this blog, you're probably aware of the ongoing CSS evolution. Let me tell you, there hasn't been a better time to be a web developer. To name a few recent CSS features: we now have container queries, the :has()
selector entered the stage, and view transitions make animating things easier than ever before.
The modern web is fantastic, and one other exciting feature in the making is scroll-driven CSS animations. Scroll what? Yes, you heard that right!
Scroll-driven animations allow you to remove all these custom JavaScript scroll handlers and use the CSS Animations API to add scroll animation behavior.
The API runs off the main thread and provides a massive performance boost compared to thousands of firing scroll handlers in JavaScript land. With scroll-driven CSS animations, you can create scroll effects with less and more performant code.
The new CSS feature is currently supported only by Chromiums, but Webkit is open to this platform addition, and Firefox ships an initial implementation behind a flag! Native scroll-driven animations will be coming to the web!
If Chromium-only isn't good enough to use the new CSS feature yet, I understand. However, with Chromium-based browsers having a global market share of over 70%, it's fair to treat scroll effects as progressive enhancement. Browsers that support scroll-driven animations will show users some eye candy, while the other browsers will only render a headline on top of a hero image.
Side note: if scroll-driven animations are essential for your site, there is also a polyfill. I haven't used it, but it might be a valid alternative to add custom scroll behavior.
But let's get to it and add some eye candy!
Scroll-driven animations are based on good old @keyframes
.
@keyframes fade-out {
0% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 0.5;
}
}
We can define a fade-out
animation that makes an element disappear by scaling it down and reducing its opacity. Next, we take our hero-text
class and set our scroll-driven animation.
.hero-text {
/* Set the animation */
animation: fade-out linear;
/* Connect the animation to the nearest scroller's scroll progress */
animation-timeline: scroll();
}
And look at this: two added CSS declarations give us something to work off of already.
Let's understand what's going on here!
The hero-text
class now sets the fade-out
keyframes using the animation
property. It isn't a "normal" animation
declaration, though.
The animation
shorthand doesn't define an animation-duration
. Usually, time controls the keyframe animation progress, but in our case, the animation-duration
is set to auto
because we want to make it dependent on scroll progress and a scroll timeline.
Below the animation
declaration, you'll see the new animation-timeline
property. Theoretically, animation-timeline
is also part of the animation
shorthand, but you can only use it to reset a timeline. To define an animation other than auto
we must define an animation followed by a specified animation-timeline
. Order matters in this case!
But I got ahead of myself; what's an animation timeline?
An animation timeline specifies the timeline used to control the progress of a CSS keyframe animation. Usually, you control your animations by time which maps to animation-timeline: auto
. But with scroll-driven animations, there are now two new animation scroll timelines: scroll progress (animation-timeline: scroll()
) and view progress (animation-timeline: view()
) timelines.
A scroll progress timeline defined with the scroll()
CSS function maps the scroll progress of a wrapping scroll container (also called scroller) to values from 0%
to 100%
and applies them to a keyframe animation. In our case, the nearest scroll container is the html
element. When scrolling down, the overall document scroll progress controls the fade-out
animation.
And while this works, you might now wonder what happens with massively long HTML documents.
When the fade-out animation relies on document scroll progress, a longer document affects the animation progress.
scroll()
comes in handy if you want to display a document scroll progress indicator, but for our hero animation, it doesn't make sense to use a scroll progress timeline. If you scroll 500px down in a 2000px high document, the animation will progress by 25%. But when the document grows to 10000px, the scroll timeline will only progress to 5%. The animation will be barely noticeable. Our animation should rely on something other than the document length!
Luckily, we can use another scroll timeline.
If you don't want to consider the overall scroll progress, there's another way to control scroll-driven animations. The new view()
CSS function allows you to consider the visibility of an element inside a scroller. The view()
function is quite a beast to wrap your head around, but most importantly, view progress timelines are also wildly powerful!
I will only go into some of the details here (check the resources below if you want to learn more), but let's take a step back to rethink how the visual effect should work.
We want to:
- Start the keyframe animation at
0%
when our hero starts moving off the screen. - Finish the keyframe animation at
100%
when the hero disappears.
To achieve this behavior, we have to adjust the animation range, and of course, there's another new CSS property for it — say hello to animation-range
. animation-range
enables us to define the start and end of our scroll-driven animation. For our fade-out
animation, we're interested in kicking off the animation when an element starts disappearing (0%
) until it's entirely gone (100%
).
For this use case, we can add an exit animation range. Let's go ahead and put this into the CSS.
.hero-text {
animation: fade-out auto ease-in-out;
animation-timeline: view();
/* Apply a custom animation range */
animation-range: exit 0% exit 100%;
}
When you add the custom animation range, you'll discover that, for our hero animation, it doesn't work, though.
We defined the hero text to be sticky, and this positioning implies that it will leave the scroller only when the surrounding container is moving off the screen. The hero text animation is applied, but it's not visible because our hero dummy image covers the scroll exit animation.
When we remove the stickiness, we'll discover that our animation-range
works appropriately. What can we do now that we want to animate a sticky element?
Of course, the new scroll-driven animations have a solution to this problem, too. To make our animation visible, we need to control the animation depending on the view progress of another element with a named view progress timeline.
In our CSS, we can go into the wrapping hero container and specify a view-timeline-name
…
.hero-container {
/* Define a named view progress timeline */
view-timeline-name: --hero-scroll;
}
… to then change our hero text to use the view timeline instead of view()
.
.hero-text {
animation: fade-out auto ease-in-out;
/* Specify the container animation timeline */
animation-timeline: --hero-scroll;
animation-range: exit 0% exit 100%;
}
And now check this out!
Our hero text element fades out and scales down right when the surrounding container starts leaving the screen. And how much CSS did this take us to write? It was only a few CSS declarations and one keyframe animation. I'm amazed!
Let's consider a few more things to be good web citizens, though.
First, some folks can feel discomfort or even sickness when faced with unexpected movements on screen. To build "a good web™", we can add a CSS reduced-motion
media query to allow people to opt out of moving animations.
Second, while creating the demo, I discovered that Firefox's flagged implementation doesn't support the animation-range
property yet. To resolve this, we can also add a @supports
feature query to avoid a broken animation in browsers that don't support scroll-driven animations yet.
If you ask me, the fallback looks excellent as well, but of course, you could also remove the stickiness if you don't like the image sliding over the text.
So, here's the final code.
@supports (animation-range: exit) {
@media (prefers-reduced-motion: no-preference) {
.hero-container {
view-timeline-name: --hero-scroll;
}
.hero-text {
animation: fade-out auto ease-in-out;
animation-timeline: --hero-scroll;
animation-range: exit 0% exit 100%;
}
}
}
.hero-text {
position: sticky;
top: 1rem;
}
.hero-image {
isolation: isolate;
}
@keyframes fade-out {
0% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 0.5;
}
}
Isn't this magical and fun? I'm a big fan!
So, with only a few lines of modern CSS, we rebuilt this JavaScript-heavy effect on GitHub's landing page. Our scroll animation is now more maintainable and performant because we're not relying on any JavaScript blocking the main thread!
And because we're relying on modern CSS paired with progressive enhancement, we can give users the best experience if their browsers support it. Win-win!
If you want to see the effect in action, here's the deployed version.
Are you hooked already? I indeed am!
If you want to learn more about scroll-driven animations, there's a single place to go. Bramus from the Chrome DevRel team, aka "Mr. ScrollAnimation", maintains scroll-driven-animations.style. The site lists many tools, resources, and visual examples.
If you prefer learning with videos, Bramus has also released a free course on YouTube.
And if you wonder how to debug scroll-driven animations, this Chrome extension is invaluable for figuring out what's going on.
And now we're done, have fun animating, and let me know how it goes!
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.