Lesson 17 / 25
Loading Indicators
The specification of the indeterminate and determinate indicator; computing the progress value and remaining-time estimate, measuring the instability of the estimate, and the frequency policy for progress announcements.
Contents
The previous lesson built the layer that reports a task has finished. What appears on screen during the interval the same task runs is a separate pattern, and it splits into two basic forms.
An indeterminate indicator says only one thing: a task is running. It is used when the completion ratio is unknown. A determinate indicator also states that ratio; it reports how much of a computable whole is finished. The difference between them is not a visual difference: their roles, the values they carry, their announcements, and their update frequencies differ. This lesson writes both as a specification and measures the most often misbuilt part of the determinate indicator — the remaining-time estimate.
What It Solves, When Not to Use It
An indicator answers two questions: is the system working, and how much is left? The first question exists in every wait; the second can be answered only for tasks whose whole is known.
Situations where it should not be used depend on measured durations. The Loading and Empty States lesson measured catalog search response times and found that most requests complete in under a second; opening an indicator in that interval produces flicker. That decision is bound to the delay and minimum-visible-time rules from that lesson; this lesson writes what comes after that decision.
Two more rules apply. A determinate indicator is not built if the whole is unknown; making up a percentage promises the user a false finish. An indicator is not enough if the space for coming content needs to be held; a small indicator does not prevent layout shift when the content arrives, and that work belongs to the next lesson’s pattern.
Native Element First, ARIA Second
Markup has an element set aside for this purpose: progress. It becomes a determinate
indicator when given a value and an indeterminate one when not; it carries its own role
and value fields.
The manual setup lists its fields one by one:
| Part | Determinate indicator | Indeterminate indicator |
|---|---|---|
| Role | role="progressbar" |
role="progressbar" |
| Value | aria-valuenow |
not given |
| Bounds | aria-valuemin, aria-valuemax |
not given |
| Readable value | aria-valuetext (for example “8 MB of 24 MB”) |
not given |
| Name | aria-labelledby or aria-label |
same |
| Region state | aria-busy="true" on the region being refreshed |
same |
Two distinctions are critical. First, the absence of the aria-valuenow field is how
indeterminacy is announced; writing zero means “made no progress at all” and is wrong.
Second, aria-valuetext is written only when the raw number is meaningless on its own:
writing an additional “40 percent” text when the percentage is already understandable
doubles the announcement.
The indicator itself is not a live region. Announcing every change of the progress value produces the flood of announcements the measurement below shows. The announcement is made sparingly, from a separate polite region next to the indicator.
Keyboard and Focus Contract
The indicator is not interactive; it does not take focus and holds no place in the tab order. The keyboard contract lives not in the indicator itself but around it.
Cancel button. For tasks that exceed ten seconds, cancellation is required, and the cancel button must be focusable. When cancelled, focus has to move somewhere, because the indicator disappears: to the button that started the task.
Displaced focus. If the indicator is placed in the spot of a section that holds a focused element, focus is lost and falls to the start of the document. The correct setup either places the indicator inside the section without replacing it, or moves focus deliberately. The return-point rule from the Modal Dialogs lesson applies here too.
Disabling controls. Disabling a button while a task runs can drop the focus of a user
whose focus is on that button. Marking it with aria-disabled instead, and ignoring
clicks, preserves focus.
Value, Remaining Time, and Announcement Frequency
Computing the percentage is simple and reliable: the accumulated amount over the whole. The remaining time, on the other hand, is an estimate, and the quality of the estimate can be measured. The script below produces a twenty-four-megabyte catalog export through a four-phase flow, compares two estimation methods, and tests announcement frequency policies.
// progress.mjs — determinate progress percentage, remaining-time estimation, and announcement frequency // Catalog export: 24 MB, arriving in chunks on 200 ms steps. const TOTAL = 24 * 1024 * 1024; const STEP = 200; // ms // Reproducible example: linear congruential generator. function generator(seed) { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); } const random = generator(20260728); // Four phases: slow start, fast flow, stall, fast finish. const PHASE = [ { steps: 10, base: 180_000, jitter: 0.5 }, { steps: 20, base: 900_000, jitter: 0.3 }, { steps: 8, base: 40_000, jitter: 0.8 }, { steps: 40, base: 700_000, jitter: 0.3 }, ]; const increments = []; for (const e of PHASE) for (let i = 0; i < e.steps; i++) increments.push(Math.max(0, Math.round(e.base * (1 + e.jitter * (random() * 2 - 1))))); // Cut / complete until the total fills exactly. let total = 0; const incoming = []; for (const a of increments) { if (total >= TOTAL) break; const chunk = Math.min(a, TOTAL - total); total += chunk; incoming.push(chunk); } if (total < TOTAL) incoming.push(TOTAL - total); // --- Percentage and remaining-time estimate ----------------------------------- const ALPHA = 0.25; // weight of the exponentially weighted average const BUCKET = 5000; // ms — rounding step for the displayed estimate let accumulated = 0, speed = null, previousShown = Infinity; const rows = []; for (let i = 0; i < incoming.length; i++) { accumulated += incoming[i]; const elapsed = (i + 1) * STEP; const percent = (accumulated / TOTAL) * 100; const instantSpeed = incoming[i] / STEP; // bytes/ms speed = speed === null ? instantSpeed : ALPHA * instantSpeed + (1 - ALPHA) * speed; const naive = percent > 0 ? (elapsed * (100 - percent)) / percent : Infinity; const weighted = speed > 0 ? (TOTAL - accumulated) / speed : Infinity; // Displayed estimate: rounded to the bucket and never increases. const shown = Math.min(previousShown, Math.ceil(weighted / BUCKET) * BUCKET); previousShown = shown; rows.push({ i: i + 1, elapsed, percent, naive, weighted, shown }); } const actualDuration = incoming.length * STEP; console.log(`total ${(TOTAL / 1024 / 1024).toFixed(0)} MB, ${incoming.length} steps, actual duration ${actualDuration} ms`); console.log("\nstep elapsed(ms) percent naive left weighted left shown actual left"); for (const s of rows) { if (![1, 5, 10, 15, 25, 30, 34, 38, rows.length].includes(s.i)) continue; const write = (v) => (v === Infinity ? "—" : Math.round(v) + " ms"); console.log( `${String(s.i).padStart(4)} ${String(s.elapsed).padStart(12)} ${s.percent.toFixed(1).padStart(7)} ` + `${write(s.naive).padStart(12)} ${write(s.weighted).padStart(15)} ${write(s.shown).padStart(9)} ` + `${(actualDuration - s.elapsed + " ms").padStart(12)}`, ); } // Stability of the estimates: increases in remaining time, largest jump, deviation from actual function stability(field) { let increasing = 0, largest = 0, deviationTotal = 0, changes = 0; for (let i = 1; i < rows.length; i++) { const diff = rows[i][field] - rows[i - 1][field]; if (diff > 0) { increasing++; largest = Math.max(largest, diff); } if (diff !== 0) changes++; deviationTotal += Math.abs(rows[i][field] - (actualDuration - rows[i].elapsed)); } return { increasing, largest, changes, avgDeviation: deviationTotal / (rows.length - 1) }; } console.log("\nestimate increasing steps largest jump screen changes average absolute deviation"); for (const [name, field] of [["naive", "naive"], ["weighted", "weighted"], ["shown", "shown"]]) { const k = stability(field); console.log(`${name.padEnd(10)} ${String(k.increasing).padStart(15)} ${(Math.round(k.largest) + " ms").padStart(14)} ` + `${String(k.changes).padStart(15)} ${(Math.round(k.avgDeviation) + " ms").padStart(27)}`); } // --- Announcement frequency --------------------------------------------------- // The minimum interval between two announcements is the announcement text's reading duration (the previous lesson's model). const REACTION = 700, READING_SPEED = 200; const ANNOUNCEMENT = "40 percent complete"; const MIN_INTERVAL = Math.round(REACTION + (ANNOUNCEMENT.trim().split(/\s+/).length / READING_SPEED) * 60000); console.log(`\nannouncement text: "${ANNOUNCEMENT}" -> minimum interval ${MIN_INTERVAL} ms`); const POLICY = [ { name: "every step", threshold: null, interval: null }, { name: "every 1%", threshold: 1, interval: null }, { name: "every 10%", threshold: 10, interval: null }, { name: "every 25%", threshold: 25, interval: null }, { name: "at least 2 s apart", threshold: null, interval: 2000 }, { name: "every 10% + at least 2 s", threshold: 10, interval: 2000 }, ]; console.log("policy announcements shortest interval unreadable announcements"); for (const p of POLICY) { const moments = []; let lastPercent = -Infinity, lastAt = -Infinity; for (const s of rows) { const percentOk = p.threshold === null ? true : Math.floor(s.percent / p.threshold) > Math.floor(lastPercent / p.threshold); const intervalOk = p.interval === null ? true : s.elapsed - lastAt >= p.interval; if (p.threshold === null && p.interval === null) { moments.push(s.elapsed); lastPercent = s.percent; lastAt = s.elapsed; continue; } if (percentOk && intervalOk) { moments.push(s.elapsed); lastPercent = s.percent; lastAt = s.elapsed; } } let shortest = Infinity, unreadable = 0; for (let i = 1; i < moments.length; i++) { const gap = moments[i] - moments[i - 1]; shortest = Math.min(shortest, gap); if (gap < MIN_INTERVAL) unreadable++; } console.log(`${p.name.padEnd(25)} ${String(moments.length).padStart(14)} ` + `${((shortest === Infinity ? "—" : shortest + " ms")).padStart(18)} ${String(unreadable).padStart(24)}`); }
total 24 MB, 45 steps, actual duration 9000 ms step elapsed(ms) percent naive left weighted left shown actual left 1 200 0.6 33474 ms 33474 ms 35000 ms 8800 ms 5 1000 3.9 24647 ms 25221 ms 30000 ms 8000 ms 10 2000 7.5 24808 ms 23986 ms 25000 ms 7000 ms 15 3000 27.8 7806 ms 4395 ms 5000 ms 6000 ms 25 5000 65.0 2698 ms 1971 ms 5000 ms 4000 ms 30 6000 81.8 1336 ms 1047 ms 5000 ms 3000 ms 34 6800 82.6 1436 ms 2813 ms 5000 ms 2200 ms 38 7600 82.8 1574 ms 7764 ms 5000 ms 1400 ms 45 9000 100.0 0 ms 0 ms 0 ms 0 ms estimate increasing steps largest jump screen changes average absolute deviation naive 11 2424 ms 44 4839 ms weighted 13 2795 ms 44 5365 ms shown 0 0 ms 6 6405 ms announcement text: "40 percent complete" -> minimum interval 1600 ms policy announcements shortest interval unreadable announcements every step 45 200 ms 44 every 1% 36 200 ms 35 every 10% 11 400 ms 8 every 25% 5 1200 ms 1 at least 2 s apart 5 2000 ms 0 every 10% + at least 2 s 5 2000 ms 0
The first table shows how poor the estimate is. At the first step, both methods say thirty-three seconds; the actual remaining time is eight point eight seconds. At the thirty-fourth and thirty-eighth steps — during the phase where the stream stalls — the weighted estimate says close to seven seconds while the actual remaining time has dropped to one point four seconds. Through the stall, the estimate rises, meaning the user sees “remaining time increasing” on screen.
The second table sums this up in three numbers. The naive estimate increases on eleven of the forty-five steps, the weighted estimate on thirteen. The weighted method’s average absolute deviation turns out worse than the naive method’s: weighting the most recent measurement spreads the stall forward into the future. The conclusion that follows is not to look for a better estimation method, but to change how the estimate is presented.
The third column does this. The displayed value rounds to five-second buckets and never increases. The result: zero increases, only six screen changes across forty-five steps. The cost appears in the last field of the third column — the average absolute deviation climbs to 6405 milliseconds, higher than both methods. This is a deliberate trade-off: the remaining time is not reliable to begin with, so a presentation that never moves the wrong way and changes rarely is better than one that is closer to accurate but jumps around.
The percentage itself is exempt from this problem: because the accumulated amount does not decrease, the percentage does not decrease either. The only case where the percentage goes backward is the whole changing afterward; in that case the indicator stops being determinate and must fall back to indeterminate.
The last table ties down the announcement frequency. Announcing on every step produces forty-five announcements, and forty-four of them arrive before the previous one’s reading time has elapsed — meaning none of them get read. Policies that watch only the percentage threshold are not enough either: in the fast phase, the ten-percent threshold is crossed twice within four hundred milliseconds. What is sufficient is a time threshold; a two-second interval brings the announcement count down to five and pulls the unreadable-announcement count to zero. Applied together with the percentage threshold, the result stays the same, because the binding condition is time.
Measurable Constraints
4.1.3 Status Messages. A progress notification must be announced without moving focus; the announcement is made from a polite region next to the indicator, not the indicator itself, and its frequency is bound by the policy above.
2.2.2 Pause, Stop, Hide. An indicator that moves on its own for longer than five seconds must be able to be paused or hidden. The indeterminate indicator’s spinning motion falls under this criterion; reducing the motion with a reduced-motion query is the cheapest way to meet the criterion — a pulsing or entirely still indicator is used in place of motion.
2.3.1 Three Flashes. The indicator must not flash more than three times per second. A fast-blinking loading animation can exceed this threshold.
1.4.11 Non-Text Contrast. The ratio between the progress bar’s filled portion and its empty channel must be at least 3:1; the bar itself must also be distinguishable against the surrounding surface. A thin bar distinguished only by a light and a dark tone misses this threshold.
2.5.8 Target Size (Minimum). The cancel button must meet the 24 x 24 pixel threshold.
Common Mistakes and How to Recognize Them
Writing zero on an indeterminate indicator. An indeterminate indicator carrying
aria-valuenow="0" broadcasts “made no progress at all”. How to recognize it: the value
staying fixed at zero while progress continues.
Making the indicator itself a live region. An announcement is produced on every value change. How to recognize it: compare the announcement count against the step count; if they are equal, there is no policy.
Showing remaining time second by second. The number jumps back and forth and erodes trust. How to recognize it: watch whether the remaining-time field ever increases; if it increases even once, the presentation is defective.
Manufacturing a percentage from time. Producing a percentage from elapsed time when the whole is unknown produces a bar that sticks at ninety percent. How to recognize it: check whether the indicator’s value is fed by an actual measurement or by a timer.
Summary
- An indeterminate indicator reports only that a task is running; a determinate indicator
reports its completion ratio; indeterminacy is expressed by the absence of the
aria-valuenowfield, not by writing zero. - The indicator is not interactive and does not take focus; the keyboard contract lives in the cancel button and in preserving focus in the section the indicator replaces.
- The percentage is reliable and never decreases; remaining time is an estimate, and both methods in the measurement produce deviation on the order of seconds, with the weighted method growing even worse during a stall.
- When the estimate is presented rounded to buckets that never increase, screen changes drop from forty-four to six; the cost is a larger deviation, and it is paid deliberately.
- Progress announcements are made from a separate polite region, not the indicator, and are bound to a time threshold; a percentage threshold alone produces unreadable announcements during fast phases.
- The pattern is tested against the 4.1.3, 2.2.2, 2.3.1, 1.4.11, and 2.5.8 criteria; an indicator that honors the reduced-motion preference is the cheapest path to conformance.
Next Step
This lesson’s indicator said “I am working” but did not hold the place of the content to come. When the content arrives and everything beneath it gets pushed down, the user loses the point they were looking at; measured, this push turns into a number. The next lesson writes the space-holding loading form — the skeleton screen — as a specification: how the skeleton must appear in the accessibility tree, how shift accumulates when several regions refresh instead of one, and what cost the skeleton’s promised structure being wrong produces.
To keep your progress and take notes, Log in
My notes
Log in to take notes.