Tracking Your Reader's Journey
I recently came across a progress bar at the top of a blog page on Alexandru's website (kudos to him—it looks great). I thought this was incredibly helpful and wanted to implement the same feature on my own site, specifically using Jaspr. From a Flutter perspective, this seems like a straightforward widget to create, but how would that translate to another Dart framework?
Implementing the Progress Bar
First things first, we need to apply the proper CSS to our site. Import the Bulma CSS within the head of the
Document (located in main.server.dart). Bulma has a very sleek
Progress Bar component that makes it perfect for this use case.
head: [
link(
rel: 'stylesheet',
href: 'https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css'
),
],
Next, set up the ScrollProgressBar as a StatefulComponent, as its state will need to be updated as we scroll the page. Be sure to add the
@client annotation here as well, because we want this component to be interactive
rather than static. Finally, provide two properties for the ScrollProgressBarState:
scrollProgress and scrollListener.
import 'package:jaspr/dom.dart';
import 'package:jaspr/jaspr.dart';
import 'package:universal_web/web.dart' as web;
import 'package:universal_web/js_interop.dart';
/// Fixed progress bar that displays scroll progress at the top
class ScrollProgressBar extends StatefulComponent {
const ScrollProgressBar({super.key});
@override
State<ScrollProgressBar> createState() => ScrollProgressBarState();
}
class ScrollProgressBarState extends State<ScrollProgressBar> {
/// Current scroll progress as a decimal (0.0-1.0)
double scrollProgress = 0;
/// Scroll event listener
web.EventListener? _scrollListener;
}
Within the initState method, we first check if we're currently on the web (the client) to determine whether we need to create the
scrollListener. This component isn't necessary on the server side and could cause potential errors if a render is attempted there. If we are on the web, the
scrollListener is created and added as a listener for the current view.
@override
void initState() {
super.initState();
// Create the event listener
if (kIsWeb) {
_scrollListener = (web.Event event) {
_updateScrollProgress();
}.toJS;
// Add scroll listener after component mounts
web.window.addEventListener('scroll', _scrollListener);
}
}
We also need to dispose of the listener properly. If the event listener remains active throughout the site, it could cause problems such as memory leaks, multiple listener accumulation, and runtime errors.
@override
void dispose() {
// Clean up the listener when component is removed
if (_scrollListener != null) {
web.window.removeEventListener('scroll', _scrollListener);
}
super.dispose();
}
Now it's time for the meat and potatoes. The _updateScrollProgress method will be responsible for determining the
scrollProgress, which is used to show how complete the progress bar is. The process is as follows:
- Get
scrollTop- current scroll position in the document. - Calculate
docHeight- total height of the document. - Calculate
windowHeight- height of the visible viewport. - Calculate
scrollableHeight- total height minus the viewport. -
Calculate
progress- current scroll position divided by the total scrollable height.
Optional, but recommended: Adding a clamp check from 0 to 100 will ensure we always have a value between those two numbers.
/// Calculates and updates the scroll progress percentage.
void _updateScrollProgress() {
// Current vertical scroll position from the top of the document
final scrollTop = web.window.scrollY;
// Total height of the document including non-visible content
final docHeight = web.document.documentElement?.scrollHeight ?? 0;
// Height of the visible viewport
final windowHeight = web.window.innerHeight;
// Calculate the scrollable distance (total height minus viewport)
final scrollableHeight = docHeight - windowHeight;
// Calculate progress as a decimal (0.0 to 1.0)
final progress = scrollableHeight > 0 ?
(scrollTop / scrollableHeight) : 0.0;
// Clamp ensures the value stays within 0-1 bounds, preventing
// edge cases like elastic/bounce scrolling from exceeding the range
setState(() => scrollProgress = progress.clamp(0.0, 1.0));
}
With the scroll percentage now available, the progress bar can be rendered in the build method of the component. The following classes are provided via Bulma:
progress- general styling for the progress baris-warning- yellow color of the progress bar
The percentage is then passed to the value property in the attributes.
Note: I added some raw styling to override the rounded corners on the progress bar (having the ends of the progress bar line up with the edges of the screen was more aesthetically pleasing).
/// Renders a Bulma progress bar element
@override
Component build(BuildContext context) => progress(
classes: 'progress is-warning progress-fixed',
styles: Styles(raw: {'--bulma-progress-border-radius': '0'}),
attributes: {
'value': '${scrollProgress * 100}',
'max': '100',
},
[],
);
Since the progress bar should always stay at the top, creating this progress-fixed class will ensure it remains fixed at the start of the view.
/// Styles to fix the progress bar at the top of the viewport
@css
static List<StyleRule> get styles => [
css('.progress-fixed').styles(
position: Position.fixed(
top: 0.px,
left: 0.px,
),
zIndex: ZIndex(1000),
width: 100.vw,
height: 0.5.em,
margin: Spacing.zero,
),
];
Finally, I can add the ScrollProgressBar like any other component. Since I want this to appear on my blog page, I'll place it above the div that holds the blog content.
return div(classes: 'blog', [
ScrollProgressBar(),
div(classes: 'main-container', [
// ...blog page components
]),
])
Voilà! Progress Tracked
Running the application, we can see the progress bar updating its fullness as we scroll down the screen. It will also deplete when scrolling back up. I hope you found this article helpful, especially if you're just starting out with Jaspr. If you did, please share it!
[ SHOW THE SCROLL BAR IN USE ON THE BLOG PAGE ]
Coffee Break: Forster Wolf Latte
Quick shoutout to the Forster Wolf Latte from Bennu Coffee. At $8, it's definitely a splurge, but the rich chocolate-caramel espresso with perfectly integrated microfoam made it worth every penny. The kind of coffee that makes you slow down between debugging sessions and actually enjoy it.
8.5/10