DEV Community

Cover image for Implementing scroll-aware UI state with CSS
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Implementing scroll-aware UI state with CSS

Written by David Omotayo✏️

Interactive user interface features, like scroll-aware UI state, can improve website user experience but often pose challenges regarding performance optimization. This is largely because most interactive UI functionalities are implemented using JavaScript.

CSS-only solutions can reduce the workload on the rendering pipeline, as compared to JavaScript which works on the main thread. However, there are limitations to what can be achieved solely with CSS.

In this article, we'll investigate the concept of scroll-aware UI state and discuss how it can help strike a balance between the level of interactivity and system performance. We’ll also explore methods to implement this feature using only CSS.

Prerequisites

To follow along with this tutorial, you should have:

  • Foundational knowledge of CSS
  • Familiarity with JavaScript

What is scroll-aware state?

Scroll-aware user interface state enables developers to create native-like interactivity on web interfaces. This is a popular approach for improving website user experience.

"Scroll-aware state" may seem like an obscure term, but it's just a fancy way of saying "scroll-driven UI" — it’s a UX pattern that responds dynamically and adaptively to the user's scrolling action on a webpage.

Here’s an example that showcases how a scroll-aware UI looks and behaves: Scroll-aware UI with full-height cover card fixed to its header

This UX pattern is often used in combination with JavaScript APIs such as the Intersection Observer API, or third-party packages to monitor, calculate, and store the state of a user’s scroll progress and position. This position can then trigger CSS animations or styles that transform the appearance, behavior, or visibility of elements or components on a webpage.

Scroll-driven UI designs can be as simple as a scroll-snap, fixed positioning, or even a parallax effect made solely with CSS. They can also be more complex. With the recent additions to the CSS scroll-driven animation specification, it’s now possible to achieve the same results as complex animations made with JavaScript using only CSS.

Implementing scroll-aware animations with CSS

A scroll-aware UI can range from a simple scroll-snap effect to an element that gets animated when it is scrolled into view by a user or when a user scrubs forward or backward in direct response. There are several ways to implement this kind of UX pattern.

A simple scroll-snap is the most common type of scroll-aware UI. Implementing this effect is as easy as adding a scroll-snap-type: y or position: fixed one-liner property to a container to get a result like this:

scroll-snap scroll-aware UI

This type of scroll-aware UI is simple to implement, but there's far more you can do beyond our basic example. For a more comprehensive understanding of how these properties work and how to use them, check out: How to style scroll snap points with CSS and Build a custom sticky navbar with CSS.

The latter example is not an easy case of slapping a CSS property to a container; it requires calculating a user's scroll distance on a webpage and detecting when an element comes into view. CSS is not capable of such functionality, so scroll-driven animations with CSS are basically an impossible feat — or were at the time that article was written.

A new set of APIs that work in conjunction with the Web Animation API (WAAPI) and the CSS Animations API were recently introduced to the CSS scroll-driven animations specification to facilitate the implementation of declarative scroll-driven animations using only CSS. These APIs are an addition to the list of animation timeline property values that provide a new and accessible way of controlling the progress of CSS animations:

  • Scroll progress timeline
  • View progress timeline

To understand how these timelines work, let’s first look at what an animation timeline entails.

Understanding the CSS animation-timeline property

The CSS animation-timeline property is used to specify the timeline that controls the progress of a CSS animation. Before the introduction of the scroll and view progress timelines, we only had access to the default document timeline.

The document timeline was brittle, lacking customization options, and it progressed continuously from the web page’s initial load. Webpage animations automatically started when the page loaded and continued for the specified animation duration. Before the availability of scroll and view progress timelines, there was no flexibility to control this behavior.

