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.
↓Scroll to step through it
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.
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:
@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:
const [armed, setArmed] = React.useState(false);
React.useEffect(() => {
if (!isLoading) {
setArmed(false);
return;
}
setArmed(false);
const id = requestAnimationFrame(() => setArmed(true));
return () => cancelAnimationFrame(id);
}, [isLoading]);"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.
The result
One hinge, three triggers, a bar that's either counting up or just letting you know it's still working. Click it.
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
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)]";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.
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
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)]";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.
determinate — scaleX(progress / 100)
indeterminate — translateX(-100% → 250%)
- Determinate
- a
progressprop is passed - Indeterminate
- no
progress— fixed segment sweeps
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";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:
@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:
const [armed, setArmed] = React.useState(false);
React.useEffect(() => {
if (!isLoading) {
setArmed(false);
return;
}
setArmed(false);
const id = requestAnimationFrame(() => setArmed(true));
return () => cancelAnimationFrame(id);
}, [isLoading]);"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.
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:
{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
rotateXFront-face hinge fold on hover / focus / loadingscaleXDeterminate fill from the left (0→progress)translateXIndeterminate 40% segment sweep loop