For some years now, a series of techniques have been quite popular with a common point: the introduction of animations or interactions linked to the user's progress through the page (scroll) that often allow us to "tell" a story (scrollytelling).
In its simplest form, this technique can be used to make certain elements "appear" when they reach a fixed point on the screen, thus giving a sense of greater dynamism and catching the user's attention. It is a technique that we can see in hundreds of sites such as, for example, the website of Opigno or this Netlify minisite.
In this tutorial we are going to tell you step by step how we recently added micro-animations to Metadrop.net and how you can do it on your own sites using our new contrib module and the Scrollama.js library.
Why Scrollama.js
Until a few years ago, scroll libraries had to listen to scroll events or even replace the native scroll with a simulated one (scrolljacking), which caused major performance problems, especially on mobile (jumpy scroll, interrupted scroll, etc.).
Fortunately, today we have the IntersectionObserver API that allows us to operate in a much more precise way.
When dealing with animations in metadrop.net, we opted for Scrollama for two main reasons:
- It was one of the first libraries to use IntersectionObserver.
- Instead of including a whole library of pre-made animations and functionalities of all kinds, Scrollama focuses on exposing a simple and minimalistic API that detects the input/output of elements, leaving the developer to choose how to react.
In a sense, Scrollama is the heir of Waypoints.js, another well-known library whose spirit is also to provide the necessary tools and abstractions to operate… and nothing more.
After choosing the tool, we developed and contributed a simple Drupal module to streamline the development process.
This module simply exposes a small data-attributes
based API that allows you to create animations directly with HTML and CSS, without having to write a single line of JavaScript.
Let's get to work: animations
The simplest way to animate an element is to add a class when it reaches the indicated point on the screen and thus be able to delegate all animations and changes to the CSS.
Compared to a pure JavaScript approach, this introduces a number of benefits:
- The
transition
andkeyframes
CSS syntax is simple and powerful. - Animations benefit from the GPU, we can alert the browser via the
will-change
property in order to improve performance and we can honor the user preferences by using theprefers-reduced-motion
media query. - Easier to maintain, scale and modify: just touch a CSS stylesheet.
Let's say we want to animate our elements in two ways: entering from the right or from the left. A small proof of concept would be the following:
Configure Scrollama
We have our animations: now we need to make them run only when the user scrolls.
To do this, we are going to load the Scrollama module in the usual way. If we use composer, it's as easy as writing this:
composer require drupal/scrollama
Let's go to the configuration page and - for the moment - activate it globally. We are also going to activate the "debug" mode so we can see our entry point, which we are going to set to 0.66, i.e. 66% of the screen size from the top edge.
If we reload any page we will see… nothing. This is because Scrollama is only triggered if it detects at least one active element. And how is an element activated? Well, as we said before, by using data-attributes
.
Specifically, by means of these three:
data-scroll-init
- This is the only mandatory attribute. It indicates that the HTML element will be animated, and also indicates which class or classes it will receive when passing through the scroll point.data-scroll-exit
- Alternatively, you can also specify a class to be received at the output of the point, i.e. when the lower limit of the HTML element also exceeds the point defined on the screen.data-scroll-delay
- Optionally you can define a delay expressed in seconds ("1" or "1s"). The CSS class will not be added until after this time.
That is, our HTML elements could end up being something as simple as this:
<div data-scroll-init=”enter-right”>
<!-- My content… -->
</div>
<div data-scroll-init=”enter-left”>
<!-- My content… -->
</div>
Now, our elements receive a class when crossing the scroll point that we can use to introduce our interactions.
The initial state and how to solve it
We will have to rewrite our CSS a bit to adapt it since we now have the problem of the initial state. In the previous example we have hidden the element in the "from" of our @keyframes
, but this state will not be applied until the animation is added.
We want the HTML element to be initially invisible so that we can animate its appearance.
The most direct and simple solution is to use any class to identify our element so we can initialize it as hidden
.
<div class=“will-animate” data-scroll-init=”enter-right”>
<!-- My content… -->
</div>
<div class=“will-animate” data-scroll-init=”enter-left”>
<!-- My content… -->
</div>
<style>
@keyframes enter-right {
from {
opacity: 0;
transform: translateX(50%);
}
to {
opacity: 1;
transform: translateX(0%);
}
}
.will-animate {
visbiility: hidden
}
.will-animate.enter-right {
/* Shows the element… */
visibility: visible;
/* …and animates it */
animation: enter-right 1s ease-out;
}
</style>
If we cannot or do not want to use a class, there is no problem since we can also use CSS attribute selectors on the data-attribute
itself used to instantiate scrollama.
[data-scroll-init] {
visibility: hidden;
}
We are assuming that our strategy to hide the element is by means of the visibility
property, but we could also reproduce the initial state of the animation. This has the disadvantage that it will force us to define each animation separately:
[data-scroll-init~=”enter-right”] {
opacity: 0;
transform: translateX(50%);
}
[data-scroll-init~=”enter-left”] {
opacity: 0;
transform: translateX(-50%);
}
Note that we are using ~=
; this selector indicates that the data-attribute
must contain the entire specified string separated by spaces. It is better to use this and not others like *=
.
You can use any combination, but the option based on [data-attributes]
and visibility:hidden
is more generic and easier to maintain. It is important to use visibility
and not display: none
so that the element keeps its size and we do not produce a reflow of the page that affects performance. If you want to know the difference between repaint and reflow, read this article.
With any of these configurations, we obtain the desired result:
Use transitions
This same code, since it alternates between two states, could also have been expressed using transitions:
[data-scroll-init~="enter-right"] {
opacity: 0;
transform: translateX(50%);
}
.enter-right {
transition: opacity 0.5 ease-out, transform 0.5s ease-out;
}
It is important to note that in this case, we cannot get rid of declaring the initial state animation by animation. It is as if the first rule does the work of the from
of the animation and the second rule does the to
.
The final state and how to solve it
If we have used animations on some property defined outside the @keyframes
statement, we may see this value reset after the animation is executed. Generally, this will produce undesirable results.
This is because, by default, CSS animations after running return to whatever state was initially defined outside of the animation.
For example, take a look at this:
@keyframes enter-right {
from {
opacity: 0;
transform: translateX(50%);
}
to {
opacity: 1;
transform: translateX(0%);
/* Animate background from original value, not from the "from" */
background-color: red;
}
}
.will-animate {
visibility: hidden;
/* This is the original colour */
background-color: green;
}
.will-animate.enter-right {
visibility: visible;
animation: enter-right 1s ease-out;
}
An undesired effect will occur whereby, after the end of the animation, the background-color
value will be reset to the initial green
value. Note that this does not happen with opacity
or transform
because these values are defined inside the animation.
To avoid it, we must add the property animation-fill-mode: forwards
that indicates that after the animation the final state must remain.
.enter-right {
animation: enter-right 1s ease-out;
animation-fill-mode: forwards
}
You can see the result in this example: the leftmost values don't have animation-fill-mode: forwards
, the ones on the right do.
Finishing touches and best practices
Before finishing, we should consider a number of improvements that will make our code more respectful of user preferences and improve its performance.
Honor the user preferences
There are users who, for various reasons, prefer not to have animations, transitions or effects of any kind on web pages.
For some years now, browsers allow the user to specify this preference and expose a media query called prefers-reduced-motion
that allows us to react to this configuration.
Applying it on a global level is as easy as typing the following
@media (prefers-reduced-motion) {
* {
animation: none;
transition: none;
}
}
Although, in our case, we have to take into account that we are starting with the hidden element so we will have to show it as well:
@media (prefers-reduced-motion) {
* {
animation: none;
}
[data-scroll-init] {
visibility: visible
}
}
This can get complicated quickly, so we can also think about it the other way around and activate the animations if the user has no preference:
@media screen and (prefers-reduced-motion: no-preference) {
/* …here goes our custom code */
}
Remove JavaScript dependency
Another problem we have introduced that is very common is that, in the absence of JavaScript, our page will be unusable since we have several hidden elements that will not be displayed.
The solution here may be the old trick of adding a .no-js
class to the body that is changed to .js
when JavaScript is initialized, something that in jQuery is as simple as this (you can see here an alternative in JavaScript vanilla)
$(document).ready(function(){
$(window).removeClass(‘no-js’).addClass('js');
});
It would then suffice to restrict our selectors to act only when the page has JavaScript active:
.js [data-scroll-init] {
visibility: hidden
}
.js .enter-right {
animation: enter-right 1s ease-out;
}
Improve performance using will-change
There is a recent and fairly well-supported property that allows us to announce to the browser when an element is expected to receive state changes that affect its rendering (position, animations, color, etc).
This allows the browser to pass the handling of that element to the GPU, instead of the default behavior whereby the CPU is in charge of rendering until an animation appears, at which point it is passed to the GPU - at which point this "jump" can lead to performance problems or jerky movements. If you want to know more, this article by Sara Soueidan explains it very well.
[data-scroll-init] {
will-change: transform, opacity;
}
Next level
As we have seen, Scrollama.js allows us to easily add points on the screen and manually "trigger" actions when certain elements pass through them.
In the tutorial, we focused on micro-animations, but nowadays these types of interactions are being used to build impressive web pages. Two of the most popular techniques in recent years are parallax and scrollytelling: the former refers to the effect by which some elements scroll at a speed different from that of the scroll and the latter, typical of newspaper formats, refers to narrative pieces in which the content is displayed and developed as the user advances. A good example of parallax is the website of the soft drink Cann and in scrollytelling, newspapers such as The Guardian stand out.
From the smallest to the largest, they all have one thing in common: they can be orchestrated using scrollama behind to provide the ability to react to the entry and exit of elements in the scroll.