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)));
}