Add a Global Progress indicator to your Remix app

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

This post is about the spinner at the bottom left corner of the cover image 🤓

Disclaimer: there's a follow-up post for this one

Check it out later: Creating a Github-like Progress Bar for your Remix app

Intro

At Seasoned we are always looking for ways to deliver better UX/DX with less code and that is one of the main reasons we've been investing a lot in Remix.

One very nice recent addition to our Remix apps was a global progress indicator. By leveraging Remix's Form and Fetchers we have a single source of truth for any roundtrip the browser may be doing to the server.

Therefore, by just dropping a little component at our app/root.tsx file we can show a subtle progress indicator - I'm not talking about those obtrusive spinners the "SPA era" got us so used to, but something more similar to the browser's native tab spinner - whenever the page is loading/refreshing.

Browser native spinner

Got it, show me the code!

Preparation

I bootstrapped a new Remix app using the Indie Stack which already has tailwind installed as we are going to use it in this exercise.

After creating the repo, I added our cx utility function for composing classes and also created a sleep helper so I can better test the spinners locally:



// app/utils.ts
const sleep = (time: number) =>
  new Promise((resolve) => setTimeout(resolve, time));

export { sleep }


Enter fullscreen mode Exit fullscreen mode

Using the sleep method in places I want to simulate a slow backend activity:



// Add this to any loaders and actions
export async function loader({ request }: LoaderArgs) {
  await sleep(1000);
  return json({
    // ..
  });
}

export async function action({ request }: ActionArgs) {
  await sleep(1000);
  return json({
    // ..
  });
}


Enter fullscreen mode Exit fullscreen mode

The GlobalLoading component

I'm just going to add the component code here and explain the important parts later:



import { useNavigation } from "@remix-run/react";
import { cx } from "~/utils";

function GlobalLoading() {
  const navigation = useNavigation();
  const active = navigation.state !== "idle";

  return (
    <div
      role="progressbar"
      aria-valuetext={active ? "Loading" : undefined}
      aria-hidden={!active}
      className={cx(
        "pointer-events-none fixed left-0 bottom-0 z-50 p-4 transition-all duration-500 ease-out",
        active ? "translate-y-0" : "translate-y-full"
      )}
    >
      <svg
        className="h-7 w-7 animate-spin"
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        width="1em"
        height="1em"
      >
        <circle
          className="stroke-blue-600/25"
          cx={12}
          cy={12}
          r={10}
          strokeWidth={4}
        />
        <path
          className="fill-blue-600"
          d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
        />
      </svg>
    </div>
  );
}

export { GlobalLoading };


Enter fullscreen mode Exit fullscreen mode

The Spinner

First of all, the SVG is the "official" Tailwind spinner from their docs, nothing special about it.

The active state

This is easy. Thanks to Remix and their useNavigation hook, we know that whenever the navigation.state !== 'idle' that means there's some loader/action validating.

aria-* stuff

We added role="progressbar", aria-valuetext and aria-hidden with values based on the active state so we can bring some semantics to any a11y device that may be screening our app.

The Tailwind classes

The core of the component's functionalities lies in tailwind classes.



className={cx(
  "pointer-events-none fixed left-0 bottom-0 z-50 p-4 transition-all duration-500 ease-out",
  active ? "translate-y-0" : "translate-y-full"
)}


Enter fullscreen mode Exit fullscreen mode

Of course, we don't really need TW for this, but I'm going to assume you know a little of it:

  • pointer-events-none fixed left-0 bottom-0 z-50: we want it to be on the bottom-left corner of the app, floating above any content but not blocking any mouse/touch interaction with the rest of the page.
  • transition-all duration-500 ease-out: to animate the entrance and disappearance of the spinner.
  • active ? "translate-y-0" : "translate-y-full": when the spinner is active it shows up at its original position, otherwise it moves down in the Y axis at a distance equal to the size of the spinner container

Et voilá

Now we just need to import and add this component once and that's it:



// app/root.tsx
import { GlobalLoading } from "./components/global-loading";

export default function App() {
  return (
    <html lang="en" className="h-full">
      <head />
      <body className="h-full">
        <GlobalLoading />
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}


Enter fullscreen mode Exit fullscreen mode

This component is very simple yet it gives a little boost to the overall UX of your app. We don't ever need to think about it again as we know that even if we forget or don't have time to do progressive enhancement in every feature of our app, our foundational UX is solid.

That is exactly what we love about Remix.

We can dive deeper

In the next post I expand on this subject so we can create a progress bar similar to Github's:
Github-like progress bar

Check it out

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