Skip to content
academia.sh

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-valuenow field, 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.

Start typing to search.

↑↓ Esc navigate · open · close