The animation-timeline property accepts any of the following values: scroll(), view(), or auto, and is assigned to an element that has an animation preset. For example, to rotate a box element when a user scrolls up or down the webpage, you'd have an animation preset like the following before adding the animate-timeline property and a specific value:

  #box {
    animation-name: box;
    animation-direction: alternate;
    /* animation timeline with any of the accepted values */
  }

  @keyframes box {
    from {
      transform: rotate(0deg);
    }
    to {
      transform: rotate(360deg);
    }
  }
Enter fullscreen mode Exit fullscreen mode

This will cause the box to animate as expected, but it will use the specified timeline instead of the default document timeline: animating the box using the specified timeline

Scroll progress timeline

The scroll progress timeline operates based on the scroll position of a scrollable element, which may be referred to as a scrollport, scroller, or source. Since the timeline is tied to the container of the animating element, its timing progresses only when the container's scroll position undergoes changes.

The container’s scroll position is indicated in percentages, with the initial scroll position represented as 0% and the end scroll position as 100%. This is in contrast to the default document timeline, which continues progressing either for the specified duration or for the duration that the webpage remains open (if no duration is specified).

There are two ways to define a scroll timeline:

  • Anonymous method
  • Named method

Anonymous scroll progress timeline

The anonymous method is the default method and the quickest way to define a scroll timeline. If you use the scroll() function and assign it as the value for the animation-timeline property, the scroll progress timeline will select the nearest scroller ancestor and leverage its timeline:

animation-timeline: scroll();
Enter fullscreen mode Exit fullscreen mode

The scroll() function accepts two optional arguments: <scroller> and <axis>. The <scroller> argument is used to reference the source (i.e., the scrollable element or container) and its scroll position that will drive the progress of the timeline. Here are its accepted values:

  • nearest: Selects the nearest scrollable ancestor of the current element
  • root: Selects the root element of the document, such as the <html> element
  • self: Selects the current element itself, if it is scrollable

The <axis> argument is used to specify the scrollbar direction that will be used to provide the timeline. Here are its accepted values:

  • block: Uses the measure of progress along the block axis of the scroll container
  • inline: Uses the measure of progress along the inline axis of the scroll container
  • y: Uses the measure of progress along the vertical axis of the scroll container
  • x: Uses the measure of progress along the horizontal axis of the scroll container

The scroll() function's default value uses the nearest scroller and block axis values:

animation-timeline: scroll(nearest block);
Enter fullscreen mode Exit fullscreen mode

The above code will bind the animation to the root scroller on the block axis.

The scroll progress timeline can be used for different scroll-aware designs, but the most practical use case is a reading progress indicator. This is easy to create using the anonymous scroll progress timeline, like so:

    <main>
      <section>
          <article>
          ...
        </article>
      </section>
      <div id="progress"></div>
    </main>

#progress{
    width: 100%;
    height: 10px;
    background-color: #c00bc0;
    position: fixed;
    top: 0;
    left: 0;

    transform-origin: 0 50%;
    animation: progress linear;
    animation-timeline: scroll();
}
Enter fullscreen mode Exit fullscreen mode

In this code, we fix the reading progress indicator to the top of the viewport and scale it forward and backward on the x-axis based on the scroller's position. Here’s the result:

scaling the reading progress indicator on the x axis based on scroller’s position

Named scroll position timeline

The named scroll position timeline offers explicit control over scroller selection. Instead of relying on automatic lookup by the API, it allows you to assign unique identifiers, or names, to specific containers with scrollbars.

This approach aids in pinpointing and utilizing the scroll progress timeline of the identified container. This method is especially useful in cases where the webpage has multiple timelines and the anonymous automatic lookup doesn't suffice.

To implement a named scroll position timeline, set the scroll-timeline-name property on the desired scroll container with a name value of your choice. Ensure the value has a -- prefix, like so:

scroll-timeline-name: --my-timeline;
Enter fullscreen mode Exit fullscreen mode

Similar to the anonymous scroll timeline, you can adjust the axis using the scroll-timeline-axis property. This property uses the same values as the <axis> argument in the anonymous method:

scroll-timeline-axis: block;
Enter fullscreen mode Exit fullscreen mode

