MynduiMyndui
0

Anatomy of the Progress Fold Button

How a 3D hinge, three triggers, and a rAF-armed transition turn one button into a folding progress indicator.

01/03The hinge

idle — rotateX(0deg)

hover / focus — rotateX(35deg)

loading — rotateX(35deg)

Front face
hinged at the top, folds back
Back layer
tint always there, bar while loading
tsx
const FRONT_BASE ="relative grid w-full place-items-center rounded-[inherit] " +"[transform:rotateX(0deg)] [transform-origin:top_center] " +"[transition:transform_0.2s] " +"group-hover:[transform:rotateX(35deg)] " +"group-focus-visible:[transform:rotateX(35deg)] " +"group-data-[status=loading]:[transform:rotateX(35deg)]";
01

The hinge

The fold isn't a separate open/closed component swapped in on hover — it's one button whose front face is a hinged plate in 3D space. Tilt it back and whatever's underneath becomes visible. Here's the hinge, the three things that tilt it, and the bar that lives behind it.

The button sits in a perspective(800px) space. Its front face is a single element with transform-origin: top center, so rotating it on the X axis tips the bottom edge away from the viewer while the top stays pinned — like a flap hinged at the top:

Behind that face, at -z-[1], sits a back layer: a tint that's always there, plus a progress bar that's scaleX(0) until loading starts.

Three completely different triggers — a mouse hovering, a keyboard user tabbing in, a status prop flipping to "loading" — all resolve to the same rotateX(35deg). None of them know or care which of the other two is also true; they're just three selectors pointed at one transform.

02/03The bar behind the fold

determinate — scaleX(progress / 100)

indeterminate — translateX(-100% → 250%)

Determinate
a progress prop is passed
Indeterminate
no progress — fixed segment sweeps
tsx
const BAR_BASE ="absolute top-0 left-0 h-full w-full origin-left [transform:scaleX(0)] " +"group-data-[determinate=true]:[transform:scaleX(var(--progress-fold-fill,0))] " +"group-[&[data-status=loading]:not([data-determinate=true])]:w-[40%] " +"group-[&[data-status=loading]:not([data-determinate=true])]:animate-progress-fold-indeterminate";
02

The bar behind the fold

Once the face is tilted, the back layer is doing one of two things. If you pass a progress number, the bar is determinate — it scaleXs from the left to that fraction. If you don't, it's indeterminate — a fixed 40%-wide segment sweeps across on a loop:

css
 
@keyframes progress-fold-indeterminate {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(250%); }
}

Both modes stay on the compositor: determinate is scaleX against a unitless CSS variable (progress / 100), indeterminate is a fixed-width segment translating left→right. No layout property moves.

Determinate progress can't just start transitioning scaleX the instant status becomes "loading" — the CSS variable would already be set to the target fraction, and the bar would jump there with no fill animation at all. The fix is a two-step state: snap the fill to 0 synchronously, then arm the transition one frame later with requestAnimationFrame:

tsx
 
const [armed, setArmed] = React.useState(false);
React.useEffect(() => {
  if (!isLoading) {
    setArmed(false);
    return;
  }
  setArmed(false);
  const id = requestAnimationFrame(() => setArmed(true));
  return () => cancelAnimationFrame(id);
}, [isLoading]);
tsx
 
"group-[&[data-determinate=true][data-armed=true]]:[transition:transform_0.25s_ease]";

Without data-armed, the transition rule is live from the first paint and the browser has nothing to animate from — the bar would render already full. With it, the first frame paints at scaleX(0) untransitioned, and only the second frame turns the transition on, so there's always a "from" state to fill away from.

03/03Result
03

The result

One hinge, three triggers, a bar that's either counting up or just letting you know it's still working. Click it.

Accessibility

The button carries aria-busy while loading, plus a visually hidden role="progressbar" that only exists during that state — aria-valuenow when determinate, a plain "Loading" label when not:

tsx
 
{isLoading ? (
  <span
    className="sr-only"
    role="progressbar"
    aria-valuemin={0}
    aria-valuemax={100}
    aria-valuenow={isDeterminate ? clamped : undefined}
    aria-valuetext={isDeterminate ? `${clamped}%` : "Loading"}
  />
) : null}

prefers-reduced-motion drops both motions to a hard cut: the fold's transition: none and the indeterminate sweep's animate-none mean the face still moves to 35deg and the bar still shows progress, just without the tween or the endless sweep.

Motion Score

Progress Fold ButtonSSCompositor-only
SrotateXFront-face hinge fold on hover / focus / loading
SscaleXDeterminate fill from the left (0→progress)
StranslateXIndeterminate 40% segment sweep loop
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →