Interaction
Polled job card
One card covering every state a background job passes through, where an unknown total switches the bar to indeterminate with a running count rather than inventing a percentage, and a failed poll is treated as transient instead of ending the job.
Reach for it when building
- a library scan or import
- a long export or report build
- a bulk download queue
- a video transcode
- a data migration triggered from the UI
- any job whose progress is fetched rather than streamed
- progress
- polling
- background-job
- indeterminate
- aria-busy
- reduced-motion
<div class="pjc-demo">
<section class="pjc-card" aria-labelledby="pjc-name">
<header class="pjc-head">
<h3 class="pjc-name" id="pjc-name">Scan library</h3>
<span class="pjc-pill" id="pjc-pill" data-state="idle">Not started</span>
</header>
<div class="pjc-track" id="pjc-track">
<div class="pjc-fill" id="pjc-fill"></div>
</div>
<p class="pjc-line" id="pjc-line" role="status">Nothing has run yet.</p>
</section>
<div class="pjc-controls">
<button type="button" class="pjc-btn pjc-primary" id="pjc-start">Start scan</button>
<button type="button" class="pjc-btn" id="pjc-cancel">Cancel</button>
<button type="button" class="pjc-btn" id="pjc-fail">Simulate a failed poll</button>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.pjc-demo { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
.pjc-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.pjc-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: space-between; }
.pjc-name { margin: 0; font-size: 1rem; }
/* Each state is a different shape as well as a different colour: the border
style and the word both change, so the state survives a greyscale screenshot. */
.pjc-pill {
font-size: 0.875rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--dim);
}
.pjc-pill[data-state='running'] { border-color: var(--accent); color: var(--accent); background: color-mix(in oklab, var(--accent) 12%, var(--surface)); }
.pjc-pill[data-state='done'] { border-color: var(--ok); color: var(--ok); background: color-mix(in oklab, var(--ok) 12%, var(--surface)); }
.pjc-pill[data-state='failed'] { border-color: var(--bad); color: var(--bad); background: color-mix(in oklab, var(--bad) 12%, var(--surface)); }
.pjc-pill[data-state='cancelled'] { border-style: dashed; border-color: var(--warn); color: var(--warn); }
.pjc-track {
height: 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-2);
overflow: hidden;
}
.pjc-fill { height: 100%; width: 0; background: var(--accent); transition: width 200ms linear; }
/* Indeterminate is a different drawing, not a guessed width. */
.pjc-fill[data-mode='indeterminate'] {
width: 100%;
background-image: repeating-linear-gradient(
45deg,
color-mix(in oklab, var(--accent-ink) 45%, transparent) 0 8px,
transparent 8px 16px
);
animation: pjc-drift 900ms linear infinite;
}
@keyframes pjc-drift {
from { background-position: 0 0; }
to { background-position: 22px 0; }
}
.pjc-line { margin: 0; font-size: 0.9375rem; color: var(--dim); min-height: 3em; }
.pjc-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pjc-btn {
font: inherit;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--ink);
cursor: pointer;
}
.pjc-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.pjc-btn[disabled] { opacity: 0.55; cursor: not-allowed; }/* Uneven but fixed steps: a scan discovers files in bursts, and a demo that
redraws differently on every run is impossible to reason about. */
const DISCOVERY_STEPS = [148, 212, 176, 233, 191, 260];
const pill = document.getElementById('pjc-pill');
const fill = document.getElementById('pjc-fill');
const line = document.getElementById('pjc-line');
const track = document.getElementById('pjc-track');
const startButton = document.getElementById('pjc-start');
let timer = null;
let found = 0;
let done = 0;
let total = 0;
let missedPolls = 0;
let discoveryStep = 0;
function setState(word, state) {
pill.textContent = word;
pill.dataset.state = state;
startButton.disabled = state === 'running';
track.setAttribute('aria-busy', state === 'running' ? 'true' : 'false');
}
function stop() {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
function poll() {
/* A total of zero means "not known yet", not "nothing to do". The bar goes
indeterminate and the count carries the only honest number there is. */
if (total === 0) {
found += DISCOVERY_STEPS[discoveryStep % DISCOVERY_STEPS.length];
discoveryStep += 1;
fill.dataset.mode = 'indeterminate';
setState('Scanning', 'running');
line.textContent =
found.toLocaleString() + ' files found so far. No percentage until the total is known.';
if (found > 900) {
total = found;
delete fill.dataset.mode;
}
return;
}
done = Math.min(total, done + Math.ceil(total / 14));
fill.style.width = (done / total) * 100 + '%';
if (done >= total) {
stop();
setState('Done', 'done');
line.textContent = total.toLocaleString() + ' files indexed. Nothing was left waiting.';
return;
}
setState('Indexing', 'running');
line.textContent = done.toLocaleString() + ' of ' + total.toLocaleString() + ' indexed.';
}
startButton.addEventListener('click', () => {
stop();
found = 0;
done = 0;
total = 0;
missedPolls = 0;
discoveryStep = 0;
fill.style.width = '0';
fill.dataset.mode = 'indeterminate';
setState('Starting', 'running');
line.textContent = 'Counting files. The total is not known yet.';
timer = window.setInterval(poll, 420);
});
document.getElementById('pjc-cancel').addEventListener('click', () => {
stop();
delete fill.dataset.mode;
setState('Cancelled', 'cancelled');
line.textContent =
'Stopped at ' + (done || found).toLocaleString() + ' files. Nothing was rolled back — starting again resumes.';
});
/* A poll that fails is not a job that failed. Count the misses, keep polling,
and only give up after several in a row. */
document.getElementById('pjc-fail').addEventListener('click', () => {
missedPolls += 1;
if (missedPolls < 3) {
line.textContent =
'Poll ' + missedPolls + ' of 3 failed. Still polling — the job is very likely fine.';
return;
}
stop();
delete fill.dataset.mode;
setState('Failed', 'failed');
line.textContent = 'Three polls failed in a row. The library folder may have moved — check the path and start again.';
});Paste this into an agent to rebuild the pattern from scratch.
Build one card that covers a whole background job: not started, starting, working with an unknown total, working with a known total, cancelled, failed, and done. Progress is fetched on a timer rather than streamed, so the card has to stay honest between polls.
Reach for this for any job the server owns and the page merely observes — a scan, an import, an export, a transcode. Walk away when the work finishes inside a second: a card with six states for a 200ms request is furniture, and a button that briefly reads "Saving…" is the whole pattern you need.
The rule that makes it work: **a total of zero means the total is not known yet, and that switches the bar to indeterminate with a running count.** Not a 0% bar, and never a percentage computed against a guess. Show "1,412 files found so far" and swap to the determinate bar the moment a real total arrives. A fake percentage that jumps backwards when the total is discovered destroys trust in every number on the card.
**Treat a failed poll as transient, exactly like a thrown network error.** Count consecutive misses and keep polling; only declare failure after several in a row. Ending the loop on the first non-OK response is the bug that freezes a card mid-job forever — one dropped request, one server restart, and the card sits at 43% claiming to be working while nothing is watching.
Make each state a different shape, not the same pill in a different colour. Change the word, the border style, and the fill together, so the state reads on a greyscale screenshot and in a peripheral glance.
Say what cancellation actually did. "Stopped at 812 files, nothing rolled back, starting again resumes" is information; "Cancelled" alone leaves someone wondering whether the half-finished work is now garbage.
Put the status line in a `role="status"` region and set `aria-busy` on the track while the job runs. One announcement per meaningful change — not one per poll, which turns a screen reader into a metronome.
The indeterminate animation must respect reduced motion: the stripes may stop, but the state must still read as indeterminate, so the mode has to be carried by an attribute and not by the animation alone. Every colour comes from theme custom properties.