Loading animations for websites that don't feel slow

A loading animation for a website should show real progress, or the picture itself. Examples with timings, and how to keep a loader from delaying LCP.

A thin ring of polished glass floating on pure black, three quarters of its circumference glowing turquoise and the last quarter still dark, as if filling toward completion.
Count what has arrived, never a timer.

A loading animation for a website is worth adding only when there is a real wait to cover, and on most pages the best one is the picture itself: the hero film's own frame, on screen from the first paint, handing over to the moving film when it can play. A full-screen loader earns its place in one case, a 3D scene or a set of large assets the page cannot work without, and then it should count what has actually arrived.

The guidance on how long people will wait is old and still holds. Jakob Nielsen's limits from 1993 put a feeling of instant response at 0.1 seconds, an uninterrupted flow of thought at 1 second and the edge of attention at 10 seconds (Nielsen Norman Group). NN/g's guidance on progress indicators adds that a looped animation is distracting for anything under a second, suits waits of 2 to 10 seconds, and should give way to a percent-done indicator beyond that (Katie Sherwin, NN/g).

A loader cannot paint before the HTML and CSS that contain it, so it never hides a slow server; it only hides a page that has already arrived. That is the first thing to keep in mind, and the reason most of the examples below are hand-overs rather than loaders.

When a website needs a loading animation#

The waitWhat causes itWhat to show
The first paintthe server, render-blocking CSSnothing; make the page faster
A hero filma video that cannot paint yetthe film's own frame as a poster, then a crossfade
Web fontsdisplay faces still downloadingthe fallback face, with the entrance waiting for the real one, capped
A 3D scene or image settextures and images the scene needs wholea percentage that counts real arrivals
An actiona search, a submit, a paymenta spinner for 2 to 10 seconds, a percent bar beyond
A route changethe next page loadinga short view transition, or nothing

Two rows deserve a comment. Web fonts are the wait people forget: an entrance that runs before the display face arrives animates the fallback, then the real face swaps in and the lines rebreak. Waiting for document.fonts.ready, with a cap of about 1200ms to 1500ms, fixes it without holding the page. The action row is where spinners belong, and it is the only row where NN/g's 2-to-10-second band applies directly.

Everything else on a marketing page should be visible at once, at full contrast, with motion layered on top once it can run. The hero section guide covers what that first screen should contain; this page covers the moment before it is complete.

The picture as the loader#

Strix covers its film with a poster that is one exact frame of that film, the owl in its hi-vis jacket, and keeps it there until the first seek to that frame has landed with a painted picture: the seeked event with a readyState of 2 or more. Only then does the root get its ready class, and the poster fades out over 250ms, linear, so the owl never jumps.

Zoomies goes one step earlier. Before even the poster paints, the page's ground is #8FACC9, the poster's own sky, so the first frame the browser draws is already the right color. The film stays transparent until its loadeddata event and then simply cuts in, which nobody can see, because the poster is the film's own first frame. The copy waits for the fonts, or 1500ms, whichever comes first.

Lorikeet puts the film's first frame, an empty forest, on screen from the first paint as its plate. The word and the bird cut-out wait at zero opacity and the copy waits off screen, and the entrance is timed to the film itself, starting at 1.25 seconds of playback, so motion and picture can never fall out of step.

The pattern is the same in all three. The reader sees a finished picture immediately, the hand-over is triggered by an event that proves the next state can paint, and nothing on screen ever reports a wait. Hero films get their own treatment in the video background guide.

What a loader costs in Core Web Vitals#

Largest Contentful Paint is the Core Web Vital a loader can damage. web.dev asks for an LCP of 2.5 seconds or less at the 75th percentile of visits, and lists what counts: images, the poster or first frame of a <video> (whichever paints earlier), elements with a url() background, and blocks of text. It also lists what Chromium leaves out, and the list reads like a description of a preloader:

  • elements with an opacity of 0, invisible to the user;
  • elements that cover the full viewport, likely treated as background;
  • low-entropy placeholder images that do not reflect the content.

So a hero held at opacity: 0 behind a full-screen loader is not a candidate for as long as the loader stays, and the reader sees nothing of the page meanwhile. A poster that is the film's real frame is the opposite case: a full, detailed image that can count from the moment it paints.

Layout shift is the other metric to watch. web.dev's guide to CLS asks for a score of 0.1 or less and points out that transform animates elements without triggering layout shifts, which is why every entrance below moves with transform, translate, scale and opacity. Reserve every box before anything loads, with width and height on images and aspect-ratio on the video and canvas, and the entrance becomes the only movement on screen. The rest of the motion budget is in the animated website guide.

Wait for decode, then enter#

When the first screen is made of cut-outs rather than a film, the equivalent of the poster is a decoded image. HTMLImageElement.decode() returns a promise that resolves once the image is ready to paint without a stall, which makes it the right gate for an entrance.

Caravan, a chrome caravan floating in a hot-pink studio, waits for the three images that make up the caravan (the craft and two lit versions of it) to decode, or for 2500ms, whichever comes first. Then the scene fades in over 1300ms and the floating caravan settles from 4% higher over 1800ms, both on cubic-bezier(0.16, 1, 0.3, 1), while the copy rises 18px over 700ms, starting 150ms in and 90ms apart. Grumps gates its entrance the same way, on its two web fonts and the first figure's decode, capped at 1500ms.

js
const ready = () => document.documentElement.classList.add("ready");
const art = [...document.querySelectorAll(".hero img")];
Promise.all(art.map((img) => img.decode().catch(() => {}))).then(ready);
setTimeout(ready, 2500); // the page never waits longer than this
css
.motion:not(.ready) .hero > * { opacity: 0; }
.motion .hero > * { transition: opacity 1300ms cubic-bezier(0.16, 1, 0.3, 1); }

The .catch(() => {}) matters: an image that fails still counts as done, so one broken file cannot hold the page. The motion class matters too, since it is added by a one-line script in the head only when scripts run and the reader has not asked for reduced motion. Without it, the hero is simply visible.

An honest percentage loader#

Kickflip is a bubblegum-pink 3D skate shop in WebGL, and a shop that cannot run half-loaded is the case where a full-screen loader is honest. Its loader is a 300px glass ring with the percentage in wide digits on a raspberry radial ground. The count eases toward the real share of what has arrived (twenty images, loaded or failed, and the fonts), is capped by elapsed time over 1200ms so a fast load still reads as a count, never goes backwards, and after 9s treats everything as arrived. At 100 it holds 200ms, then the ring scales to 1.06 and blurs 10px out over 600ms on cubic-bezier(0.25, 0.8, 0.35, 1) while the ground fades over 700ms. Under reduced motion it shows the real share and leaves in 250ms.

The code behind an honest count#

js
const loader = document.querySelector(".loader"); // role="progressbar"
const out = loader.querySelector("output");
const jobs = [...document.querySelectorAll("img[data-scene]")]
  .map((img) => img.decode().catch(() => {}));  // a failure counts as arrived
jobs.push(document.fonts.ready);
let done = 0, shown = 0;
jobs.forEach((job) => job.then(() => { done += 1; }));
const t0 = performance.now();

function tick(now) {
  const t = now - t0;
  const real = t > 9000 ? 1 : done / jobs.length; // stop waiting at 9s
  const target = Math.min(real, t / 1200);        // a count, even when cached
  shown = Math.max(shown, shown + (target - shown) * 0.15); // never backwards
  const pct = Math.round(shown * 100);
  out.textContent = pct;
  loader.setAttribute("aria-valuenow", pct);
  if (pct < 100) requestAnimationFrame(tick);
  else setTimeout(() => document.documentElement.classList.add("ready"), 200);
}
requestAnimationFrame(tick);

Give the loader role="progressbar", an aria-label, and aria-valuemin="0" and aria-valuemax="100", so a screen reader hears the same progress the digits show. Hide it in CSS unless the root has the js class, so a failed script can never leave a reader behind a ring that does not count.

Spinners, skeletons and route changes#

A spinner is for an action, not a page. Keep it small, linear and steady, and slow it down under reduced motion rather than stopping it, because a stopped spinner reads as a frozen page. WCAG 2.2.2 treats a preload animation as essential when nobody can interact during it and its absence would make the content look broken.

css
.spinner {
  width: 24px; aspect-ratio: 1; border-radius: 50%;
  border: 2px solid color-mix(in srgb, currentColor 20%, transparent);
  border-top-color: currentColor;
  animation: spin 800ms linear infinite;
}
@keyframes spin { to { rotate: 1turn; } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2400ms; } }

A skeleton belongs to interfaces whose layout is known before the data: dashboards, feeds, tables. Its boxes must match the final content's size exactly, or the skeleton itself causes the layout shift it was meant to prevent.

A route change can use the View Transitions API. Same-document transitions, started with document.startViewTransition(), work in Chrome and Edge from 111, Safari from 18 and Firefox from 144. Cross-document transitions, switched on with @view-transition { navigation: auto; }, work in Chrome and Edge from 126 and Safari from 18.2, and are still in preview in Firefox, according to MDN's compatibility data. Browsers without support simply navigate, which is the right fallback.

css
@view-transition { navigation: auto; }
::view-transition-old(root), ::view-transition-new(root) {
  animation-duration: 240ms;
  animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) {
    animation: none;
  }
}

What a prompt has to say about loading#

A model asked for "a cool loading animation" builds a full-screen spinner on a timer, because that is the commonest version it has seen. A prompt that produces the examples above states six things.

  1. What the page waits for, by name: the display face, the three hero cut-outs, the hero film's first frame, the scene's textures.
  2. The cap, in ms: 1200ms for fonts, 2500ms for an image entrance, 9s for a full loader.
  3. The poster and the ground: the film's own frame as the poster, and the page background set to that frame's dominant color.
  4. The hand-over event and its duration: seeked with readyState of 2 or more, or loadeddata, then a 250ms linear fade.
  5. The loader's honesty rules, if there is one: the real share of arrivals, failures counted, never backwards, a minimum count time, a timeout.
  6. The still and no-script states: under reduced motion, everything at rest at once; without scripts, no loader at all.
text
Loading: no full-screen loader. The hero poster is the film's first
frame and the page background is #8FACC9, its sky, until it paints.
The film is transparent until loadeddata, then fades in over 250ms,
linear. Headline entrance waits for document.fonts.ready, capped at
1200ms. Under prefers-reduced-motion the film never gets a source and
the poster is the hero. Without scripts everything is visible.

Loading animation mistakes to avoid#

  • A timer instead of a count. setTimeout(hide, 3000) makes a cached visit wait as long as a cold one.
  • A loader on every page. One heavy scene may need one; a site that shows a loader on every navigation tells the reader it is slow.
  • Hiding a page that has arrived. HTML and CSS that are already painted should be visible; animate on top of them.
  • Stalling at 99, or counting backwards. Keep the shown value monotonic, and count failed files as arrived.
  • No timeout. A lazy image that never loads can hold a loader forever. Stop waiting at a fixed time.
  • A spinner for a sub-second wait. NN/g calls it distracting; show nothing for anything that finishes inside a second.
  • Loader exits that shift the layout. Every box reserved, every move a transform, so the exit and the entrance are the only things that move.

Open a design that loads without a loader#

Most pages need no loader, a few need a hand-over, and one kind, a 3D scene, needs an honest count. Decide which before writing a line, and put the asset list, the cap and the hand-over event in the prompt. For heavier scenes, the Three.js examples cover what a WebGL page asks of the network, and entrance timings for the moment after loading are in website animation effects. Scroll-driven films load the same way as the hero films here, as the scroll examples show, and the rest of the cluster is on the inspiration hub.

Open Vesperion and copy its prompt; it is free with an account. Before the video has any data, its poster is drawn through the same matte as the film, so the creature standing in front of the word never flashes or disappears while loading; when the film first has data, the canvas crosses in from the poster over 320ms. Paste it into Claude or Cursor and check the first second of what comes back. Kickflip, Strix, Caravan and the other paid designs are on the pricing page, and the whole library is open to browse.

Questions this raises

How do I add a loading animation to my website?

Decide what the page is waiting for first. For a hero video, show the film's own first frame as its poster and crossfade to the film when it can paint. For a 3D scene, show a percentage that counts the textures and fonts that have actually arrived, never goes backwards, and gives up waiting after a few seconds. Most other pages need no loader at all.

Do loading screens hurt SEO?

They can hurt Largest Contentful Paint, which is part of Core Web Vitals. Chromium excludes elements with an opacity of 0 from LCP, so a hero held invisible behind a loader cannot count until the loader leaves. Paint the real hero image or poster first and animate on top of it, and keep LCP at 2.5 seconds or less for most visits.

How long should a loading animation last?

As long as the real wait and no longer. Nielsen Norman Group advises against looped animations for waits under one second, recommends them for 2 to 10 seconds, and recommends a percent-done indicator beyond 10. Cap any loader with a timeout, around 9 seconds, after which the page shows itself with whatever has loaded.

Is a spinner or a skeleton screen better?

A skeleton suits pages whose layout is known before the data arrives, such as dashboards and feeds, because the boxes hold the final size and nothing shifts when content fills them. A spinner suits a single action, such as a search or a form submission, that takes between two and ten seconds. A marketing page usually needs neither.

Part of

Inspiration, with the prompt

Examples of the looks and effects people search for, from 3D scenes to scroll animation, each one shown working and paired with the specification that builds it.