Flutter Web Scroll Animations
You have probably seen it on a fancy marketing site: you scroll down, the page stops moving, and instead of advancing, your scroll wheel…
Flutter Web Scroll Animations

You have probably seen it on a fancy marketing site: you scroll down, the page stops moving, and instead of advancing, your scroll wheel drives a hero animation.
The key insight is that the animation is driven by scroll position, not by time. There is no AnimationController, no curve, no "play" button. The user's scroll is the timeline. Scroll forward and the animation advances. Scroll back and it reverses.
This is what I want to build today, as a single drop in Sliver:
- pin its content while consuming a configurable amount of scroll
- expose a
progressvalue from0.0to1.0to drive any animation we want
The full widget is about a hundred lines of Dart and works inside any CustomScrollView. Let's walk through how it works.
The API we want
The widget takes a scroll distance and a builder that receives progress:
CustomScrollView(
slivers: [
SliverScrollJackingAnimation(
animationRange: 500,
builder: (context, progress) {
return Opacity(
opacity: progress,
child: Center(
child: Text('Progress: ${progress.toStringAsFixed(2)}'),
),
);
},
),
],
)
animationRange is the number of logical pixels the user has to scroll for progress to go from 0.0 to 1.0. builder is called every frame while the section is on screen, with the current progress value. That's the whole API. Everything else is implementation.
The widget itself is a StatelessWidget with two fields. The builder type is a typedef so the signature is easy to read at the call site:
typedef ScrollAnimationBuilder =
Widget Function(BuildContext context, double progress);
class SliverScrollJackingAnimation extends StatelessWidget {
const SliverScrollJackingAnimation({
required this.animationRange,
required this.builder,
super.key,
});
final double animationRange;
final ScrollAnimationBuilder builder;
@override
Widget build(BuildContext context) {
// shown below
}
}
Why a sliver, and why SliverPersistentHeader
The widget has to coexist with other slivers in a CustomScrollView that's the whole point of plugging into a marketing page or onboarding flow. So it has to be a sliver.
It also has to do two things that ordinary sliver widgets don’t do out of the box:
- Take up more scroll extent than its visible size. Showing a viewport-sized child but consuming
viewport + animationRangeof scroll is the trick that gives us pixels to map toprogress. - Pin its child to the top of the viewport while that extra scroll is being consumed.
Flutter already has a sliver that knows how to be pinned and report a shrinkOffset: SliverPersistentHeader. We don't want the "shrinking app bar" semantics, but we very much want the bookkeeping. So instead of writing a RenderSliver from scratch, we build on top of the existing one and supply a delegate that fakes a constant size:
class _ScrollAnimationDelegate extends SliverPersistentHeaderDelegate {
// ...
@override
double get maxExtent => size + scrollRange;
@override
double get minExtent => size + scrollRange;
}
Setting minExtent == maxExtent tells the framework: this header doesn't shrink, it has a fixed size of viewport + animationRange. We'll handle the visual pinning ourselves inside build. From the framework's point of view we're just a tall, non collapsing header.
The outer widget itself is small and just looks up the viewport size:
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.viewportMainAxisExtent;
return SliverPersistentHeader(
delegate: _ScrollAnimationDelegate(
size: viewportSize,
scrollRange: animationRange,
builder: builder,
),
);
},
);
}
SliverLayoutBuilder gives us viewportMainAxisExtent, which is exactly what we need to make the pinned area fill the screen — the same approach SliverFillViewport takes, just in our hands.
Turning shrinkOffset into progress
SliverPersistentHeaderDelegate.build is called with a shrinkOffset parameter the number of pixels the header has scrolled past, from 0.0 up to its maxExtent. We have two jobs:
- Map
shrinkOffsetto aprogressin[0, 1]. - Counter translate the child by the same offset so it visually stays at the top of the viewport.
Both jobs fit inside a single build method on the delegate:
class _ScrollAnimationDelegate extends SliverPersistentHeaderDelegate {
const _ScrollAnimationDelegate({
required this.size,
required this.scrollRange,
required this.builder,
});
final double size;
final double scrollRange;
final ScrollAnimationBuilder builder;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
final axisDirection =
Scrollable.maybeOf(context)?.axisDirection ?? AxisDirection.down;
final axis = axisDirectionToAxis(axisDirection);
final progress = scrollRange > 0
? (shrinkOffset / scrollRange).clamp(0.0, 1.0)
: 1.0;
final pinOffset = shrinkOffset.clamp(0.0, scrollRange);
return Stack(
fit: StackFit.expand,
children: [
PositionedDirectional(
top: axis == Axis.vertical ? pinOffset : 0,
bottom: axis == Axis.horizontal ? 0 : null,
start: axis == Axis.horizontal ? pinOffset : 0,
end: axis == Axis.vertical ? 0 : null,
child: SizedBox(
height: axis == Axis.vertical ? size : null,
width: axis == Axis.horizontal ? size : null,
child: builder(context, progress),
),
),
],
);
}
@override
double get maxExtent => size + scrollRange;
@override
double get minExtent => size + scrollRange;
@override
bool shouldRebuild(covariant _ScrollAnimationDelegate oldDelegate) {
return oldDelegate.size != size ||
oldDelegate.scrollRange != scrollRange ||
oldDelegate.builder != builder;
}
}
The progress and pinOffset lines are the entire mapping. Note that we clamp pinOffset to scrollRange, not to maxExtent. Once the user has scrolled past animationRange pixels we stop translating the child — the rest of maxExtent (the viewport-sized portion) is what allows the section to scroll off screen normally afterwards.
The Stack with PositionedDirectional is what does the visual pinning. The conditionals look fussy but they're only there because the same widget should work in horizontal scroll views too — the pin axis follows the scroll axis. In a normal vertical CustomScrollView, the only branch that matters is top: pinOffset with a fixed height: size.
shouldRebuild compares the three fields that actually drive layout. As long as size, scrollRange and builder stay the same, Flutter can reuse the existing render object and skip a relayout — which matters because this delegate's build runs on every scroll frame.
Scrolling is reversible. When the user scrolls down, shrinkOffset increases and progress goes from 0 to 1. When they scroll back up, shrinkOffset decreases and progress goes from 1 to 0. The animation simply plays backwards.
Direction awareness, as a freebie
I mentioned earlier that the widget works in both vertical and horizontal scroll views. That’s essentially free look back at the start of the delegate’s build method: a single Scrollable.maybeOf(context)?.axisDirection lookup is the entire mechanism. From there it's just a matter of which side the pin offset is applied to (top for vertical, start for horizontal) and which dimension is fixed (height vs. width). Reverse axes (AxisDirection.up, AxisDirection.left) are handled by PositionedDirectional we don't have to special case them.
If you end up dropping this into a project, I’d love to hear which animations you reach for first. 👋
메타데이터
- post_id
- 68b093c1e93b
- slug
- flutter-web-scroll-animations-68b093c1e93b
- url
- https://medium.com/@dragopicari98/flutter-web-scroll-animations-68b093c1e93b
- canonical_url
- https://medium.com/@dragopicari98/flutter-web-scroll-animations-68b093c1e93b
- author_url
- https://medium.com/@dragopicari98
- status
- ok
- fetched_at
- 2026-07-13 22:13:33