Unlike the animation-timeline property, you can combine the scroll-timeline-name and scroll-timeline-axis properties into a single shorthand property: scroll-timeline. This means you can define both properties at the same time, instead of having to do so separately.

Here’s an example that demonstrates how named scroll progress works:

.source {
    scroll-timeline-name: --my-timeline;
    scroll-timeline-axis: inline;
  }
You can simply write:
.source {
    scroll-timeline: --my-timeline inline;
  }
Enter fullscreen mode Exit fullscreen mode

You can update the base markup of the previous demo and add another scroll container to the page:

<div id="galaxies" style="--num-images: 3">
  <div id="galaxy-container">
    <div class="galaxy_progress"></div>
    <div class="andromenda galaxy_entry">
      <img src="./andromeda.jpg" />
    </div>
    <div class="pinwheel galaxy_entry">
      <img src="./pinwheel.jpg" />
    </div>
    <div class="milky-way galaxy_entry">
      <img src="./milky.jpg" />
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

In this code, we create a galaxy-container with three child images and a galaxy_progres element, which will be positioned absolute to the galaxies container.

Next, let’s give the galaxy-container an identifier and an inline axis using the scroll-timeline shorthand property. Then, we’ll attach it to an animating element (this case, the galaxy_progress element) using the animation-timeline property:

#galaxy-container{
    overflow-x: scroll;
    scroll-snap-type: x mandatory;
    display: flex;
    scroll-timeline: --galaxies inline;
}

@keyframes galaxies_animation {
    to { transform: scaleX(1); }
  }

.galaxy_progress {
    position: absolute;
    top: 0;
    left: 0;
    width: 500px;
    height: 10px;
    background-color: #c00bc0;
    transform: scaleX(calc(1 / var(--num-images)));

    animation: auto galaxies_animation linear forwards;
    animation-timeline: --galaxies;
  }
Enter fullscreen mode Exit fullscreen mode

Here we set an identifier, --inner_timeline, to the .inner-container and reference it with the animation-timeline property on the .gallery-progress animation. Here’s the result:

Scroll-Aware UI Named Scroll Position Timeline

It may seem counterintuitive to use a named scroll progress timeline here, since an anonymous progress timeline could have easily used the nearest ancestor container with a scrollbar using the scroll(nearest inline) argument (the .inner-container, in this case). But, actually, since the .gallery-progress is absolutely positioned, the animation timeline would have skipped the .inner-container and used the root scroller, which is the nearest ancestor that is relatively positioned.

The nearest argument considers the element that can affect its size and position. Since the .gallery-progress element is absolutely positioned, and its nearest parent is not relatively positioned, it will jump to the nearest ancestor that is relatively positioned and use its timeline.

Scroll progress timeline ranges

The default behavior of animations linked to the scroll and view timeline is to attach to the entire page's range. For the scroll timeline, this spans from 0% to 100% scrolled. For the view timeline, it covers the period from an element's initial visibility at one edge to its appearance at the opposite edge.

The animation-range property comes into play when you need to customize these values and override the default behavior. You can use the animation-range property, to bind a scroll or view timeline animation to specific start and end ranges on the timeline:

animation-range: <range-start> <range-end>;
Enter fullscreen mode Exit fullscreen mode

If you only want an animation to run for the first 40% of the webpage and end at 80%, you can define it as follows:

animation-range: 40% 80%;
Enter fullscreen mode Exit fullscreen mode

The animation-range can also be specified in view height (vh) values, like so:

animation-range: 40vh 80vh;
Enter fullscreen mode Exit fullscreen mode

The animation-range property accepts different values depending on the timeline it is used with. The example above will only work for the scroll progress timeline, as the specified values are within the range of the scroll progress timeline's start and end values. We’ll look at how to define an animation range for the view progress timeline later in this article.

View progress timeline

