How to Add a Loading Animation to an App or Website
By the Charmotion team · Updated

To add a loading animation, decide first how long the wait is: show nothing under about a second, a looping spinner or character for roughly 2–10 seconds, and a percent-done bar beyond that. Then render it only while work is pending, announce it with role="status", and respect reduced-motion settings.
Key takeaways
- Match the indicator to the wait: none under about 1 second, a loop for 2–10 seconds, a progress bar for 10 seconds or more.
- Delay the loader slightly so fast responses never flash a spinner.
- Animate only
transformandopacityso the loader stays smooth while the main thread is busy. - Pair the visual with
role="status"text andaria-busyso screen reader users know something is happening. - A branded character works as a looping indicator; it does not replace a progress bar on long tasks.
Already have a character or logo? Preview your own character animated in a phone splash, a website hero and an empty state — free, in your browser, nothing uploaded.
Spinner, skeleton, progress bar or branded loader: which one?
Pick the indicator from the expected wait, then from the context. Nielsen Norman Group's guidance gives the time bands: no indicator for actions under about one second, a looped indicator such as a spinner for waits of roughly 2–10 seconds, and a percent-done indicator for anything that takes 10 seconds or more (NN/g). Those bands rest on Nielsen's response-time limits: 0.1 second feels instant, 1 second keeps the user's flow of thought, and 10 seconds is about the limit of attention.
| Situation | Use |
|---|---|
| Under ~1 second | No loader. Give immediate feedback on the control (pressed state, disabled button). |
| Page or list whose layout you already know | A skeleton: gray placeholders in the shape of the content, replaced in place. |
| 2–10 seconds, unknown progress | A looping spinner or a looping character. |
| 10+ seconds, or measurable work (uploads, exports) | A percent-done progress bar, optionally with a character beside it. |
Skeletons and spinners are not rivals. A skeleton describes where content will appear; a spinner or character says that work is happening. Many screens use a skeleton for the first page load and a small spinner for later actions like "load more." For the timing details behind these bands, see animation durations and loading UX.
How do you build a CSS-only loader?
A CSS spinner needs one element, one keyframe rule and no JavaScript. Rotate it with transform: web.dev recommends restricting animations to transform and opacity because they stay on the compositing stage, so the spinner keeps turning even while scripts run (web.dev).
<div class="loader" role="status">
<span class="spinner" aria-hidden="true"></span>
<span class="visually-hidden">Loading results…</span>
</div>
<style>
.spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2.4s; }
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
</style>
Give the spinner a fixed width and height so it never shifts the layout when it appears. Use currentColor so it inherits the text color in both light and dark themes.
A skeleton is equally simple: blocks with a fixed size and a background color, optionally with a shimmer made from an animated gradient moved with transform. Keep the shimmer subtle, and drop it under reduced motion.
How do you show a branded character while loading?
A character is a looping indicator with personality, so it belongs in the same 2–10 second band as a spinner. It works best in places users already pause: generating an image, sending a message, syncing a library.
With a Rive file, the loader is one state of a state machine rather than a separate asset. You set a number to the loading mood while work runs and fire a success or error trigger when it ends, so the same character also handles the result. On the web:
import { Rive } from "@rive-app/webgl2";
const r = new Rive({
src: "/character.riv",
canvas: document.querySelector("#mascot"),
stateMachine: "CharacterStateMachine",
autoplay: true,
onLoad: () => {
r.resizeDrawingSurfaceToCanvas();
runWithMascot(); // inputs are available once the file has loaded
},
});
function setMood(n) {
const mood = r.stateMachineInputs("CharacterStateMachine").find((i) => i.name === "mood");
if (mood) mood.value = n;
}
function fire(name) {
r.stateMachineInputs("CharacterStateMachine").find((i) => i.name === name)?.fire();
}
async function runWithMascot() {
setMood(5); // loading loop
await doWork().then(() => fire("success"), () => fire("error"));
setMood(0); // back to idle
}
Charmotion files are built for exactly this: in a set generated from your own mascot, mood 5 is the loading loop and mood 3 is thinking, with success and error triggers that return to the current mood. The same states are also exposed through a Character view model for Data Binding. If the task is long and measurable, keep a real progress bar next to the character.
How do you show a loader only while work is in progress?
Tie the loader to the request, not to a timer, and add a short delay before showing it. Because users need no indicator for actions under about a second, a delay of a few hundred milliseconds to one second hides the loader on fast responses and prevents a distracting flash.
async function withLoader(region, task, delay = 600) {
region.setAttribute("aria-busy", "true");
const timer = setTimeout(() => region.classList.add("is-loading"), delay);
try {
return await task();
} finally {
clearTimeout(timer);
region.classList.remove("is-loading");
region.setAttribute("aria-busy", "false");
}
}
The finally block matters: errors and cancellations must remove the loader too, or the screen spins forever. In React, the same idea is a pending state set before the request and cleared in finally, with the loader rendered conditionally.
On native platforms the built-in components are the default choice. SwiftUI has ProgressView() for an indeterminate spinner and ProgressView(value:) for a determinate bar; Jetpack Compose has CircularProgressIndicator and LinearProgressIndicator with or without a progress value. Swap in a character view only where the brand moment is worth it, using the same pending flag to show and hide it.
How do you make a loader accessible?
Three things make a loader work for everyone:
- Announce it. Put short text such as "Loading results…" inside an element with
role="status", a live region that screen readers read politely without moving focus (MDN). Hide the decorative spinner or character witharia-hidden="true". - Mark the region busy. Set
aria-busy="true"on the container being updated and back tofalsewhen it is done, so assistive technology can wait for the final content. - Respect reduced motion. The
prefers-reduced-motion: reducemedia query reflects the operating system's reduce-motion setting (MDN). Slow the spinner, replace a bouncing character with a static pose plus text, or show a simple progress bar. In SwiftUI, read@Environment(\.accessibilityReduceMotion).
WCAG 2.2 SC 2.2.2 (Pause, Stop, Hide) normally requires a way to pause motion that runs longer than five seconds alongside other content. It notes that an animation during a preload phase can count as essential when no interaction is possible at that point (W3C). A loader beside content the user can still use does not qualify, so stop it when the work ends. More in accessible animation and reduced motion.
What are the most common loading animation mistakes?
- A loader for instant actions. A spinner that appears for 150 ms reads as a glitch. Delay it.
- A spinner on a two-minute export. Past about 10 seconds users want to know how much is left. Show a percent-done bar or a step count.
- Full-screen blockers for partial updates. If only a list is refreshing, put the indicator in the list and keep the rest of the screen usable.
- Layout shift. A loader that pushes content down when it appears, then pulls it back, is worse than none. Reserve its space or overlay it.
- Heavy loader assets. A large GIF or video loader can take longer to download than the work it covers. A CSS spinner is a few lines; a small Rive file is typically tens of kilobytes.
- Stuck states. Always clear the loader on errors and timeouts, and replace it with a clear message and a retry action.
If the character also shows success and error states, see mascot animation states for mapping events to reactions.
How to add a loading animation
- Estimate the wait. Measure typical durations. Under about 1 second needs no loader, 2–10 seconds a looping indicator, 10 seconds or more a percent-done bar.
- Pick the indicator. Use a skeleton where the layout is known, a spinner or character loop for unknown short waits, and a progress bar for measurable work.
- Build it with cheap properties. Animate only
transformandopacityin CSS, or use a small Rive file with a loading mood for a character. - Bind it to the request. Show the loader after a short delay while the request is pending, and remove it in a
finallyblock so errors clear it too. - Make it accessible. Add
role="status"text, togglearia-busy, hide the decorative motion from screen readers, and slow or replace it under reduced motion.
Frequently asked questions
How long should I wait before showing a loading spinner?
Wait long enough that fast responses never show it. Nielsen Norman Group advises no indicator for actions under about one second, so a delay between a few hundred milliseconds and one second works well. Always cancel the delay when the request finishes first.
Is a skeleton screen better than a spinner?
Neither is universally better. A skeleton suits content whose layout you already know, such as a feed or a product grid, because the page appears to fill in place. A spinner suits actions with no predictable layout. Many apps use a skeleton on first load and spinners afterward.
Can a mascot replace a progress bar?
Not for long tasks. A looping mascot says work is happening but not how much remains. Use it for waits of about 2–10 seconds, and for 10 seconds or more show a percent-done bar, optionally with the mascot beside it.
Should a loader stop under reduced motion?
It should calm down rather than disappear, because users still need to know work is in progress. Slow the rotation, switch a bouncing character to a static pose, or show a progress bar, and keep the role="status" text so the state is still announced.
Quick answers from this guide
Sources
- Progress Indicators Make a Slow System Less Insufferable — Nielsen Norman Group
- Response Times: The 3 Important Limits — Nielsen Norman Group
- How to create high-performance CSS animations — web.dev
- ARIA: status role — MDN Web Docs
- aria-busy — MDN Web Docs
- prefers-reduced-motion — MDN Web Docs
- Understanding SC 2.2.2: Pause, Stop, Hide — W3C WAI
- Web (JS) runtime — Rive