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 is one button whose front face is a hinged plate in 3D space. Tilt it back and whatever's underneath becomes visible. This page covers the hinge, the three things that tilt it, and the bar 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 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 depends on the other two; they're 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 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, so 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, and a bar that either counts up or shows 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 is one button whose front face is a hinged plate in 3D space. Tilt it back and whatever's underneath becomes visible. This page covers the hinge, the three things that tilt it, and the bar 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 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 depends on the other two; they're three
selectors pointed at one transform.
determinate: scaleX(progress / 100)
indeterminate: translateX(-100% → 250%)
- Determinate
- a
progressprop is passed - Indeterminate
- no
progress; a 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 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, so 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, and a bar that either counts up or shows 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. It sets
aria-valuenow when determinate and 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