This post is about the progress bar that shows at the top of the cover image 🤓
This is a follow-up post
If you haven't read the first post, go check it out: Add a Global Progress indicator to your Remix app
Intro
Now that we know how to create a global progress indicator in our Remix apps we want to get a little fancy.
Creating a progress bar with actual download/upload percentage can be quite tricky. But with just a few adjustments in our GlobalLoading
component, leveraging the possible states of navigation.state
we can achieve a much better UX.
Start by styling it properly
Change the returning JSX of the component on the previous post.
<div
role="progressbar"
aria-hidden={!active}
aria-valuetext={active ? "Loading" : undefined}
className="fixed inset-x-0 top-0 z-50 h-1 animate-pulse"
>
<div
className={cx(
"h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all duration-500 ease-in-out",
active ? "w-full" : "w-0 opacity-0 transition-none"
)}
/>
</div>
We changed a little bit, we are not going to be using that spinner SVG anymore, now we just need a div
with some style in our progress bar container. The main changes are:
-
fixed inset-x-0 top-0
: we are positioning the container at the top. -
animate-pulse
: from tailwind to give the bar another touch of "looking busy"
And now the transition classes transition-all duration-500 ease-in-out
are placed on the child div
because that is what we are going to be animating.
It should now be looking like the following:
The problem is the timing of the animation (500ms) does not follow the timing of the request/response and the animation is linear. We want to add a few stops on the way so it feels more like an actual progress bar.
Introducing navigation.state
Other than the "idle"
, there are couple more states we can be aiming for so the progress bar will actually feel like "progressing". By just changing the code a little bit we already add a step on the way:
<div role="progressbar" {...}>
<div
className={cx(
"h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all duration-500 ease-in-out",
navigation.state === "idle" && "w-0 opacity-0 transition-none",
navigation.state === "submitting" && "w-1/2",
navigation.state === "loading" && "w-full"
)}
/>
</div>
When the network is idle, the progress bar has a width of 0 and is transparent. We also add transition-none
at this stage so the bar doesn't animate back from w-full
to w-0
.
When there's some sort of form submission, the bar will animate from w-0
to w-1/2
in 500ms and when the loaders are revalidating it will transition from w-1/2
to w-full
.
Now the bar animates from w-0
to w-full
when only a loader is dispatched and will stop in the middle of the way if we are sending data to the server! Again, Remix is here for us!
I wish there was the 4th step
I'd like the progress bar to stop in 2 places though, so it feels more like Github's. The problem is we don't have an extra state in navigation.
What I really want to tell the computer is:
- during the request animate from 0 to 25%-ish
- during the response animate till 75%-ish
- when going idle again quickly go all the way to 100% and disappear. 🤔
Yes, this can be done, we just need to manufacture that last step!
I'll call this variable animationComplete
and show how to use it, later I'll show how to define it:
<div
className={cx(
"h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all duration-500 ease-in-out",
navigation.state === "idle" &&
animationComplete &&
"w-0 opacity-0 transition-none",
navigation.state === "submitting" && "w-4/12",
navigation.state === "loading" && "w-10/12",
navigation.state === "idle" && !animationComplete && "w-full"
)}
/>
Ok, how are we gonna do this?
There is an API for DOM elements called Element.getAnimations
that can be mapped to return an array of promises that will be settled when the animations are finished!
Promise.allSettled(
someDOMElement
.getAnimations()
.map((animation) => animation.finished)
).then(() => console.log('All animations are done!')
With a little ref
from my friend React to get the DOM element and some React state we can get the job done! Here's the updated code for the component:
import * as React from "react";
import { useNavigation } from "@remix-run/react";
import { cx } from "~/utils";
function GlobalLoading() {
const navigation = useNavigation();
const active = navigation.state !== "idle";
const ref = React.useRef<HTMLDivElement>(null);
const [animationComplete, setAnimationComplete] = React.useState(true);
React.useEffect(() => {
if (!ref.current) return;
if (active) setAnimationComplete(false);
Promise.allSettled(
ref.current.getAnimations().map(({ finished }) => finished)
).then(() => !active && setAnimationComplete(true));
}, [active]);
return (
<div role="progressbar" {...}>
<div ref={ref} {...} />
</div>
);
}
export { GlobalLoading };
Understanding the important parts
We already had the first 2 lines defining navigation
and active
. We now added:
- The
useRef
to store the DOM element of the innerdiv
- A definition of the
animationComplete
state - A
useEffect
that will run whenever theactive
state of the navigation changes fromidle
and back. In this effect we:- set the animationCompleted state to
false
to start - wait for all the animations of the
ref
element to be completed so we can setanimationCompleted
back totrue
. This only happens ifnavigation.state
isidle
again.
- set the animationCompleted state to
That's it! Now we have our progress bar in 4 steps with just a bit of code:
The final code
import * as React from "react";
import { useNavigation } from "@remix-run/react";
import { cx } from "~/utils";
function GlobalLoading() {
const navigation = useNavigation();
const active = navigation.state !== "idle";
const ref = React.useRef<HTMLDivElement>(null);
const [animationComplete, setAnimationComplete] = React.useState(true);
React.useEffect(() => {
if (!ref.current) return;
if (active) setAnimationComplete(false);
Promise.allSettled(
ref.current.getAnimations().map(({ finished }) => finished)
).then(() => !active && setAnimationComplete(true));
}, [active]);
return (
<div
role="progressbar"
aria-hidden={!active}
aria-valuetext={active ? "Loading" : undefined}
className="fixed inset-x-0 top-0 left-0 z-50 h-1 animate-pulse"
>
<div
ref={ref}
className={cx(
"h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all duration-500 ease-in-out",
navigation.state === "idle" &&
animationComplete &&
"w-0 opacity-0 transition-none",
navigation.state === "submitting" && "w-4/12",
navigation.state === "loading" && "w-10/12",
navigation.state === "idle" && !animationComplete && "w-full"
)}
/>
</div>
);
}
export { GlobalLoading };
I hope you've found these 2 posts useful! I'd love to know if you happen to add this code to your project or even evolve it or come up with better solutions. Do let me know 😉
PS: To see the full code for both posts, check out this pull request.
Top comments (2)
Really cool, thanks for this! I switched it from animating the width to using transform with a negative translate-x. Some maths was necessary to use the correct values compared to your width animation. That should be a tad smoother since it does not need layout calculation. It also of course shifts the whole bar from out of view into view. So when using a gradient, that might result in a different look. Hopefully that happens so fast that it is barely noticeable anyways 😅
Edit: One annoying thing I ran into was that Jest complained about the requestAnimationFrame. I ended up just mocking the GlobalLoading component for the tests instead of using weird workarounds. It is not critical for the tests to succeed.
This is really helpful. Thank you!