Although CSS has added so many features recently, do we still have to use JavaScript to detect when an element becomes visible?
It depends:
- We don't need JavaScript for lazy-loading images anymore.
- Scroll-linked animations without JavaScript will be coming to CSS.
- But we still need IntersectionObserver to animate images when they first become visible.
- We also need IntersectionObserver to check the visual state of a sticky header element.
As this year's State of CSS survey is open for contributions, we can look back on a year of CSS innovation and better cross-browser support. Extended color space, container queries, and parent/child selectors, top wish list items for years, are now available in modern web browsers.
But we still struggle with visibility detection. There is no :visible
and no :stuck
selector to select elements based on their visibility or sticky state.
Not so simple Use Cases
An apparently simple use case could be a single page portfolio website. We have some pictures, some text, some links, and the contact information. A minimalist website focused on images and visuals, so we want to add some animation. We might also want to shrink or hide the header when scrolling down and make it appear when scrolling up again.
Can we do this without JavaScript?
Both use cases are best solved in JavaScript using IntersectionObserver
, which has a better performance than implementation alternatives based on scroll
event handlers. Scroll detection can waste performance, time, and energy, blocking our main thread and makes websites feel slow and sluggish. IntersectionObserver
is a modern API optimized by browser engines.
But IntersectionObserver
is also an API that requires some boilerplate code which is not easy to understand and learn, especially as a beginner or as a web developer focused on CSS and web design.
I will use an animation example in this post, as there are already a lot of blog posts about sticky headers and their complicated details, especially when it comes to compatibility with Safari browsers on iPhones that don't get the latest updates anymore.
Minimal Movement on Visibility Detection
As micro-animations are trending in web design, we want to animate elements when they come into view: when scrolling down a website, we could make a teaser text move in a subtle way, or make an image enter the viewport.
Animate.css as Animation Library
Simple animations, technically defined as transitions with animation keyframes, don't need to be reinvented. Animate.css (animate.style) is a popular open source CSS animation library that offers a set of predefined animations which can be customized and configured.
Animating a heading or an image becomes quite easy with animate.css
, as we only need to add two class names: animate__animated
, and another animate__*
class to choose an animation effect.
Adding Visibility Detection
But without adding a visibility detection, the animations don't wait for the user to view the element, so they might already have finished when the user scrolls the elements into view. A simple visibility detection using IntersectionObserver
is not that simple after all. We need to
- initialize an observer object,
- tell it what elements to observe,
- make sure we use the correct container element
- adjust the threshold for the visibility trigger
- code a callback function that
- iterates over all triggered element,
- tests the visibility criteria again (why? see below 👇),
- and adds the animation classes to start the actual animation.
Code Example and our animated Website
You can check the commented code pen and a real-world use case below.
Notable details:
- The code example is written in classic JavaScript (< ES6) so that we don't need Babel transpilation for older browser support.
- While we might expect the observer only triggering when an intersection happened at the threshold configured in the observer's options object, we still need to ensure every
intersectingEntry
isIntersecting
and itsintersectionRatio
is greater than ourobserverOptions.threshold
.
It seems counter-intuitive that we have to check the intersection criteria in our callback. I would have expected the IntersectionObserver to return only elements that have changed in the entries array. But it seems that initializing an IntersectionObserver causes an intial function call with an array that contains all observable objects, whether they are intersecting or not. See this StackOverflow answer from 2017 for more details.
Allowing for non-intersecting entries also adds the opportunity to handle elements that are not visible anymore.
- We don't initialize the observer until all DOM elements are present (loaded), so that we don't miss any element that we want to animate:
document.addEventListener('DOMContentLoaded'
... - Elements'
data-animationclass
attributes hold the class name for an animation, to be set as an actual class name usingclassList.add
, by our intersection handler callback function, so that each element can have a different class name.
For the actual portfolio website, I added another attribute, data-animationclassincolumn
to be used instead of data-animationclass
when the animated element is displayed within a column (portrait) layout like on a small smartphone screen. So we can use different animation directions to adapt our animations to alternative screen layouts.
Although our websites might often be complex enough to use custom logic and some JavaScript anyway, there are simple situations where we could do without the boilerplate code and just use some CSS to style a website. So this is my conclusion:
Future CSS Visibility Innovation?
Let's keep our eyes open for upcoming CSS innovation. I hope that there will be something like a :visible
selector so we can handle simple use cases like this, without redundant boilerplate JavaScript code. Then it will become easier to focus on visual web design in the future.
Top comments (2)
This post concludes my series about new, and underrated, features that have come to CSS recently. If you are looking for a comprehensive overview of what has been new in CSS in 2022, have a look at @this-is-learning's review of the state of CSS report 2022 and find some more useful resources in my 2021/2022 dev bookmarks / reading list post.
Getting excited about technological progress, don't forget about compatibility and progressive enhancement and the importance of learning the basics instead of hunting for the latest trends.
@starbist pointing out why he has not been "that excited about new CSS features" is another must-read in my opinion.
Review of the state of css 2022
Nicoss54 for This is Learning ・ Dec 14 ・ 8 min read
I am not that excited about new CSS features
Silvestar Bistrović ・ Oct 6 ・ 3 min read
DEV Bookmarks / Reading List Excerpts from 2021/2022
Ingo Steinke ・ Nov 22 ・ 12 min read
How to prevent smooth scrolling triggering momentary visibility, making animations start too early, or even worse, causing the intended target scrolled out of view caused by layout shift after inserting elements that are larger than their original placeholder?
I found two relevant StackOverflow questions, one is unanswered, the other suggests using a debounce technique trying to figure out if the user is there to stay. As you can see in my project code, I use a hash map to relate timeout IDs to DOM element IDs to prevent redundant function calls.
A helpful takeaway to learn: passing our IntersectionObserver entries to a timeout function, we can check again later if they are still visible. A timeout of 1 second seems more than enough to make sure that the smooth scrolling has finished and the elements that we only passed by are out of sight and not intersecting anymore.
How to prevent intersection observer from firing when passing over elements quickly with the debounce approach:
In the code example below, there is a button to scroll to the bottom of the page, passing over six observed divs.
isIntersecting
fires for each element as they pass through the viewport. Is it possible to throttle or add a delay to the trigger to prevent the intersection event…Avoid handle IntersectionObserver when use window.scrollTo with behavior smooth (unanswered):
I'm creating a landing page using nuxt, I use hash to indicate each route on my page, for this I'm using scrollBehavior to handle hash on route and scroll to section with smooth:
…