overscroll_feedback
A Flutter widget that reveals custom feedback when users pull beyond a scroll edge.
It is useful for chat screens, message lists, feed pages, horizontal carousels, and any scrollable UI where you want to show a small "start reached" or "you reached the end" indicator, trigger haptic feedback, or run a callback when the user keeps pulling past the scroll limit.
Demo

Features
- Reveal a custom widget when the user pulls beyond the end of a list.
- Reveal feedback at the start, end, or both edges of the list.
- Supports vertical and horizontal scrolling.
- Release-after-hold detection for
ListView,SingleChildScrollView,CustomScrollView, and other Flutter scrollables. - Works with simple string lists, custom item types, or lazy builders.
- Progress-aware feedback builder for custom animations.
- Optional haptic feedback when the pull distance reaches a threshold.
- Custom haptic callback support.
- One-shot callback when the scroll limit is reached.
- Repeated hold callback while the user keeps pulling.
- One-shot callback when the user releases after reaching the hold threshold.
- Configurable reveal distance, haptic trigger distance, feedback size, and offset.
Installation
Add the package to your pubspec.yaml:
dependencies:
overscroll_feedback: ^0.0.4
Then run:
flutter pub get
Import it:
import 'package:overscroll_feedback/overscroll_feedback.dart';
Which API should I use?
Use the helper that matches your list:
| API | Best for |
|---|---|
OverscrollFeedback.strings |
Quick demos, settings pages, or simple text lists. |
OverscrollFeedback<T> |
Lists backed by your own model objects. |
OverscrollFeedback.builder |
Large, paged, or dynamic lists where items should be built lazily. |
ReachLimitListView |
Lower-level control when you want to provide every ListView option yourself. |
ReachLimitListener |
Release-after-hold detection around any Flutter scrollable. |
Quick start
Use OverscrollFeedback.strings when you want a simple list of text items.
OverscrollFeedback.strings(
messages: const [
'Welcome aboard',
'Pull gently',
'Keep scrolling',
'Now pull past the end',
],
reachLimitWidget: DecoratedBox(
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(999),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Text(
'You reached the end',
style: TextStyle(color: Colors.white),
),
),
),
onReachLimit: () {
debugPrint('Reached the bottom');
},
)
Start, end, or both edges
By default, feedback appears when the user pulls beyond the end of the list.
Use position when you want top/start feedback too.
OverscrollFeedback.strings(
position: OverscrollFeedbackPosition.both,
messages: const [
'First message',
'Second message',
'Third message',
],
startReachLimitWidget: const Text('Start of list'),
reachLimitWidget: const Text('End of list'),
)
For a top-only indicator:
OverscrollFeedback.strings(
position: OverscrollFeedbackPosition.start,
messages: const ['One', 'Two', 'Three'],
startReachLimitWidget: const Text('Nothing above this'),
)
Custom item type
Use the generic constructor when your list contains custom model objects.
class Message {
const Message({
required this.text,
required this.sender,
});
final String text;
final String sender;
}
final messages = [
const Message(text: 'Hello', sender: 'Me'),
const Message(text: 'Hi there', sender: 'Friend'),
];
OverscrollFeedback<Message>(
items: messages,
itemBuilder: (context, message, index) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Text('${message.sender}: ${message.text}'),
);
},
reachLimitWidget: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'No more messages',
style: TextStyle(color: Colors.white),
),
),
)
Lazy builder
Use OverscrollFeedback.builder for large or dynamic lists. It works like
Flutter's ListView.builder, so items are created only when needed.
OverscrollFeedback.builder(
itemCount: 1000,
itemBuilder: (context, index) {
return ListTile(
title: Text('Message $index'),
);
},
reachLimitWidget: const Text('No more messages'),
)
Animated feedback
Use reachLimitBuilder when your feedback should react to pull progress.
progress goes from 0.0 to 1.0.
OverscrollFeedback.builder(
itemCount: 30,
itemBuilder: (context, index) {
return ListTile(title: Text('Item $index'));
},
reachLimitBuilder: (context, progress) {
return Transform.scale(
scale: 0.8 + (progress * 0.2),
child: Opacity(
opacity: progress,
child: const Text('You reached the end'),
),
);
},
)
You can also animate the start indicator separately:
OverscrollFeedback.builder(
position: OverscrollFeedbackPosition.both,
itemCount: 30,
itemBuilder: (context, index) {
return ListTile(title: Text('Item $index'));
},
startReachLimitBuilder: (context, progress) {
return Opacity(
opacity: progress,
child: const Text('Start reached'),
);
},
reachLimitBuilder: (context, progress) {
return Transform.scale(
scale: 0.8 + (progress * 0.2),
child: const Text('End reached'),
);
},
)
Horizontal list
For horizontal lists, set isHorizontal: true.
A horizontal list needs a fixed height, so height is required when
isHorizontal is true.
OverscrollFeedback.strings(
isHorizontal: true,
height: 140,
messages: const [
'Card',
'Article',
'Story',
'Preview',
],
itemWidth: 180,
reachLimitWidget: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'End',
style: TextStyle(color: Colors.white),
),
),
)
Callbacks
onReachLimit
Called once when the user pulls far enough to reach the feedback trigger.
OverscrollFeedback.strings(
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('End reached'),
onReachLimit: () {
debugPrint('User reached the scroll limit');
},
)
onReachLimitHold
Called repeatedly while the user keeps holding/pulling beyond the limit.
OverscrollFeedback.strings(
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('Keep holding...'),
holdTriggerDistance: 140,
holdInterval: const Duration(milliseconds: 500),
onReachLimitHold: (pullDistance) {
debugPrint('Still pulling: $pullDistance');
},
)
onReachLimitHoldRelease
Called once when the pointer is released, but only if the pull reached
holdTriggerDistance during that gesture. Merely scrolling to the edge or
releasing below the threshold does not call it.
OverscrollFeedback.strings(
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('Release now'),
holdTriggerDistance: 140,
onReachLimitHoldRelease: (pullDistance) {
debugPrint('Released after reaching: $pullDistance');
},
)
Use with any scrollable
Wrap a SingleChildScrollView, CustomScrollView, or another widget that emits
Flutter ScrollNotifications with ReachLimitListener. The callback uses the
same reach-then-release behavior as OverscrollFeedback.
ReachLimitListener(
position: OverscrollFeedbackPosition.end,
holdTriggerDistance: 120,
onReachLimitHoldRelease: (pullDistance) {
debugPrint('Released at the end: $pullDistance');
},
child: CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
SliverList.builder(
itemCount: 20,
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
),
),
],
),
)
ReachLimitListener observes the nearest scrollable by default. Set
scrollDirection for horizontal content and position for start, end, or both
edges. The wrapped scrollable must use physics that permit overscroll; combining
BouncingScrollPhysics with AlwaysScrollableScrollPhysics also supports
content shorter than the viewport.
Custom haptic feedback
By default, the widget uses Flutter haptic vibration when the haptic trigger distance is reached.
You can provide your own haptic feedback callback:
OverscrollFeedback.strings(
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('Limit reached'),
hapticFeedback: (pullDistance) {
if (pullDistance > 180) {
return HapticFeedback.heavyImpact();
}
return HapticFeedback.selectionClick();
},
)
You can also disable haptics:
OverscrollFeedback.strings(
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('Limit reached'),
hapticsEnabled: false,
)
Common customization
These options are useful for tuning the feel of the interaction:
| Option | What it controls |
|---|---|
position |
Which edge can reveal feedback: start, end, or both. |
reachLimitExtent |
The space reserved for the revealed feedback widget. |
offset |
How far the feedback slides while it is being revealed. |
revealStartDistance |
Pull distance before feedback starts appearing. |
revealEndDistance |
Pull distance where feedback reaches full opacity. |
hapticTriggerDistance |
Pull distance where haptics and onReachLimit trigger. |
holdTriggerDistance |
Pull distance where repeated hold callbacks begin. |
holdInterval |
How often onReachLimitHold runs while the user keeps pulling. |
onReachLimitHoldRelease |
Runs once on release after the hold threshold was reached. |
Notes
This package listens to Flutter scroll notifications.
For the best effect on iOS-style bouncing scroll, keep the default
BouncingScrollPhysics. On platforms that normally clamp overscroll, the
package uses AlwaysScrollableScrollPhysics by default so short lists can still
be pulled. When using ReachLimitListener, configure the physics on its child
scrollable.
Tuning the reveal behavior
You can control when the feedback starts appearing, when it becomes fully visible, and when haptics/callbacks are triggered.
OverscrollFeedback.strings(
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('You reached the end'),
revealStartDistance: 32,
revealEndDistance: 120,
hapticTriggerDistance: 140,
)
Important values
| Property | Description |
|---|---|
revealStartDistance |
Pull distance where the feedback widget starts appearing. |
revealEndDistance |
Pull distance where the feedback widget becomes fully visible. |
hapticTriggerDistance |
Pull distance where haptic feedback and onReachLimit trigger. |
holdTriggerDistance |
Pull distance where the repeated hold callback starts. |
holdInterval |
How often onReachLimitHold is called while holding. |
onReachLimitHoldRelease |
Called once upon release after reaching the hold threshold. |
reachLimitExtent |
Space reserved for the revealed feedback widget. |
offset |
Starting offset animation for the revealed widget. |
hapticsEnabled |
Enables or disables built-in haptic feedback. |
hapticFeedback |
Custom haptic callback. |
Full example
import 'package:flutter/material.dart';
import 'package:overscroll_feedback/overscroll_feedback.dart';
class OverscrollDemoPage extends StatelessWidget {
const OverscrollDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: OverscrollFeedback.strings(
messages: List.generate(12, (index) => 'Message'),
revealStartDistance: 40,
revealEndDistance: 120,
hapticTriggerDistance: 135,
reachLimitWidget: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'You reached the end',
style: TextStyle(color: Colors.white),
),
),
onReachLimit: () {
debugPrint('Reached the list end');
},
onReachLimitHold: (pullDistance) {
debugPrint('Holding at distance: $pullDistance');
},
onReachLimitHoldRelease: (pullDistance) {
debugPrint('Released after holding: $pullDistance');
},
),
),
);
}
}
Notes
Haptics
Haptic feedback depends on the device, platform, and system settings.
For best testing, use a real device instead of a simulator or emulator. Some devices may ignore repeated vibration requests or make them feel weaker than expected.
Android vibration permission
If your custom haptic implementation uses a vibration plugin, you may need to add the Android vibration permission:
<uses-permission android:name="android.permission.VIBRATE"/>
Flutter's built-in HapticFeedback does not require extra Dart setup.
Horizontal lists
When using isHorizontal: true, provide a fixed height.
OverscrollFeedback.strings(
isHorizontal: true,
height: 160,
messages: const ['One', 'Two', 'Three'],
reachLimitWidget: const Text('End'),
)
Example app
A runnable example app is included in the example folder.
cd example
flutter run
API overview
OverscrollFeedback<T>
Generic widget for custom item types.
OverscrollFeedback<T>({
required List<T> items,
required ReachLimitItemBuilder<T> itemBuilder,
required Widget reachLimitWidget,
})
OverscrollFeedback.strings
Convenience constructor for simple string lists.
OverscrollFeedback.strings({
required List<String> messages,
required Widget reachLimitWidget,
})
ReachLimitListener
Scrollable-agnostic reach-and-release listener.
ReachLimitListener({
required Widget child,
required ReachLimitHoldReleaseCallback onReachLimitHoldRelease,
double holdTriggerDistance = 138,
OverscrollFeedbackPosition position = OverscrollFeedbackPosition.end,
Axis scrollDirection = Axis.vertical,
})
License
MIT