The problem

Until fairly recently, the first thing you'd see when opening the Porter dashboard was a small grey spinner. You've seen it before, it's a fairly generic spinner half of all SaaS applications on the internet use for loading states, but it gets the job done.

In our case however, if you were paying close attention (or on a slow connection), you'd actually catch three loading states in a row: the spinner while the entire dashboard loaded, an empty state on the Applications page while your cluster loaded in, and then one more loading state for your apps, since apps on Porter are tied to a cluster and can't load until the cluster has.

SpinnerEmpty stateSpinner againApp
Approximate real timings. Slow motion is 4× slower.

While each loading state only lasted a moment, strung together, the first few seconds in the dashboard were a bit… jarring. Most people may just ignore this, given in an ideal world you're not opening the Porter dashboard often, but this UX is particularly bad for those users who open the dashboard many times a day.

Loading less

This should go without saying, but the best loader you can set up is one users never see (or, more realistically, one users rarely see). So before touching the loader itself, we changed how the dashboard loads. We set up the frame of the app to now paint in the initial HTML before any JavaScript runs, so most page loads go straight from that frame to the app itself with minimal CLS.

For cases where the page load needs to take longer, we replaced the three loading states with a brand-new loader (the star of this post!) which only fades in if loading takes longer than a second. A second is roughly the point where a wait starts to interrupt your train of thought. With this in mind, once our new loader shows up, it sticks around for at least 800ms (more on that number below), so the animation never gets cut off halfway through.

Load time
The loader fades in at 1s, holds until 2400ms, then fades out over 250ms.

This raises a question: if most people will never see the loader, why spend any real time on it? The way I see it is when this loader shows up, it is the first thing someone sees when they open Porter. If something has to be on screen while the dashboard boots, it may as well feel like Porter.

Bringing in the brand

Porter's brand is built on what we call the block system, which the very talented Adam Ho designed as part of our brand identity. It shows up all over our website, our online presence, and our onboarding flow, but once you were inside the dashboard, there was a lot less of Porter's visual identity to be found.

The block system's smallest piece is an atom: a single square, which comes in five variations depending on how large a square is punched out of its center.

P1
P2
P3
P4
P5

Porter's five atom classes, P1 to P5, from the block system by Adam Ho.

Atoms combine on a five-by-five grid to form primitives, and the "O" in the Porter logo is itself a primitive. A loader that cycles through primitives felt like the most natural way to bring that into the app.

Seven of Porter's primitives. Each is built from atoms on a 5×5 grid.

The first draft

The idea itself was fairly simple: cycle through a set of primitives, swapping in a new one on a specific cadence. Every primitive is made of the same building block, the atom. I wanted each one to build itself out of the last, atom by atom, as if it were being taken apart and reassembled. Each atom simply switches on or off as it lands, which fits the pixel-y feel of the brand and mirrors the hard-cut logo animation on our website.

My very first version ran on a cadence where a switch occurred every 600ms. By the end of that same afternoon, I'd slowed it down to 800ms, and added a quarter turn every time the primitive changed. This was the version I ended up sharing with the team.

Rotation

The draft I shared with the team. Toggle the rotation off to compare.

I asked the team about three things in particular:

  1. The pause between each primitive
  2. The speed of the animation itself
  3. Whether the animation as a whole felt too jarring

The speed, as it turned out, felt right to folks. What I hadn't thought to ask about was the rotation. I'd also shown the team an earlier version without it, and everyone preferred that one to the version you see above. In hindsight, it makes sense: with the primitives turning on every change, you never really get a chance to look at them and there's a lot going on.

I also had another note which was the most interesting, seeing as it led to a whole new rabbit hole: could the transition between primitives be slower, but without increasing the time between each change?

Ripple vs. beat

Funnily enough, this was possible because the loader was already built on two separate constants. The beat (cycleMs) is how often a new primitive starts. And the ripple (rippleMs) is the window of time in which every atom snaps into place, each one landing at its own random moment. Put simply, the ripple always has to fit inside the beat. Widening it makes each change slower, but the next primitive still starts on time, so the extra time comes out of the pause after the last atom lands.

We compared three ripples on the same 800ms beat: the original 240ms, a 15% slower 276ms, and a 30% slower 312ms. Here are all three, side by side.

240msoriginal
276msshipped
312ms30% slower

Each line is one 800ms beat. Each tick is an atom landing.

At 312ms, a couple of the primitives started to feel a tad too slow, and the pause between changes almost disappeared. 276ms was where each change felt more deliberate while still leaving a clear pause before the next one, so that's what we shipped.

The shipped loader: an 800ms beat, a 276ms ripple, and no rotation.

Outcome

Ultimately, success for us still means most people never see this loader. This exercise was in some ways about planning for the worst-case scenario rather than the best one. That way, whoever does end up seeing it gets something that actually feels like Porter.

Made in Brooklyn, New York.

Last updated October 30, 2026.