The view progress timeline is also linked to the scroll position of a container, but in relation to an element's progress within the container. Simply put, it’s the relative position of the element in the scroller that determines the progress of the timeline. The timeline progress begins the moment an element first intersects with the scroller and ends when the element no longer intersects the scroller.

The view progress timeline is similar to the Intersection Observer API in JavaScript. You can use both the anonymous and names methods to create the view progress timeline.

Anonymous view progress timeline

To create an anonymous view progress timeline, you’ll use the view() function and pass it as the animation-timeline value:

animation-timeline: view();
Enter fullscreen mode Exit fullscreen mode

The view() function accepts the <axis> argument and an optional <view-timeline-inset> argument. The <axis> argument is identical to that used in the scroll() function; it is used to define which axis to track, and it accepts the same values.

The <view-timeline-inset> argument is used to specify an offset value to adjust the bounds when an element is considered to be in (or outside of) the view. The offset value can be positive or negative.

It’s easy to add a view progress timeline to any webpage animation using the anonymous method. As an example, you can use the following code to make the images on our demo page fade in when they're scrolled into view:

@keyframes image-reveal {
    from {
        opacity: 0;
        clip-path: inset(0% 30% 0% 30%);
     }
    to {
        opacity: 1;
        clip-path: inset(0% 0% 0% 0%);
    }
  }

  .images {
    animation: image-reveal both;
    animation-timeline: view();
  }
Enter fullscreen mode Exit fullscreen mode

With this simple code, every image element on the webpage fades in as it intersects with the viewport:

Image elements fading on the webpage as users scroll up

Named view progress timeline

You can create a named view timeline using the same method employed for the scroll timeline. Just set an identifier (or name) and an axis with the same properties and values, but replace the scroll prefix with view:

.nested-container{
    view-timeline-name: --image-reveal;
    view-timeline-axis: block;
}

@keyframes image-reveal {
    from {
        opacity: 0;
        clip-path: inset(0% 30% 0% 30%);
     }
    to {
        opacity: 1;
        clip-path: inset(0% 0% 0% 0%);
    }
  }

.nested-images{
    animation: image-reveal both;
    animation-timeline: --image-reveal;
  }
Enter fullscreen mode Exit fullscreen mode

This code will create the same visual output as the anonymous method, but for the nested scroller and its child images.

View progress timeline ranges

The animation range value varies depending on the timeline in use. In the case of the view timeline, it accepts a range name value rather than a percentage value. Here are the acceptable range name values for the view timeline:

  • cover: Sets the view timeline range to values that ensure the element is fully covered by the scroller during the view timeline
  • entry: Sets the start value of the view timeline range to 0% and sets the end value to the percentage of the element’s visibility when it’s inside the scroller
  • exit: Sets the start value of the view timeline range to the percentage of the element’s visibility when it’s inside the scroller and sets the end value to 100%
  • entry-crossing: Sets the start value and end value of the view timeline range to the percentage of the element’s visibility when it is first visible at one edge of the scroller and when it is fully visible inside the scroller, respectively
  • exit-crossing: Sets the start value and end value of the view timeline range to the percentage of the element’s visibility when it‘s inside the scroller and when it reaches the opposite edge of the scrollbar, respectively
  • contain: Specifies that the animation will start and end within the bounds of the scrollport

These range names are often combined with a range offset when defining the range-start and range-end values for the animation-range property. The range offset is a percentage value that is used to determine the position of an element in relation to the range name it is combined with.

For example, if you want an animation to start halfway the moment it intersects with the scrollport, set the range-start value to entry 50%. This way, the animation will start and progress to 50% before it enters the viewport. The same approach applies to the range-end value:

animation-range: entry 25% cover 50%;
Enter fullscreen mode Exit fullscreen mode

Limitations

Browser compatibility is a significant issue that you’ll encounter when implementing scroll-aware UI via CSS. Most of the new CSS APIs, and even some old ones, that provide an improved scope for scroll-aware UI design are still experimental and do not yet have widespread support across different browsers.

