Creating a Github-like Progress bar for your Remix app

Gustavo Guichard (Guga) - Aug 4 '22 - - Dev Community

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>


Enter fullscreen mode Exit fullscreen mode

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:
step 1

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>


Enter fullscreen mode Exit fullscreen mode

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.

It already looks quite cool:
step 2

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"
  )}
/>


Enter fullscreen mode Exit fullscreen mode

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!')


Enter fullscreen mode Exit fullscreen mode

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 };


Enter fullscreen mode Exit fullscreen mode

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 inner div
  • A definition of the animationComplete state
  • A useEffect that will run whenever the active state of the navigation changes from idle 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 set animationCompleted back to true. This only happens if navigation.state is idle again.

That's it! Now we have our progress bar in 4 steps with just a bit of code:
final step

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 };


Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . .