---
title: 'Loading Indicators'
source: 'https://academia.sh/en/courses/accessible-patterns/loading-indicators'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:52+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.