For instance, at the time of writing, the scroll and view APIs are currently supported only in Chromium-based browsers as well as on Firefox browsers behind a feature flag.

Another limitation is the performance and accessibility concerns that often arise when dealing with scroll-driven animations. The complexity and frequency of animations can lead to issues such as jank, lag, or even battery drain on certain devices. Furthermore, these animations can interfere with user scrolling experience as well as accessibility tools like screen readers and keyboard navigation.

One approach to mitigate these issues is to optimize the animations to ensure smooth performance without compromising user experience. Additionally, providing options to disable animations can enhance accessibility for users who might find them disruptive.

Another effective strategy is to use JavaScript polyfills. Polyfills extend the functionality of CSS features, allowing them to work in browsers that do not have native support. You can use JavaScript polyfills to improve compatibility and ensure that scroll-driven animations work consistently across different browsers.

Extending capabilities with JavaScript

The new CSS features for scroll-driven animations are powerful and declarative, but they are not yet widely supported by all browsers. To ensure cross-browser compatibility and accessibility, you can use JavaScript to enhance or polyfill the scroll-driven animations.

Several libraries and frameworks can help with this task, such as ScrollTrigger, ScrollMagic, Framer Motion, and the good ol’ Intersection Observer API. These tools provide more options and flexibility to create complex and interactive scroll-driven animations with ease.

Another way to extend capabilities is to use the scroll-driven animation specification’s ScrollTimeline and ViewTimeline interfaces. You can use these classes to create scroll and view timeline animations using JavaScript, creating similar interactions as their CSS counterparts. They are useful for those who prefer using JavaScript and for cases where JavaScript is required for additional functionality.

The ScrollTimeline and ViewTimeline classes accept options such as the source element, axis, orientation, range, and range of the timeline. You can also link these timelines to animations created with the Animation interface and control them with methods such as play, pause, resume, cancel, or reverse.

To create a scroll and view timeline using JavaScript, create a new ScrollTimeline or ViewTimeline object using the constructor. Then, specify the source element and any of the following optional properties: axis, orientation, and range:

  const timeline = new ScrollTimeline({
    source: document.documentElement, // The document element as the source
    axis: "block",
  });
Enter fullscreen mode Exit fullscreen mode

Here, we set the document element as the source; this will bind the animation to the timeline of the root scroll position.

Next, create a new Animation object using the animation constructor. You can pass a keyframe object to specify the animation properties and values, and an options object to specify the animation duration, easing, and fill.

You’ll also need to pass the ScrollTimeline or ViewTimeline object as the timeline option to link the animation to the timeline:

  const animation = new Animation(
    /* The keyframe object*/
    {
      transform: ["rotate(0deg)", "rotate(360deg)"],
    },
    /* The options object*/
    {
      duration: 100,
      fill: "both",
    }
  );

  /*Link the animation to the timeline*/
  animation.timeline = timeline;
Enter fullscreen mode Exit fullscreen mode

Conclusion

The concept of scroll-aware UI refers to various interactive elements on a webpage that dynamically adjust, stick, or snap into position as the scrollbar thumb moves. In this article, we explored building scroll-aware UI with only CSS in order to provide more interactivity and better user experience without negatively impacting system performance.

There are many ways to implement scroll-aware designs for your web applications. The possibilities are virtually endless, limited only by one's imagination.

Happy hacking!


Is your frontend hogging your users' CPU?

As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app, mobile app, or website. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web and mobile apps — Start monitoring for free.

Top comments (3)

Collapse
 
best_codes profile image
Best Codes

Very interesting and thorough, but I can't see your GIFs and they don't have alt text. Overall, an awesome article!

Collapse
 
mangelosanto profile image
Matt Angelosanto

Thanks for catching that! The article has been updated to include alt text.

Thanks for reading!

Collapse
 
best_codes profile image
Best Codes

No problem! Keep up the good posts!