cssscrollcss-animationssmooth-scrolling

How to add a delay or smooth out scroll-driven animations using the animation-timeline property?


I'm currently using CSS scroll-driven animations with the new animation-timeline property. An example of the technique I'm referring to can be seen here: Image Reveal Example.

However, I'm facing an issue: I would like to add an animation delay to smooth out the animation, especially when scrolling with a mouse. Currently, the animation looks a bit choppy, as mouse scrolls on a PC can be less smooth and occur in larger, instant steps.

I've tried using the traditional CSS property animation-delay, but it doesn't seem to have any effect when used with scroll-driven animations and the animation-timeline property.

Here is an example of how I want the animation to feel:

// Initialize Lenis
const lenis = new Lenis();

// Listen for the scroll event and log the event data
lenis.on('scroll', (e) => {
  console.log(e);
});

// Use requestAnimationFrame to continuously update the scroll
function raf(time) {
  lenis.raf(time);
  requestAnimationFrame(raf);
}

requestAnimationFrame(raf);
.animation-element-wrapper {
  display: grid;
  justify-content: center;
  background-color: green;
}

.animation-element {
  background-color: red;
  height: 50px;
  width: 50px;
  animation: move;
  animation-timeline: view(block);
}

@keyframes move {
  from {
    transform: translateY(0) rotate(0deg);
  }
  to {
    transform: translateY(-150px) rotate(360deg);
  }
}

// Smooth scroll styling

html.lenis, html.lenis body {
  height: auto;
}

.lenis.lenis-smooth {
  scroll-behavior: auto !important;
}

.lenis.lenis-smooth [data-lenis-prevent] {
  overscroll-behavior: contain;
}

.lenis.lenis-stopped {
  overflow: hidden;
}

.lenis.lenis-smooth iframe {
  pointer-events: none;
}
<script src="https://unpkg.com/lenis@1.1.14/dist/lenis.min.js"></script>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>

<div class="animation-element-wrapper">
  <div class="animation-element"></div>
</div>

<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
(The stackoverflow snippet doesn't seem to work well with smooth scroll so here's a codepen where the smooth scroll does work: Smooth Scroll Example)

In this case, I'm using a JavaScript library to achieve smooth scrolling for the entire page. My goal is to apply a similar smooth, slow-moving effect for a CSS scroll-based animation without using a JS library.

To highlight the issue, here's another example without the smooth scroll effect:

.animation-element-wrapper {
  display: grid;
  justify-content: center;
  background-color: green;
}

.animation-element {
  background-color: red;
  height: 50px;
  width: 50px;
  animation: move;
  animation-timeline: view(block);
}

@keyframes move {
  from {
    transform: translateY(0) rotate(0deg);
  }
  to {
    transform: translateY(-150px) rotate(360deg);
  }
}
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>

<div class="animation-element-wrapper">
  <div class="animation-element"></div>
</div>

<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
<h1>Hello World!</h1>
Or Codepen: Non-Smooth Example

The difference in fluidity is noticeable, and I'd like to replicate the smoother experience using pure CSS.

Is there a way to introduce a delay or smooth out this animation behavior using just CSS, or is JavaScript still required? I'm primarily looking for a CSS-based solution.


Solution

  • I've been able to figure it out by using only CSS:

    @property --scroll-position {
      syntax: '<number>';
      inherits: true;
      initial-value: 0;
    }
    
    @property --scroll-position-delayed {
      syntax: '<number>';
      inherits: true;
      initial-value: 0;
    }
    
    @keyframes adjust-pos {
      to {
        --scroll-position: 1;
        --scroll-position-delayed: 1;
      }
    }
    
    .animation-element-wrapper {
      animation: adjust-pos linear both;
      animation-timeline: view(block);
      
      display: grid;
      justify-content: center;
      background-color: green;
    }
    
    .animation-element {
      transition: --scroll-position-delayed 0.15s linear;
    }
    
    .red-square {
      background-color: red;
      height: 50px;
      width: 50px;
      transform: translateY(calc(-150px * var(--scroll-position-delayed)));
    }
    
    /* Display debugging information */
    #debug {
      position: fixed;
      top: 50%;
      left: 75%;
      translate: -50% -50%;
      background: white;
      border: 1px solid #ccc;
      padding: 1rem;
      
      & li {
        list-style: none;
      }
      
      counter-reset: scroll-position calc(var(--scroll-position) * 100) scroll-position-delayed calc(var(--scroll-position-delayed) * 100);
      
      [data-id="--scroll-position"]::after {
        content: "--scroll-position: " counter(scroll-position);
      }
      [data-id="--scroll-position-delayed"]::after {
        content: "--scroll-position-delayed: " counter(scroll-position-delayed);
      }
    }
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    
    <div class="animation-element-wrapper">
      <div class="animation-element">
        <div class="red-square"></div>
        <div id="debug">
          <ul>
            <li data-id="--scroll-position"></li>
            <li data-id="--scroll-position-delayed"></li>
          </ul>
        </div>
      </div>
    </div>
    
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>
    <h1>Hello World!</h1>

    This is all thanks to this article: https://www.bram.us/2023/10/23/css-scroll-detection/#lerp-effects

    Explanation.
    Here the scroll position is fetched (and animated) from the parent of the red-square:

        @keyframes adjust-pos {
          to {
            --scroll-position: 1;
            --scroll-position-delayed: 1;
          }
        }    
        
        .animation-element-wrapper {
            animation: adjust-pos linear both;
            animation-timeline: view(block);
    

    Here the scroll-position is delayed (responsible for the smoothness).

    .animation-element {
      transition: --scroll-position-delayed 0.15s linear;
    }
    

    Here the delayed scroll-position is used to animate the "red-square":

    .red-square {
      background-color: red;
      height: 50px;
      width: 50px;
      transform: translateY(calc(-150px * var(--scroll-position-delayed)));
    }