Scroll-linked animation
Animate elements based on scroll position with damped physics. This example demonstrates the scrolled and ticked services, the map / damp / transform utilities, and manual service toggling.
What we're building
A three-column image grid where columns skew based on scroll velocity and alternate columns move at different parallax speeds. The animation feels smooth thanks to damped interpolation.
HTML markup
A grid of images split into three columns. Each column is a cols[] ref (the [] suffix creates an array ref). One image is also marked as offsetItem to measure the parallax offset:
<div
data-component="ScrollLinkedAnimation"
class="grid gap-10 grid-cols-3 p-10 my-10">
<div data-ref="cols[]" class="grid gap-10">
<img
src="https://picsum.photos/seed/01/400/500"
alt=""
data-ref="offsetItem" />
<img src="https://picsum.photos/seed/02/400/500" alt="" />
<!-- more images... -->
</div>
<div data-ref="cols[]" class="grid gap-10">
<img src="https://picsum.photos/seed/11/400/500" alt="" />
<!-- more images... -->
</div>
<div data-ref="cols[]" class="grid gap-10">
<img src="https://picsum.photos/seed/21/400/500" alt="" />
<!-- more images... -->
</div>
</div>JavaScript component
The component uses scrolled() to capture scroll progress and delta, then ticked() to apply damped transforms on each animation frame. Services are toggled manually for performance — ticked is only enabled while scrolling:
import { Base } from '@studiometa/js-toolkit';
import { map, damp, transform } from '@studiometa/js-toolkit/utils';
export default class ScrollLinkedAnimation extends Base {
static config = {
name: 'ScrollLinkedAnimation',
refs: ['cols[]', 'offsetItem'],
};
scrollDeltaY = 0;
dampedScrollDeltaY = 0;
scrollProgressY = 0;
dampedScrollProgressY = 0;
parallaxOffsetHeight = 100;
mounted() {
this.parallaxOffsetHeight = this.$refs.offsetItem.offsetHeight / 2;
}
resized() {
this.parallaxOffsetHeight = this.$refs.offsetItem.offsetHeight / 2;
}
scrolled(props) {
if (props.changed.y && !this.$services.has('ticked')) {
this.$services.enable('ticked');
}
this.scrollProgressY = props.progress.y;
this.scrollDeltaY = props.delta.y;
}
ticked() {
this.dampedScrollDeltaY = damp(
this.scrollDeltaY,
this.dampedScrollDeltaY,
0.05,
0.0001,
);
this.dampedScrollProgressY = damp(
this.scrollProgressY,
this.dampedScrollProgressY,
0.25,
0.0001,
);
const items = this.$refs.cols.map((col, index) => {
const skewY =
index % 2
? this.dampedScrollDeltaY * -0.25
: this.dampedScrollDeltaY * 0.25;
const translateY =
index % 2 === 0
? map(
this.dampedScrollProgressY,
0,
1,
this.parallaxOffsetHeight,
-this.parallaxOffsetHeight,
)
: undefined;
return { col, skewY, translateY };
});
if (this.dampedScrollDeltaY === this.scrollDeltaY) {
this.$services.disable('ticked');
}
// Return a function to batch DOM writes (avoids layout thrashing)
return () => {
items.forEach((item) => {
transform(item.col, { skewY: item.skewY, y: item.translateY });
});
};
}
}App setup
import { registerComponent } from '@studiometa/js-toolkit';
import ScrollLinkedAnimation from './ScrollLinkedAnimation.js';
registerComponent(ScrollLinkedAnimation);Live demo
Pictures from picsum.photos
Further reading
- Services guide — how
scrolledandtickedhooks work scrolledservice — scroll service reference- Utils:
transform— apply CSS transforms from JS - Utils:
damp— damped interpolation for smooth animations - Utils:
map— remap a value from one range to another