20 CSS Loading Buttons08 / 20
Background Progress Fill Button
The button's own background fills from left to right as the work completes. A single --progress custom property drives a hard-stop gradient, and because the property is registered with @property the browser can interpolate it, so each update glides instead of stepping. One style write per progress event; no extra elements, no width animation.
Published
The code
<div class="lb-08">
<div class="lb-08__stage">
<section class="lb-08__panel" aria-labelledby="lb-08-h">
<h2 class="lb-08__h" id="lb-08-h">Reports</h2>
<p class="lb-08__sub">Export runs server-side. The button fills as rows are written.</p>
<div class="lb-08__table" role="table" aria-label="Saved reports">
<div class="lb-08__tr lb-08__tr--head" role="row">
<span role="columnheader">Report</span><span role="columnheader">Rows</span><span role="columnheader">Owner</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Churn cohort, Q3</span><span role="cell">48,210</span><span role="cell">Sam Rivera</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Activation funnel</span><span role="cell">12,904</span><span role="cell">Alex Chen</span>
</div>
</div>
<div class="lb-08__actions">
<button class="lb-08__btn" type="button" id="lb-08-btn" data-state="idle" aria-busy="false">
<span class="lb-08__label" id="lb-08-label">Export CSV</span>
</button>
<label class="lb-08__toggle"><input type="checkbox" id="lb-08-fail"> Fail at 60%</label>
</div>
<p class="lb-08__live" id="lb-08-live" role="status" aria-live="polite">No export running.</p>
</section>
</div>
</div><div class="lb-08">
<div class="lb-08__stage">
<section class="lb-08__panel" aria-labelledby="lb-08-h">
<h2 class="lb-08__h" id="lb-08-h">Reports</h2>
<p class="lb-08__sub">Export runs server-side. The button fills as rows are written.</p>
<div class="lb-08__table" role="table" aria-label="Saved reports">
<div class="lb-08__tr lb-08__tr--head" role="row">
<span role="columnheader">Report</span><span role="columnheader">Rows</span><span role="columnheader">Owner</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Churn cohort, Q3</span><span role="cell">48,210</span><span role="cell">Sam Rivera</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Activation funnel</span><span role="cell">12,904</span><span role="cell">Alex Chen</span>
</div>
</div>
<div class="lb-08__actions">
<button class="lb-08__btn" type="button" id="lb-08-btn" data-state="idle" aria-busy="false">
<span class="lb-08__label" id="lb-08-label">Export CSV</span>
</button>
<label class="lb-08__toggle"><input type="checkbox" id="lb-08-fail"> Fail at 60%</label>
</div>
<p class="lb-08__live" id="lb-08-live" role="status" aria-live="polite">No export running.</p>
</section>
</div>
</div>.lb-08 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--bg: #0d1b2e;
--surface: #12253c;
--ink: #e8f0fa;
--muted: #8ba6c4;
--rule: #1f3854;
--accent: #1b3c5e;
--fill: #4aa3ff;
--bad: #e2725b;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
padding: 40px 18px;
}
.lb-08 *,
.lb-08 *::before,
.lb-08 *::after {
box-sizing: border-box;
}
.lb-08__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-08__panel {
inline-size: 100%;
max-inline-size: 38rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 16px;
padding: 24px 22px;
}
.lb-08__h {
margin: 0;
font-size: 20px;
font-weight: 620;
letter-spacing: -.015em;
}
.lb-08__sub {
margin: 8px 0 18px;
font-size: 13px;
line-height: 1.6;
color: var(--muted);
max-inline-size: 48ch;
}
.lb-08__table {
display: grid;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
border-radius: 10px;
overflow: hidden;
}
.lb-08__tr {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr);
gap: 10px;
padding: 11px 14px;
background: var(--surface);
font-size: 12.5px;
min-inline-size: 0;
}
.lb-08__tr span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-08__tr--head {
color: var(--muted);
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
}
.lb-08__tr span:nth-child(2) {
font-variant-numeric: tabular-nums;
}
.lb-08__actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin: 18px 0 0;
min-inline-size: 0;
}
.lb-08__btn {
appearance: none;
position: relative;
isolation: isolate;
border: 1px solid var(--fill);
background: var(--accent);
color: var(--ink);
border-radius: 14px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
padding: 0 20px;
min-block-size: 46px;
min-inline-size: 11rem;
cursor: pointer;
}
.lb-08__btn:focus-visible {
outline: 3px solid var(--fill);
outline-offset: 3px;
}
.lb-08__btn[data-state="pending"] {
cursor: progress;
}
.lb-08__label {
position: relative;
z-index: 1;
display: block;
min-inline-size: 0;
}
@supports (background: linear-gradient(90deg, red 0 10%, transparent 10%)) {
.lb-08__btn {
background: linear-gradient(90deg, var(--fill) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
}
.lb-08__btn {
transition: --lb-08-p 260ms linear, border-color 200ms linear;
}
@property --lb-08-p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.lb-08__btn[data-state="error"] {
border-color: var(--bad);
background: linear-gradient(90deg, var(--bad) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
.lb-08__toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-08__toggle input {
accent-color: var(--fill);
inline-size: 15px;
block-size: 15px;
}
.lb-08__live {
margin: 14px 0 0;
font-size: 12.5px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-08__btn {
transition: none;
}
}
@media (prefers-color-scheme: light) {
.lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}
}
[data-theme="light"] .lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}.lb-08 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--bg: #0d1b2e;
--surface: #12253c;
--ink: #e8f0fa;
--muted: #8ba6c4;
--rule: #1f3854;
--accent: #1b3c5e;
--fill: #4aa3ff;
--bad: #e2725b;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
padding: 40px 18px;
}
.lb-08 *,
.lb-08 *::before,
.lb-08 *::after {
box-sizing: border-box;
}
.lb-08__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-08__panel {
inline-size: 100%;
max-inline-size: 38rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 16px;
padding: 24px 22px;
}
.lb-08__h {
margin: 0;
font-size: 20px;
font-weight: 620;
letter-spacing: -.015em;
}
.lb-08__sub {
margin: 8px 0 18px;
font-size: 13px;
line-height: 1.6;
color: var(--muted);
max-inline-size: 48ch;
}
.lb-08__table {
display: grid;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
border-radius: 10px;
overflow: hidden;
}
.lb-08__tr {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr);
gap: 10px;
padding: 11px 14px;
background: var(--surface);
font-size: 12.5px;
min-inline-size: 0;
}
.lb-08__tr span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-08__tr--head {
color: var(--muted);
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
}
.lb-08__tr span:nth-child(2) {
font-variant-numeric: tabular-nums;
}
.lb-08__actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin: 18px 0 0;
min-inline-size: 0;
}
.lb-08__btn {
appearance: none;
position: relative;
isolation: isolate;
border: 1px solid var(--fill);
background: var(--accent);
color: var(--ink);
border-radius: 14px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
padding: 0 20px;
min-block-size: 46px;
min-inline-size: 11rem;
cursor: pointer;
}
.lb-08__btn:focus-visible {
outline: 3px solid var(--fill);
outline-offset: 3px;
}
.lb-08__btn[data-state="pending"] {
cursor: progress;
}
.lb-08__label {
position: relative;
z-index: 1;
display: block;
min-inline-size: 0;
}
@supports (background: linear-gradient(90deg, red 0 10%, transparent 10%)) {
.lb-08__btn {
background: linear-gradient(90deg, var(--fill) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
}
.lb-08__btn {
transition: --lb-08-p 260ms linear, border-color 200ms linear;
}
@property --lb-08-p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.lb-08__btn[data-state="error"] {
border-color: var(--bad);
background: linear-gradient(90deg, var(--bad) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
.lb-08__toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-08__toggle input {
accent-color: var(--fill);
inline-size: 15px;
block-size: 15px;
}
.lb-08__live {
margin: 14px 0 0;
font-size: 12.5px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-08__btn {
transition: none;
}
}
@media (prefers-color-scheme: light) {
.lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}
}
[data-theme="light"] .lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}const btn = document.getElementById('lb-08-btn');
const label = document.getElementById('lb-08-label');
const live = document.getElementById('lb-08-live');
const failBox = document.getElementById('lb-08-fail');
let tick;
let reset;
const setP = (p) => btn.style.setProperty('--lb-08-p', p + '%');
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearInterval(tick);
clearTimeout(reset);
let p = 0;
setP(0);
btn.dataset.state = 'pending';
btn.setAttribute('aria-busy', 'true');
tick = setInterval(() => {
p += 20;
setP(p);
label.textContent = 'Exporting ' + p + '%';
live.textContent = 'Export at ' + p + ' percent';
if (failBox.checked && p >= 60) {
clearInterval(tick);
btn.dataset.state = 'error';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Export failed at 60% — retry';
live.textContent = 'Export failed at 60 percent. The fill is frozen where it stopped.';
return;
}
if (p >= 100) {
clearInterval(tick);
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Download ready';
live.textContent = 'Export complete';
reset = setTimeout(() => { btn.dataset.state = 'idle'; label.textContent = 'Export CSV'; setP(0); }, 2200);
}
}, 420);
});const btn = document.getElementById('lb-08-btn');
const label = document.getElementById('lb-08-label');
const live = document.getElementById('lb-08-live');
const failBox = document.getElementById('lb-08-fail');
let tick;
let reset;
const setP = (p) => btn.style.setProperty('--lb-08-p', p + '%');
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearInterval(tick);
clearTimeout(reset);
let p = 0;
setP(0);
btn.dataset.state = 'pending';
btn.setAttribute('aria-busy', 'true');
tick = setInterval(() => {
p += 20;
setP(p);
label.textContent = 'Exporting ' + p + '%';
live.textContent = 'Export at ' + p + ' percent';
if (failBox.checked && p >= 60) {
clearInterval(tick);
btn.dataset.state = 'error';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Export failed at 60% — retry';
live.textContent = 'Export failed at 60 percent. The fill is frozen where it stopped.';
return;
}
if (p >= 100) {
clearInterval(tick);
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Download ready';
live.textContent = 'Export complete';
reset = setTimeout(() => { btn.dataset.state = 'idle'; label.textContent = 'Export CSV'; setP(0); }, 2200);
}
}, 420);
});Here's a working CSS Loading Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Background Progress Fill Button
Source: https://codefronts.com/components/css-loading-buttons/background-progress-fill-button/
The button's own background fills from left to right as the work completes. A single --progress custom property drives a hard-stop gradient, and because the property is registered with @property the browser can interpolate it, so each update glides instead of stepping. One style write per progress event; no extra elements, no width animation.
## HTML
```html
<div class="lb-08">
<div class="lb-08__stage">
<section class="lb-08__panel" aria-labelledby="lb-08-h">
<h2 class="lb-08__h" id="lb-08-h">Reports</h2>
<p class="lb-08__sub">Export runs server-side. The button fills as rows are written.</p>
<div class="lb-08__table" role="table" aria-label="Saved reports">
<div class="lb-08__tr lb-08__tr--head" role="row">
<span role="columnheader">Report</span><span role="columnheader">Rows</span><span role="columnheader">Owner</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Churn cohort, Q3</span><span role="cell">48,210</span><span role="cell">Sam Rivera</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Activation funnel</span><span role="cell">12,904</span><span role="cell">Alex Chen</span>
</div>
</div>
<div class="lb-08__actions">
<button class="lb-08__btn" type="button" id="lb-08-btn" data-state="idle" aria-busy="false">
<span class="lb-08__label" id="lb-08-label">Export CSV</span>
</button>
<label class="lb-08__toggle"><input type="checkbox" id="lb-08-fail"> Fail at 60%</label>
</div>
<p class="lb-08__live" id="lb-08-live" role="status" aria-live="polite">No export running.</p>
</section>
</div>
</div>
```
## CSS
```css
.lb-08 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--bg: #0d1b2e;
--surface: #12253c;
--ink: #e8f0fa;
--muted: #8ba6c4;
--rule: #1f3854;
--accent: #1b3c5e;
--fill: #4aa3ff;
--bad: #e2725b;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
padding: 40px 18px;
}
.lb-08 *,
.lb-08 *::before,
.lb-08 *::after {
box-sizing: border-box;
}
.lb-08__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-08__panel {
inline-size: 100%;
max-inline-size: 38rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 16px;
padding: 24px 22px;
}
.lb-08__h {
margin: 0;
font-size: 20px;
font-weight: 620;
letter-spacing: -.015em;
}
.lb-08__sub {
margin: 8px 0 18px;
font-size: 13px;
line-height: 1.6;
color: var(--muted);
max-inline-size: 48ch;
}
.lb-08__table {
display: grid;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
border-radius: 10px;
overflow: hidden;
}
.lb-08__tr {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr);
gap: 10px;
padding: 11px 14px;
background: var(--surface);
font-size: 12.5px;
min-inline-size: 0;
}
.lb-08__tr span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-08__tr--head {
color: var(--muted);
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
}
.lb-08__tr span:nth-child(2) {
font-variant-numeric: tabular-nums;
}
.lb-08__actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin: 18px 0 0;
min-inline-size: 0;
}
.lb-08__btn {
appearance: none;
position: relative;
isolation: isolate;
border: 1px solid var(--fill);
background: var(--accent);
color: var(--ink);
border-radius: 14px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
padding: 0 20px;
min-block-size: 46px;
min-inline-size: 11rem;
cursor: pointer;
}
.lb-08__btn:focus-visible {
outline: 3px solid var(--fill);
outline-offset: 3px;
}
.lb-08__btn[data-state="pending"] {
cursor: progress;
}
.lb-08__label {
position: relative;
z-index: 1;
display: block;
min-inline-size: 0;
}
@supports (background: linear-gradient(90deg, red 0 10%, transparent 10%)) {
.lb-08__btn {
background: linear-gradient(90deg, var(--fill) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
}
.lb-08__btn {
transition: --lb-08-p 260ms linear, border-color 200ms linear;
}
@property --lb-08-p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.lb-08__btn[data-state="error"] {
border-color: var(--bad);
background: linear-gradient(90deg, var(--bad) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
.lb-08__toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-08__toggle input {
accent-color: var(--fill);
inline-size: 15px;
block-size: 15px;
}
.lb-08__live {
margin: 14px 0 0;
font-size: 12.5px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-08__btn {
transition: none;
}
}
@media (prefers-color-scheme: light) {
.lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}
}
[data-theme="light"] .lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}
```
## JavaScript
```js
const btn = document.getElementById('lb-08-btn');
const label = document.getElementById('lb-08-label');
const live = document.getElementById('lb-08-live');
const failBox = document.getElementById('lb-08-fail');
let tick;
let reset;
const setP = (p) => btn.style.setProperty('--lb-08-p', p + '%');
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearInterval(tick);
clearTimeout(reset);
let p = 0;
setP(0);
btn.dataset.state = 'pending';
btn.setAttribute('aria-busy', 'true');
tick = setInterval(() => {
p += 20;
setP(p);
label.textContent = 'Exporting ' + p + '%';
live.textContent = 'Export at ' + p + ' percent';
if (failBox.checked && p >= 60) {
clearInterval(tick);
btn.dataset.state = 'error';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Export failed at 60% — retry';
live.textContent = 'Export failed at 60 percent. The fill is frozen where it stopped.';
return;
}
if (p >= 100) {
clearInterval(tick);
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Download ready';
live.textContent = 'Export complete';
reset = setTimeout(() => { btn.dataset.state = 'idle'; label.textContent = 'Export CSV'; setP(0); }, 2200);
}
}, 420);
});
```Here's a working CSS Loading Button from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Background Progress Fill Button
Source: https://codefronts.com/components/css-loading-buttons/background-progress-fill-button/
The button's own background fills from left to right as the work completes. A single --progress custom property drives a hard-stop gradient, and because the property is registered with @property the browser can interpolate it, so each update glides instead of stepping. One style write per progress event; no extra elements, no width animation.
## HTML
```html
<div class="lb-08">
<div class="lb-08__stage">
<section class="lb-08__panel" aria-labelledby="lb-08-h">
<h2 class="lb-08__h" id="lb-08-h">Reports</h2>
<p class="lb-08__sub">Export runs server-side. The button fills as rows are written.</p>
<div class="lb-08__table" role="table" aria-label="Saved reports">
<div class="lb-08__tr lb-08__tr--head" role="row">
<span role="columnheader">Report</span><span role="columnheader">Rows</span><span role="columnheader">Owner</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Churn cohort, Q3</span><span role="cell">48,210</span><span role="cell">Sam Rivera</span>
</div>
<div class="lb-08__tr" role="row">
<span role="cell">Activation funnel</span><span role="cell">12,904</span><span role="cell">Alex Chen</span>
</div>
</div>
<div class="lb-08__actions">
<button class="lb-08__btn" type="button" id="lb-08-btn" data-state="idle" aria-busy="false">
<span class="lb-08__label" id="lb-08-label">Export CSV</span>
</button>
<label class="lb-08__toggle"><input type="checkbox" id="lb-08-fail"> Fail at 60%</label>
</div>
<p class="lb-08__live" id="lb-08-live" role="status" aria-live="polite">No export running.</p>
</section>
</div>
</div>
```
## CSS
```css
.lb-08 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--bg: #0d1b2e;
--surface: #12253c;
--ink: #e8f0fa;
--muted: #8ba6c4;
--rule: #1f3854;
--accent: #1b3c5e;
--fill: #4aa3ff;
--bad: #e2725b;
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
padding: 40px 18px;
}
.lb-08 *,
.lb-08 *::before,
.lb-08 *::after {
box-sizing: border-box;
}
.lb-08__stage {
display: grid;
place-items: start center;
max-inline-size: 100%;
min-height: calc(100vh - 80px);
}
.lb-08__panel {
inline-size: 100%;
max-inline-size: 38rem;
min-inline-size: 0;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 16px;
padding: 24px 22px;
}
.lb-08__h {
margin: 0;
font-size: 20px;
font-weight: 620;
letter-spacing: -.015em;
}
.lb-08__sub {
margin: 8px 0 18px;
font-size: 13px;
line-height: 1.6;
color: var(--muted);
max-inline-size: 48ch;
}
.lb-08__table {
display: grid;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
border-radius: 10px;
overflow: hidden;
}
.lb-08__tr {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr);
gap: 10px;
padding: 11px 14px;
background: var(--surface);
font-size: 12.5px;
min-inline-size: 0;
}
.lb-08__tr span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-08__tr--head {
color: var(--muted);
font-size: 10.5px;
letter-spacing: .1em;
text-transform: uppercase;
}
.lb-08__tr span:nth-child(2) {
font-variant-numeric: tabular-nums;
}
.lb-08__actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin: 18px 0 0;
min-inline-size: 0;
}
.lb-08__btn {
appearance: none;
position: relative;
isolation: isolate;
border: 1px solid var(--fill);
background: var(--accent);
color: var(--ink);
border-radius: 14px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
padding: 0 20px;
min-block-size: 46px;
min-inline-size: 11rem;
cursor: pointer;
}
.lb-08__btn:focus-visible {
outline: 3px solid var(--fill);
outline-offset: 3px;
}
.lb-08__btn[data-state="pending"] {
cursor: progress;
}
.lb-08__label {
position: relative;
z-index: 1;
display: block;
min-inline-size: 0;
}
@supports (background: linear-gradient(90deg, red 0 10%, transparent 10%)) {
.lb-08__btn {
background: linear-gradient(90deg, var(--fill) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
}
.lb-08__btn {
transition: --lb-08-p 260ms linear, border-color 200ms linear;
}
@property --lb-08-p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.lb-08__btn[data-state="error"] {
border-color: var(--bad);
background: linear-gradient(90deg, var(--bad) 0 var(--lb-08-p, 0%), var(--accent) var(--lb-08-p, 0%));
}
.lb-08__toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
min-height: 44px;
}
.lb-08__toggle input {
accent-color: var(--fill);
inline-size: 15px;
block-size: 15px;
}
.lb-08__live {
margin: 14px 0 0;
font-size: 12.5px;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-inline-size: 0;
}
@media (prefers-reduced-motion: reduce) {
.lb-08__btn {
transition: none;
}
}
@media (prefers-color-scheme: light) {
.lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}
}
[data-theme="light"] .lb-08 {
--bg: #eef3f9;
--surface: #ffffff;
--ink: #0e2138;
--muted: #5a7391;
--rule: #d5e0ec;
--accent: #e4edf7;
--fill: #9ecbff;
--bad: #d9573d;
}
```
## JavaScript
```js
const btn = document.getElementById('lb-08-btn');
const label = document.getElementById('lb-08-label');
const live = document.getElementById('lb-08-live');
const failBox = document.getElementById('lb-08-fail');
let tick;
let reset;
const setP = (p) => btn.style.setProperty('--lb-08-p', p + '%');
btn.addEventListener('click', () => {
if (btn.dataset.state === 'pending') return;
clearInterval(tick);
clearTimeout(reset);
let p = 0;
setP(0);
btn.dataset.state = 'pending';
btn.setAttribute('aria-busy', 'true');
tick = setInterval(() => {
p += 20;
setP(p);
label.textContent = 'Exporting ' + p + '%';
live.textContent = 'Export at ' + p + ' percent';
if (failBox.checked && p >= 60) {
clearInterval(tick);
btn.dataset.state = 'error';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Export failed at 60% — retry';
live.textContent = 'Export failed at 60 percent. The fill is frozen where it stopped.';
return;
}
if (p >= 100) {
clearInterval(tick);
btn.dataset.state = 'done';
btn.setAttribute('aria-busy', 'false');
label.textContent = 'Download ready';
live.textContent = 'Export complete';
reset = setTimeout(() => { btn.dataset.state = 'idle'; label.textContent = 'Export CSV'; setP(0); }, 2200);
}
}, 420);
});
```How this works
The fill is one gradient with a hard stop. linear-gradient(90deg, var(--fill) 0 var(--lb-08-p), transparent var(--lb-08-p)) paints a solid block up to the current percentage and nothing after it. Move the property and the edge moves; there is no inner bar element, no width animation, and nothing that can fall out of sync with the label sitting on top.
@property is what makes it smooth. An unregistered custom property is just a token string to the engine, so a transition on it jumps from value to value. Registering it with syntax: "<percentage>" and an initial value gives it a type, and a typed property interpolates — which is why the same code steps in one browser and glides in another when you forget the at-rule. This is the single most common reason a fill "does not animate".
Keep the write cheap and let CSS do the easing. The script only ever calls btn.style.setProperty('--lb-08-p', pct + '%'). Transition duration lives in the stylesheet, so a slow network that delivers progress in three lumpy jumps still looks continuous, and you can retune the feel without touching the JS.
The trap is what happens on failure. Resetting the fill to 0% on error implies nothing happened; the honest state is to freeze the edge where it stopped, recolour it, and say what failed. Also remember the label needs to stay readable across the fill boundary — either keep the fill low-contrast against the ink, or set the label in a colour that clears both halves, because text that is legible at 20% can be unreadable at 80%.
Make it yours
- Change
--fillto retint the progress block without touching the button's base surface. - Raise the transition duration on
--lb-08-pto smooth over a lumpier progress feed. - Flip the gradient angle to
270degfor a right-to-left fill in an RTL layout. - Add a second stop for a subtle leading edge highlight on the fill.
- Swap the percentage syntax for
<length>if you would rather drive the edge in pixels. - Hold the frozen fill on error until the user retries, instead of reverting after a timeout.
Gotchas — read before shipping
- Without
@propertythe custom property is untyped, so the transition does nothing and the fill visibly steps. - Animating an inner bar's
widthinstead triggers layout on every frame; the gradient stop does not. - Resetting the fill to zero on failure tells the user their work was discarded even when it was not.
- A fill colour close to the label colour makes the text unreadable at high percentages — check contrast at both ends.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 105+ | 16.4+ | 128+ | 105+ |
:has() sets the Chrome floor at 105 and @property sets Safari 16.4 / Firefox 128 — and it is what makes the fill interpolate. Older engines get the flat fallback colour declared before the @supports gate plus a stepping fill, so the progress is still readable, just not smooth.