CSS Progress Button
A button that animates a fill on click using a checkbox and transform: scaleX(), a state machine built entirely in CSS.
A CSS progress button turns one click into a timed sequence: the label changes, a fill wipes across the button, and a final state appears at the end. A checkbox holds the state, :checked starts everything, and animation-delay decides what happens when. The fill itself is a pseudo-element going from scaleX(0) to scaleX(1) off a left origin, which the browser can run on the compositor without touching layout.
The sequence is choreography, not measurement. Nothing in CSS can see a network request, so this button takes two seconds whether the work behind it takes fifty milliseconds or never finishes at all. That distinction decides where the pattern belongs and where it lies, which is covered below along with two versions that handle it differently: one that reports a real percentage from a registered property, and one that stays indeterminate because it has nothing honest to report.
Click to run the sequence
HTML
<div class="prog-stack">
<div class="prog-group">
<input type="checkbox" id="prog-1" class="prog-input">
<label for="prog-1" class="prog-btn">
<span class="prog-idle">▶ Run build</span>
<span class="prog-loading">Building…</span>
<span class="prog-done">✓ Complete</span>
</label>
<span class="prog-hint">click to start</span>
</div>
<div class="prog-group">
<input type="checkbox" id="prog-2" class="prog-input">
<label for="prog-2" class="prog-btn">
<span class="prog-idle">↑ Deploy</span>
<span class="prog-loading">Deploying…</span>
<span class="prog-done">✓ Live</span>
</label>
<span class="prog-hint">click to start</span>
</div>
</div>
CSS
.prog-stack {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
justify-content: center;
}
.prog-group {
display: flex;
flex-direction: column;
align-items: center;
gap: .5rem;
}
.prog-input {
display: none;
}
.prog-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: .75rem 2.25rem;
border-radius: 999px;
border: 1.5px solid #b8ff57;
background: rgba(184, 255, 87, .1);
color: #b8ff57;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .8rem;
font-weight: 500;
letter-spacing: .05em;
cursor: pointer;
overflow: hidden;
user-select: none;
min-width: 160px;
transition: color .3s;
}
/* The fill, wiped in from the left with transform: scaleX() */
.prog-btn::before {
content: "";
position: absolute;
inset: 0;
background: #b8ff57;
transform: scaleX(0);
transform-origin: left;
}
/* Idle stays in flow so it gives the button its height.
The other two labels sit on top of it. */
.prog-idle {
position: relative;
z-index: 1;
white-space: nowrap;
transition: opacity .2s;
opacity: 1;
}
.prog-loading,
.prog-done {
position: absolute;
z-index: 1;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
transition: opacity .2s;
opacity: 0;
}
.prog-hint {
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .65rem;
color: #88888f;
letter-spacing: .05em;
}
/* Checked is the whole state machine. Delays do the sequencing. */
.prog-input:checked ~ .prog-btn {
color: #0c0c0d;
}
.prog-input:checked ~ .prog-btn::before {
animation: prog-fill 2s ease-in-out forwards;
}
.prog-input:checked ~ .prog-btn .prog-idle {
animation: prog-fade-out .2s forwards;
}
.prog-input:checked ~ .prog-btn .prog-loading {
animation: prog-fade-in .2s forwards, prog-fade-out .2s 1.8s forwards;
}
.prog-input:checked ~ .prog-btn .prog-done {
animation: prog-fade-in .2s 2s forwards;
}
@keyframes prog-fill {
0% { transform: scaleX(0); }
80% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@keyframes prog-fade-out {
to { opacity: 0; }
}
@keyframes prog-fade-in {
to { opacity: 1; }
}
Other ways to build it
A determinate bar that reports a number
One registered integer drives everything here. @property gives --pb-pct a type so it can be interpolated, inherits: true lets the children read the animated value, and a single animation on the button feeds both the counter that prints the percentage and the scaleX on the bar. They cannot disagree, because there is only one number. The bar sits along the bottom edge rather than behind the text, which is what keeps the label readable throughout instead of leaving it stranded on an unfilled background. linear is deliberate: an eased percentage reads as a stalled upload.
HTML
<input type="checkbox" id="upload" class="pb-hidden-input">
<label for="upload" class="pb-meter-btn">
<span>Upload files</span>
<span class="pb-meter-pct"></span>
<span class="pb-meter-track"><span class="pb-meter-fill"></span></span>
</label>
CSS
/* inherits: true, so both children read the same animated value */
@property --pb-pct {
syntax: "<integer>";
initial-value: 0;
inherits: true;
}
@keyframes pb-progress {
from { --pb-pct: 0; }
to { --pb-pct: 100; }
}
/* rendered, so it stays in the tab order */
.pb-hidden-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.pb-meter-pct {
counter-reset: pct var(--pb-pct);
color: #b8ff57;
font-variant-numeric: tabular-nums;
opacity: 0;
transition: opacity .2s;
}
.pb-meter-pct::after {
content: counter(pct) "%";
}
/* along the bottom edge, not behind the label */
.pb-meter-track {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: #2a2a2d;
}
.pb-meter-fill {
display: block;
height: 100%;
background: #b8ff57;
transform-origin: left;
/* the same integer, read as a scale factor */
transform: scaleX(calc(var(--pb-pct) / 100));
}
/* linear, because an eased percentage looks like a stall */
.pb-hidden-input:checked ~ .pb-meter-btn {
animation: pb-progress 2.4s linear forwards;
}
.pb-hidden-input:checked ~ .pb-meter-btn .pb-meter-pct {
opacity: 1;
}
.pb-hidden-input:focus-visible ~ .pb-meter-btn {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
Indeterminate, because CSS does not know when it is finished
A fixed two second fill is a guess dressed up as a measurement. When the duration is genuinely unknown, a band that travels back and forth says the right thing: work is happening, and nobody is claiming to know how much is left. The button never announces completion, which means it never announces something false. It keeps running until a second click unchecks the box, or until whatever is behind it replaces the page. The idle label stays in normal flow so the button keeps its height while the busy label sits on top of it.
HTML
<input type="checkbox" id="sync" class="pb-hidden-input">
<label for="sync" class="pb-indet-btn">
<span class="pb-indet-idle">Sync now</span>
<span class="pb-indet-busy">Working</span>
<span class="pb-indet-track"></span>
</label>
CSS
@keyframes pb-indeterminate {
from { transform: translateX(-100%); }
to { transform: translateX(250%); }
}
/* the idle label stays in flow and gives the button its height */
.pb-indet-idle {
transition: opacity .2s;
}
.pb-indet-busy {
position: absolute;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity .2s;
}
.pb-indet-track {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: #2a2a2d;
overflow: hidden;
opacity: 0;
transition: opacity .2s;
}
/* the band is 40% of the track, so 250% carries it fully off the end */
.pb-indet-track::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 40%;
background: #b8ff57;
transform: translateX(-100%);
}
.pb-hidden-input:checked ~ .pb-indet-btn .pb-indet-idle { opacity: 0; }
.pb-hidden-input:checked ~ .pb-indet-btn .pb-indet-busy { opacity: 1; }
.pb-hidden-input:checked ~ .pb-indet-btn .pb-indet-track { opacity: 1; }
.pb-hidden-input:checked ~ .pb-indet-btn .pb-indet-track::before {
animation: pb-indeterminate 1.2s ease-in-out infinite;
}
How it works
A hidden checkbox is the state machine. When checked, ::before animates from scaleX(0) to scaleX(1) with transform-origin: left, which produces the left to right fill. Three label spans (idle, loading, done) swap visibility using @keyframes with animation-delay, so the text transitions happen at the right points in the 2-second fill animation.
transform: scaleX() is the right tool for a fill because it never touches layout. Animating width from 0 to 100 percent forces the browser to lay the element out again on every frame; scaling a pseudo-element is a compositor operation. The reason it does not turn into a stretched pill is overflow: hidden and border-radius: 999px on the button, which clip the rectangle to the button's shape. Scale the fill with its own rounded corners instead and you get a visibly distorted oval, because scaling distorts the radius along with everything else.
The .prog-idle span is the only one left in normal flow, and that is deliberate. The other two labels are position: absolute, which takes them out of flow so they contribute nothing to the button's size. Make all three absolute and the button collapses to the height of its padding, the labels overlap the border, and nothing anywhere reports a problem. min-width: 160px handles the other half of the same issue, keeping the button from changing width when a longer label swaps in.
The timing lives entirely in animation-delay. prog-loading runs a fade-in immediately and a fade-out at 1.8 seconds; prog-done runs a fade-in at 2 seconds. Each of those numbers is a hand-placed cue, and every one has to move together if the fill duration changes. That is the real cost of a CSS state machine: there is no variable holding the total duration, so the sequence is a set of magic numbers that only agree with each other because somebody kept them in step.
A timing function applies to each keyframe segment on its own, and prog-fill shows what that means in practice. Its stops are at 0, 80 and 100 percent, so ease-in-out runs across the 0 to 80 portion and then again across a segment where both ends are scaleX(1) and nothing moves. The fill therefore decelerates into place at 1.6 seconds and holds for the remaining 400 milliseconds while the labels swap. Here that hold is intentional. Add a similar hold at the start and the same rule would ease across it too, and the fill would appear to hesitate before it began.
There is a contrast problem in the version above that is easy to miss and obvious once seen. The label flips to near black with transition: color .3s the moment the button is checked, but the fill takes 1.6 seconds to arrive. For most of a second the word Building sits in near black on the dim green unfilled background, which is close to unreadable. Putting the progress somewhere other than directly behind the text is the simplest fix, and both variants below use a thin track along the bottom edge for exactly that reason.
CSS properties used
:checked- The whole state machine. Every rule that runs the sequence hangs off this one selector reading the checkbox.
transformscaleX()withtransform-origin: leftfills from the left without laying anything out again. Animatingwidthwould do the same job on the main thread.overflowhiddenon the button clips the rectangular fill to the rounded pill. Without it the fill's corners show outside the border.animation-delay- Sequences the three labels against the fill. These numbers are the only thing keeping the states in agreement, so they all move together or none of them do.
animation-fill-modeforwardson every step holds its end value. Leave it off and each label snaps back the instant its animation finishes.@property- Registers an integer that can be animated, which is what lets the first variant below drive a bar and a percentage from one value.
min-width- Stops the button resizing as labels of different lengths swap in and out, which would otherwise shift everything beside it.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
CSS3 selectors | 4 | 3.5 | 3.2 | 12 |
2D transforms | 4 | 3.5 | 3.1 | 12 |
CSS animation | 4 | 5 | 5.1 | 12 |
CSS transitions | 4 | 5 | 5.1 | 12 |
CSS counters | 4 | 2 | 3.1 | 12 |
The button and its sequence work in anything. The only recent piece is the @property at-rule used by the percentage variant, which Can I Use does not track and which reached Chromium several years before Firefox and Safari. Where it is missing the custom property stays untyped, so it cannot be interpolated, the number sits at its initial value and the bar does not move. That is a silent failure rather than a broken layout, but it is a reason to keep a plain fill available as the default and treat the counting version as the enhancement.
Accessibility notes
.prog-input { display: none } removes the checkbox from the tab order, and because the visible control is a <label> rather than a button, there is nothing else to focus. The component cannot be operated from a keyboard at all. Render the input at one pixel with opacity: 0 instead so it stays focusable, then draw the ring on the label with :focus-visible. Both variants below are built that way.
Nothing announces the state changes. A screen reader reads the label at the moment it lands on the control and hears nothing when the text swaps from Deploy to Deploying to Live, because none of that is a live region. Wrapping the changing label in role="status" will announce it, though bear in mind that the announcement will be as fictional as the animation if the underlying work has not actually finished.
The final label is a claim. Complete and Live are statements about work that CSS has no way to verify, and a reader using assistive technology has no other cue to weigh them against. If the button is genuinely decorative, use wording that describes the request rather than the result. If the outcome matters, the state has to come from the server or from script, with CSS only drawing it.
What you can build with it
- Optimistic submit feedback. Acknowledging a click on a form that posts and navigates away. The fill only has to survive until the next page loads, so its duration never gets tested.
- Prototypes and demos. Showing a flow in a static mock-up without wiring up any behavior, which is where a fixed duration is a feature rather than a lie.
- Download starts. A short fill acknowledging that a file transfer has begun, where the browser takes over reporting from there.
- Multi-step wizards. A next button that fills while the next panel is prepared, paired with a loading spinner if the wait might run long.
- Bulk actions. The indeterminate version below, which admits it does not know how long a queued job will take instead of guessing at two seconds.
Mistakes worth avoiding
- Flipping the label color on a short transition while the fill takes far longer to arrive. The text turns dark against a background that has not been filled yet, and it is unreadable for most of the animation.
- Making every label
position: absolute. All three leave normal flow, the button has no content to size itself from, and it collapses to the height of its padding with nothing logged. - Scaling a fill that has its own
border-radius.scaleXstretches the radius too, so the rounded end turns into a long oval. Clip the parent instead of rounding the child. - Changing the fill duration without moving the label delays. The sequence comes apart, with Complete appearing before the bar has arrived or a second of dead air after it has.
- Presenting a fixed duration as a result. A button that says Complete after exactly two seconds says it whether or not anything completed, and a reader has no way to tell the difference.
Frequently asked questions
Can a CSS progress button show real progress?
Why does my progress button collapse when I click it?
Should I animate width or transform for the fill?
transform: scaleX() with a left origin. Width changes force layout on every frame, while a transform runs on the compositor. Keep the rounding on the parent with overflow: hidden, or scaling will distort the corners of the fill itself.How do I reset the button?
:checked rules stop matching, and every animation is removed at once. The button snaps back to idle rather than reversing, because removing an animation is not the same as playing it backwards.