Loading craft...
Loading craft...
Pure CSS scroll-driven animations that respond to scroll position without any JavaScript. Features progressive blur indicators and SVG stroke drawing, all powered by modern CSS features like animation-timeline, animation-range, CSS masking, and @property.
I came across Soren's post about animation-timeline and thought it was the perfect opportunity to dive deep into modern CSS scroll-driven animations. What started as curiosity about a single CSS property turned into a deep exploration of:
mask-compositeanimation-timeline: scroll() links animations to scroll progressanimation-range lets you control exactly when animations play during scrollanimation-fill-mode controls animation state before and after the timeline@property makes CSS custom properties animatable with proper syntax definitionsNo JavaScript, no event listeners, no scroll event handlers. Just pure CSS doing what it does best.
The progressive blur effect uses CSS masking with three layers:
Here's how the masking works:
1mask-image:2linear-gradient(to top, transparent, var(--foreground) 90%),3linear-gradient(to bottom, transparent 0%, var(--foreground) 100%),4linear-gradient(var(--background), var(--background));56mask-size:7100% var(--top-mask-height),8100% var(--bottom-mask-height),9100% 100%;1011mask-composite: exclude;
The mask-composite: exclude is key - it subtracts the gradient areas from the solid mask, creating the blur zones. Think of it like cutting holes in a piece of paper where you want the gradients to show through.
To animate the mask heights, we need to register them as animatable custom properties:
1@property --top-mask-height {2syntax: '<length>';3inherits: true;4initial-value: 0px;5}67@property --bottom-mask-height {8syntax: '<length>';9inherits: true;10initial-value: 80px;11}
Without @property, CSS custom properties can't be animated because the browser doesn't know their type. The syntax field tells the browser "this is a length value, so you can interpolate it smoothly." This is crucial when animating CSS variables - without @property, they're treated as strings and can't be interpolated.
This is where animation-timeline shines:
1animation-name: show-top-mask, hide-bottom-mask;2animation-timeline: scroll(self), scroll(self);3animation-range: 0 var(--scroll-buffer), calc(100% - var(--scroll-buffer)) 100%;4animation-fill-mode: both;
animation-timeline: scroll(self) - Links each animation to the element's own scroll progress. You can also use scroll() to watch the nearest ancestor scrollbar, or scroll(nearest) to find the nearest scroll container.
animation-range - Controls when each animation plays during the scroll. It accepts several value types:
normal (default) - Animates for the entire scroll range0% 50% means animate from start to 50% of scrollentry, exit, entry-crossing, exit-crossing, cover0px 200px animates from 0px to 200px of scrollentry 10%, exit-crossing 50%, etc.In our example:
0 to var(--scroll-buffer) (first 2rem of scroll)calc(100% - var(--scroll-buffer)) to 100% (last 2rem of scroll)animation-fill-mode: both - Keeps the animation state before and after the timeline. Can also be:
none - No styles applied before/afterforwards - Keeps final keyframe state after animationbackwards - Applies first keyframe state before animation startsboth - Applies both forwards and backwards (most useful for scroll animations)The animation-range is brilliant - instead of animating for the full scroll, you can specify exactly when during the scroll journey each animation should happen. Combined with animation-fill-mode: both, the styles persist even when you're not actively scrolling through that range.
For the SVG stroke effect, we animate the stroke-dashoffset property:
1@property --draw-progress {2syntax: '<number>';3inherits: true;4initial-value: 101;5}67.svg-stroke-demo-content {8animation-name: draw-stroke;9animation-timeline: scroll(self);10animation-range: normal;11animation-fill-mode: both;12}1314.svg-stroke-demo-content .draw-path {15stroke-dasharray: var(--path-length);16stroke-dashoffset: var(--draw-progress);17}1819@keyframes draw-stroke {20100% {21 --draw-progress: 0;22}23}
The stroke-dasharray creates a dashed stroke the length of the entire path, and stroke-dashoffset shifts it. By animating from 101 (hidden) to 0 (fully visible), the stroke "draws" itself as you scroll. Notice we use animation-range: normal here, which means the animation plays for the entire scroll range - perfect for a continuous drawing effect.
@property is essential for animating CSS variables
Without it, custom properties are just strings. With it, they become first-class animatable values with proper interpolation.
Masking is layers, not just one gradient
You can stack multiple masks and use mask-composite to combine them in powerful ways. It's like Photoshop layers in CSS.
animation-range gives you precision control
Instead of "animate during scroll," you can say "animate from 10% to 30% of scroll" or use named ranges like entry and exit. This lets you create complex, choreographed scroll experiences where different animations play at different scroll positions.
animation-fill-mode keeps your styles persistent
With both, the animation state persists before and after the scroll range. This means your masked elements stay masked even when you're not actively scrolling through the animation range - essential for scroll indicators.
scroll(self) vs scroll()
scroll(self) watches the element's own scrollbar, while scroll() watches the nearest ancestor scrollbar. Choose based on what you're trying to achieve.
These features require modern browsers with support for:
@property (Chrome 85+, Edge 85+, Safari 16.4+)animation-timeline (Chrome 115+, Edge 115+, Safari 17.4+)For older browsers, the content will still be visible without the scroll effects - progressive enhancement at work.
Inspired by Soren's exploration of animation-timeline and the amazing Scroll-driven Animations site.