Progress & Spinners
Spinners and progress bars tell people the app is working and how long it will take. Learn determinate vs indeterminate, when to show them (and when not), why long work needs Cancel, and the flashing, lying and never-ending indicators testers find.
You’re exporting a year of bank statements for your tax advisor. The bar shoots to 99% in ten seconds… and stays there. One minute. Five minutes. There’s no Cancel, and the sheet covers the whole app. You force-quit, open the app again and start over. Later you learn the export had failed at the very first second: the bar was just a timer.
Progress indicators are tiny, and they’re everywhere: behind every login, upload, payment and export. Their job is to answer two questions honestly: “Is it still working?” and “How long will it take?” When they lie, flash or never stop, users lose trust in everything else. Let’s learn how to test them.
What is it?
A progress indicator shows that the app is busy with something the user is waiting for. It comes in two kinds:
- A spinner (also called an activity indicator or loader) says: “I’m working, I don’t know how long it takes.”
- A progress bar says: “I’m working, and I’m this far.”
It’s one form of the loading stateLoading stateWhat the UI shows while it waits for data or an action to finish, such as a spinner or skeleton.Open in glossary →. Two close relatives have their own pages: the loading state inside a button is covered on Button, and skeletons for loading whole screens are on Empty & error states. This page is about the indicators themselves and their timing.
Anatomy
A determinate bar with label, value and Cancel, and an indeterminate spinner
- 1
Label
Says what is happening: “Uploading 3 of 8 photos”, not just “Loading”. Screen readers need it too.
- 2
Active indicator
The filled part. In a determinate bar its length matches the real progress and only ever grows.
- 3
Track
The full length, so people can judge how much is left. Needs 3:1 contrast against the background.
- 4
Stop indicator
Material 3 adds a small dot at the end of the track so the end stays visible.
- 5
Value and time left
“38% · about 2 min left”. Optional, but very welcome for anything longer than ~10 seconds.
- 6
Cancel
A way out of long work. Cancel must really stop the work, not just hide the bar.
- 7
Spinner
Indeterminate: “working, no idea how long”. Fine for a few seconds, never on its own for minutes.
Determinate vs indeterminate
This is the most important distinction on the page:
| Determinate | Indeterminate | |
|---|---|---|
| Shows | How much is done (38%, 3 of 8) | Only that something is happening |
| Looks like | A bar or ring that fills up | A spinning wheel or a looping bar |
| Use when | The app can measure progress: uploads, downloads, exports, installs | The app can’t: waiting for a server answer, signing in |
| Rule | Must only move forward and match reality | Must end in seconds, not minutes |
An indeterminate indicator can switch to determinate once the app knows the size of the work: “Preparing…” with a spinner, then “Uploading 1 of 8” with a bar. The other way round (a bar that turns into an endless spinner at 99%) is a classic bug.
Linear vs circular
The shape is a layout choice, not a meaning:
- Linear (a bar) fits in a row, a card, under a toolbar or at the top of a web page. It has room for a label and “2 min left”.
- Circular (a spinner or ring) fits in small, square places: a button, a list row, the middle of an empty screen, a download icon.
Both shapes exist in both kinds: a determinate ring (like a download in an app store) and an indeterminate bar (like the thin moving line under a browser’s address bar).
How it differs from similar components
| Component | What it shows | Example |
|---|---|---|
| Progress bar / spinner | The app is working right now | “Uploading 3 of 8 photos” |
| Skeleton | Content is coming, in its future layout | Grey rows on a feed while it loads |
| Stepper / steps | Where the user is in a multi-step task | “Step 2 of 4: Delivery” |
| Meter / gauge | A measured amount, not work in progress | Storage 80% full, password strength |
| Toast | The result, after the work is done | “Album exported” |
Dev note
On iOS: UIActivityIndicatorView (spinner) and UIProgressView (bar) in UIKit, and ProgressView in SwiftUI, which is a spinner without a value and a bar with one. On Android (Material 3): LinearProgressIndicator and CircularProgressIndicator, determinate when you pass a progress value; newer Material 3 Expressive versions add a “wavy” style. On the web: the native <progress> element, or any element with role="progressbar". Don’t confuse <progress> with <meter>, which is a gauge (disk space, a score), not work in progress.
Designs & variants
The determinate indicators replay a fake 6-second upload. Pause it to compare them at the same value.
Activity indicator
iOSUIActivityIndicatorView or SwiftUI ProgressView(). Indeterminate only. Medium and large sizes; it hides itself when stopped by default.
Progress view
iOSUIProgressView or SwiftUI ProgressView(value:). A thin determinate bar, often under a label like a file name.
Uploading 0 of 8 photos
Syncing contacts…
Linear progress indicator
Android · Material 3Determinate (top) shows the gap and the stop dot; indeterminate (bottom) loops. Compose LinearProgressIndicator.
Circular progress indicator
Android · Material 3Determinate ring (left) and indeterminate (right). Good for small spaces: a card, a button, a download icon.
Native <progress>
WebThe browser draws it, so it looks different in Chrome, Safari and Firefox. Without a value it is indeterminate. It already has the progressbar role.
Importing photos0 of 8
Custom role="progressbar"
WebA styled div needs role, aria-valuenow/min/max, a label and ideally aria-valuetext (“5 of 8 photos”).
Inside a button
AllA small spinner replaces the label while an action runs. Its rules (block repeat taps, always end) are on the Button page.
Skeleton instead of a spinner
AllFor loading a whole list or screen, a skeleton in the content’s layout often feels faster. See Empty & error states.
When to use it
Show a progress indicator when the user asked for something and is waiting for it:
- Waiting for a server: signing in, placing an order, searching, loading a screen.
- Moving data: uploading photos, downloading a file, syncing, backing up.
- Long local work: exporting a video, applying a filter, installing an update.
How long the wait is decides what to show. These limits come from decades of usability research (Jakob Nielsen’s classic 0.1 s / 1 s / 10 s response times) and are still the best rule of thumb:
| Expected wait | What to show |
|---|---|
| Under ~0.1 s | Nothing. It feels instant. |
| ~0.1 to 1 s | Usually nothing, or a subtle change (the button dims). A spinner would only flash. |
| 1 to 10 s | A spinner or a skeleton, with a short label. |
| Over ~10 s | A determinate bar, what is happening, ideally time left, and Cancel. Let people keep using the app if possible. |
Good and bad uses
Nothing for fast actions
Tapping a heart to like a photo updates it immediately (Optimistic UIOptimistic UIUpdating the screen immediately, before the server confirms, and rolling back if the request fails.Open in glossary →). If the request fails, the heart reverts with a message.
A full-screen spinner for every tap
Every tap on a statement shows a dark overlay with a spinner for 60 ms. Users see the screen flicker, and the app feels slower than it is.
A real bar for long, measurable work
“Exporting 7 of 12 statements · 58% · about 1 min left”, with Cancel.
A spinner for minutes
A spinner for a two-minute export. After 30 seconds, people can’t tell whether it’s still working or stuck, so they quit and start again.
Indeterminate when you can't measure
“Signing in…” with a spinner. The app has no idea whether the server takes 300 ms or 3 s.
Fake determinate
A bar that fills on a timer, unrelated to the real work. It reaches 99% and waits, and on slow connections it lies every time.
How good progress indicators behave
They don’t flash
An indicator that appears and disappears within a few frames is worse than none: the screen flickers, layouts jump, and the app feels slower. Good apps use two timers together:
- Delay before showing (about 300–500 ms). Start the work, but only show the spinner if it isn’t finished yet after the delay. Fast answers never show an indicator at all. The action is still blocked during the delay, so a second tap can’t send a second request (see double submitDouble submitA bug where an action runs twice because the user tapped or clicked twice quickly.Open in glossary →).
- Minimum display time (about 500 ms or more). Once the spinner is visible, keep it for at least this long, even if the work finishes right after. People see a calm “it was loading, now it’s done” instead of a flicker.
The exact numbers vary by team and design system. What you test is the behavior: no flashes on a fast network, an indicator within about a second on a slow one.
Long work shows real progress, and can be canceled
For anything that can take more than about ten seconds:
- Use a determinate bar if the work can be measured, with a label (“Uploading 3 of 8 photos”) and, ideally, time left.
- Offer Cancel, and make it real: it stops the upload or export on the device and the server, leaves no half-finished files, and says what happened (“Export canceled”).
- Don’t block the whole app if you don’t have to. Let the export run while the user keeps browsing, and tell them when it’s done.
- Check what happens if the user leaves: the back gesture, switching apps, locking the phone. Does the work continue, pause, or silently die? See Background & foreground.
Progress tells the truth
A determinate indicator makes a promise. Test that it keeps it:
- It only moves forward. A percentage that jumps from 46% to 31% usually means the app divided “done so far” by a total that changed.
- It reaches 100% when the work is done, and the work is really done then (the file is in the folder, the order exists).
- It doesn’t race to 99% and wait. Many apps fake the bar on a timer and only finish when the server answers. If that answer is an error, the bar sits at 99% forever.
- The label matches the bar: “7 of 12” next to 30% is a contradiction.
- Time left is roughly right and goes down, not “about 2 min left” for five minutes.
It always ends, including on errors
Every indicator needs an exit for each outcome:
| Outcome | What the user should see |
|---|---|
| Success | The indicator goes away, the result appears, and it’s announced |
| Error | The indicator stops, and an error messageError messageText that tells the user something went wrong and, ideally, how to fix it.Open in glossary → explains what happened, with Retry |
| No answer | After a timeoutTimeoutThe time limit after which an app stops waiting for a response and gives up.Open in glossary →, the same as an error: “This is taking too long. Try again.” |
| Canceled | The indicator goes away immediately, and the app says what was (not) done |
For long work, a good timeout watches for no progress rather than total time: a 30-second export that keeps moving is fine, an upload stuck at 35% for 8 seconds is not. Many apps first say “This is taking longer than usual…” and then give up with an error. The pages on Empty & error states and Network conditions cover the error side in depth.
Try it: timing lab
A correct photo export with knobs for every timing rule. Pick a duration, tap Export album, and read the Last run timeline after each run. Some things to try:
- 100 ms with everything off: the timeline says “Flash!”. Now turn on the delay (no indicator at all), or instead the minimum display (it stays 500 ms).
- 30 s with the spinner, then with the progress bar. Hide Cancel and feel what it’s like to be stuck.
- Never finishes and Fails: watch how the indicator ends, and what VoiceOver hears.
‹ Albums
Lisbon, May
24 photos · 186 MB
How long the export takes
Change it, then tap Export album.
Delay before showing
Easiest to see with 100 ms: the indicator never appears.
Minimum display
Matters for fast work: try 100 ms with the delay off.
Indicator
Cancel button
Last run
Tap Export album to record a timeline.
VoiceOver hears
- Announcements appear here…
Common bugs
| # | Bug | What you see | Typical severity |
|---|---|---|---|
| 1 | Endless spinner | On an error or no answer, it never stops; no message, no Retry | High |
| 2 | Stuck at 99% | A faked bar waits for the server; on failure it waits forever | High |
| 3 | Flashing indicator | A spinner or overlay blinks for a few frames on every fast action | Low–Medium |
| 4 | Progress goes backwards | 46% then 31%, or “time left” goes up | Medium |
| 5 | No Cancel on long work | A 2-minute export or upload with no way out but force-quitting | Medium–High |
| 6 | Cancel doesn’t cancel | The bar disappears, but the upload continues or the order is placed anyway | High |
| 7 | Spinner for minutes | Long, measurable work with no progress, so users quit and retry | Medium |
| 8 | Indicator blocks everything | A full-screen overlay for a small background task | Medium |
| 9 | Silent for screen readers | Spinner has no label; “Done” or “Failed” is never announced | High (for affected users) |
| 10 | Layout jump | The page shifts when the indicator appears or disappears, so taps land on the wrong thing | Low–Medium |
Find the bug
This banking app has six bugs in its loading and progress indicators. Time runs 8× faster than in the real app, so the “2-minute” export takes about 15 seconds. Explore it like a tester:
- Open a few statements and watch the screen closely while you tap.
- Export 2025 and watch the percentage and the time left the whole way. Is there a way out?
- Turn on server errors, then export again and refresh the balance.
- Turn on the VoiceOver caption panel and finish an export. Read the panel.
🐞 Bug hunt: Banking app
This demo has 6 hidden bugs. Explore it like a tester, then check your findings.
Accounts
Everyday account
€2,481.36
Updated 9:12
Statements
Device
Time runs 8× faster in this demo: the export takes about 2 minutes in the real app, about 15 seconds here. The caption panel shows the last thing VoiceOver said.
Network log
- Use the app to see API requests…
- 1
Hint: Tap a statement and watch the whole screen. Do it a few times.
- 2
Hint: Export 2025 and keep your eyes on the number between 40% and 50%.
- 3
Hint: Start the export and try to stop it or to do anything else.
- 4
Hint: Turn on server errors, export, and wait well past the end. Check the network log.
- 5
Hint: With server errors on, tap the refresh icon next to the balance.
- 6
Hint: Turn on the VoiceOver caption panel, export with server errors off, and read the last caption after the check mark appears.
How to test it
Checklist
Progress indicator test checklist
0 of 14 checked
Example test cases
TC-PRG-001No flashing spinner on a fast responsepositivemedium
Preconditions
- Fast Wi-Fi
- Screen recording on (to check frame by frame)
Steps
- Open the Statements list
- Tap a statement
- Repeat 5 times
Expected result
The statement opens without any spinner or overlay appearing. The recording shows no flicker.
TC-PRG-002Slow response shows a labeled indicatorpositivehigh
Preconditions
- Network throttled to "3G" or a 3 s delay added with a proxy
Steps
- Tap a statement
- Watch the screen until it loads
Expected result
Within about 0.5–1 s an indicator with a label appears, stays until the data is shown, and then disappears. Tapping again during loading sends no second request.
TC-PRG-003Export progress is monotonic and finishes at 100%positivehigh
Steps
- Start the 2025 export
- Record the screen until it finishes
- Open the Files app
Expected result
The percentage never decreases and agrees with "N of 12". It reaches 100% only when all 12 PDFs are in Files.
TC-PRG-004Cancel stops a long exportpositivehigh
Steps
- Start the 2025 export
- At about 40%, tap Cancel
Expected result
The bar disappears immediately, "Export canceled" is shown and announced, no further export requests appear in the proxy, and no partial files are left in Files.
TC-PRG-005Server error ends the progressnegativehigh
Preconditions
- Proxy returns HTTP 500 for GET /api/exports/*
Steps
- Start the 2025 export
- Wait until the request fails
Expected result
The bar stops (not at a fake 99%), an error explains the export failed, and Retry and Close work.
TC-PRG-006No answer at all ends in a timeoutnegativemedium
Preconditions
- Proxy breakpoint holds the balance request and never releases it
Steps
- Tap Refresh balance
- Wait 60 seconds
Expected result
After a reasonable time the spinner stops and a message offers to try again. The old balance and its "Updated" time stay visible.
TC-PRG-007Screen reader hears progress and the resultpositivehigh
Preconditions
- VoiceOver (iOS) or TalkBack (Android) on
Steps
- Start the 2025 export
- Do not touch the screen until it finishes
Expected result
The start is announced, progress is announced occasionally (not every percent), and "Export complete" (or the error) is announced at the end without moving focus.
TC-PRG-008Leaving the screen during an uploadedgemedium
Steps
- Start uploading 8 photos
- Switch to another app for 30 s
- Come back
Expected result
The upload continued (or is clearly paused with Resume). Progress is correct, no photo is uploaded twice.
Tip: click a test case to expand it.
Tools
- Slow it down: network throttlingNetwork throttlingDeliberately slowing the connection to test how an app behaves on poor networks.Open in glossary → (Network Link Conditioner on iOS, the emulator’s network settings on Android, the “Fast 4G”, “Slow 4G” and “3G” presets in Chrome DevTools) shows you the indicator at all. Fast office Wi-Fi hides most of these bugs.
- Break it on purpose: a proxy tool like Charles or Proxyman can return a
500, add a long delay, or hold a request with a proxy breakpointProxy breakpointPausing a request or response in a proxy tool so you can edit it before it continues.Open in glossary → so it never answers. That’s how you find endless spinners and the 99% bug. - Catch flashes: record the screen and step through frame by frame (QuickTime, the iOS screen recorder,
adb shell screenrecord). A 60 ms flash is three or four frames. - Check it’s real: after “100%”, check the result exists (the file in Files, the order in the history). After Cancel, check the proxy log: did requests really stop?
- Web: DevTools › Network shows request timing; the Performance panel’s screenshots show flashes and layout shifts.
Accessibility
- Give it a name. A spinner alone means nothing to a screen readerScreen readerSoftware that reads the screen aloud for blind and low-vision users, like VoiceOver or TalkBack.Open in glossary →. Label it with what’s happening: “Loading statements”, “Uploading 3 of 8 photos”.
Dev note
On the web, the native<progress>has the progressbar role built in; a custom bar needsrole="progressbar", a label (aria-labeloraria-labelledby),aria-valuemin,aria-valuemaxandaria-valuenow. Addaria-valuetextwhen a number isn’t the clearest wording (“5 of 8 photos”). For an indeterminate bar, leave outaria-valuenow.aria-busy="true"on a list or region that’s being updated tells assistive technology to wait until it’s done. - Announce the result. Screen readers usually don’t read every change of a progress bar unless it has focus, and reading every percent would be unbearable anyway. Announce the start, maybe a few milestones for long work, and always the end: “Export complete”, “Upload failed”. This is WCAGWCAGThe Web Content Accessibility Guidelines, the main international standard for accessibility.Open in glossary → 4.1.3 Status Messages (AA).
Dev note
On the web use a live region (role="status"), on iOS an accessibility announcement, on Android an announcement or a live region. - Don’t steal focus to announce progress. Focus should stay where the user is.
- reduced motionReduced motionThe OS setting, and the prefers-reduced-motion media query, that ask apps to minimize animation.Open in glossary →. A small loading spinner is usually considered essential motion and may keep turning, but large or decorative motion (bouncing loaders, shimmering skeletons, confetti at 100%) should calm down. Watch out for the opposite bug: some sites switch off all animation with Reduce Motion, which freezes a CSS spinner. That’s only OK if a text label still says “Loading…”.
- Contrast. The bar and its track are UI parts: at least 3:1 against the background (WCAG 1.4.11 Non-text Contrast, AA). A pale grey bar on white fails. Any percentage text needs 4.5:1 (WCAG 1.4.3 Contrast (Minimum), AA).
- Cancel must be reachable by keyboard, Switch Control and screen readers, not just by tapping a tiny “x”. At least 44×44 pt on iOS and 48×48 dp on Android. On the web, WCAG 2.5.8 (AA) asks for at least 24×24 CSS px; 44×44 px (2.5.5, AAA) is the comfortable target.
Mobile vs web
Dev note
UIActivityIndicatorView (medium and large styles) hides itself when stopped by default; UIProgressView is the determinate bar. In SwiftUI, ProgressView() is a spinner and ProgressView(value:total:) a bar.- There’s no built-in determinate ring in UIKit; rings like a download button are custom-drawn, so test them carefully (start, 100%, error, Cancel).
- Long uploads and downloads should survive the app going to the background. Test by switching apps and locking the phone mid-transfer.
Dev note
That’s a backgroundURLSession. - Test with VoiceOver: is the indicator labeled, and is the result announced?
- Material 3 linear and circular progress indicators: indeterminate by default, determinate when given a progress value. The linear determinate version has a gap and a small stop indicator dot at the end.
Dev note
LinearProgressIndicatorandCircularProgressIndicator. - Long work is often a foreground service with a progress notification (for example a download). Check the notification updates, offers Cancel, and ends with success or failure, not a frozen bar.
- Rotation and process death can restart the work or lose the indicator. Rotate mid-upload; try Developer options › Don’t keep activities.
- Old apps may still use the legacy
ProgressBarwidget; check it in dark mode.
- The native
<progress>looks different in each browser; check it in Chrome, Safari and Firefox (see Cross-browser testing). Custom bars need the ARIA attributes above. - Throttle with DevTools’ “Fast 4G”, “Slow 4G” and “3G” presets to see the indicator, and use the Performance panel to catch flashes and layout shifts (Core Web VitalsCore Web VitalsGoogle's key page experience metrics: loading (LCP), responsiveness (INP) and visual stability (CLS).Open in glossary →: CLS).
- Check Cancel really aborts the upload request in the Network panel (it shows as “(canceled)”).
Dev note
Uploads withfetchcan be canceled with anAbortController. - Reloading or closing the tab during an upload: does the browser warn you (“Leave site?”), and is the work resumed or clearly lost?
Check your understanding
Quick quiz
0/6 answered · 0 correct
1.An export takes about two minutes and its progress can be measured. What should the user see?
2.Saving a setting takes 80 ms and a spinner blinks briefly every time. What is the best fix?
3.A progress bar goes 44%, 46%, 31%, 35%... What kind of bug is this?
4.The final request of an upload fails. Which result is correct?
5.Which web markup describes a custom determinate progress bar correctly?
6.A blind user starts an export. The screen shows a check mark when it is done. What else is needed?
Key takeaways
- Determinate shows how far (bar or ring); indeterminate only says “working” (spinner or looping bar). Linear vs circular is just layout.
- Match the indicator to the wait: nothing under ~1 s, a spinner for a few seconds, a determinate bar with Cancel for anything over ~10 s.
- Don’t flash: a delay before showing plus a minimum display time.
- Progress must tell the truth: only forward, 100% means done, no faking to 99%.
- Every indicator ends: on success, error, timeout and Cancel. Test with slow and broken networks.
- Label it, announce the result (WCAG 4.1.3), keep 3:1 contrast, and don’t rely on motion alone.
Finished this page?Nice work, this page is complete!
Mark it as done to track your progress. It's saved in this browser, no account needed.
Its review cards are now in Bites ⚡