Loading State Motion

Loading state animation solves a psychological problem: a user who sees nothing happening assumes the app has frozen. The animation is proof that the system is working — even though it doesn’t actually make anything faster.

Click Load to see the skeleton → content transition (1.5s delay)

Nguyen Ha
9 May 2026

A spring animation doesn't behave like cubic-bezier. It settles when velocity approaches 0 — rather than ending exactly when the duration runs out.

Skeleton screen

Placeholder content shaped like the real content, with a shimmer animation.

.skeleton {
  background: linear-gradient(
    90deg,
    var(--skeleton-base)    0%,
    var(--skeleton-shimmer) 50%,
    var(--skeleton-base)    100%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
  border-radius: 4px;
}

@keyframes skeleton-shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
/* Dark mode tokens */
:root {
  --skeleton-base:    rgba(255, 255, 255, 0.06);
  --skeleton-shimmer: rgba(255, 255, 255, 0.12);
}

Shimmer direction: always left-to-right (the natural reading direction). A reversed or vertical shimmer is disorienting.

When to use a skeleton: when you know the content structure in advance (article, card, list). A skeleton beats a spinner because it reduces “layout shift” — when the data arrives, the skeleton is replaced by real content of the same size.

Spinner

Only use this when you don’t know the content structure, or when an action is in progress (submitting a form, uploading a file).

@keyframes spin {
  to { transform: rotate(360deg); }
}

.spinner {
  width: 20px;
  height: 20px;
  border: 2px solid var(--overlay-10);
  border-top-color: var(--text-neutral-primary);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

linear for the spin animation — ease-in-out makes the spinner look like it’s “pulsing” cyclically, unevenly.

Inline spinner vs full-page: an inline spinner sits right inside a button or beside the loading element. A full-page spinner is for when the whole page isn’t ready yet — avoid it if you can (a skeleton is better).

Progress bar

Use this when you can estimate a percentage or want to convey clear progress.

.progress-bar {
  height: 3px;
  background: var(--accent);
  transform-origin: left;
  transition: transform 0.3s ease-out;
}
// Fake progress for UX (when there's no real percentage)
function fakeProgress() {
  let progress = 0;
  const interval = setInterval(() => {
    progress += Math.random() * 15;
    if (progress >= 85) {
      clearInterval(interval);
      progress = 85; // hold at 85%, wait for real completion
    }
    setProgress(progress);
  }, 400);
  return () => clearInterval(interval);
}

Fake progress stops at 85% and waits for the real signal — avoid 99% because users will notice.

Transitioning to real content

When the data arrives, the transition from the loading state to the real content needs to be smooth.

/* Fade content in; the skeleton disappears on its own when it unmounts */
.content-enter {
  animation: fade-in 0.25s ease-out;
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

If the content height differs from the skeleton height, avoid layout shift by keeping the container height fixed during the transition.

Animation speed and perception

Perception studies show:

  • < 400ms: feels instant; no loading indicator needed
  • 400ms – 2s: an indicator is needed but progress is not (a spinner is fine)
  • > 2s: should have progress feedback or a time estimate
// Delay showing the spinner to avoid flicker on fast loads
const [showSpinner, setShowSpinner] = useState(false);

useEffect(() => {
  if (!isLoading) { setShowSpinner(false); return; }
  const timer = setTimeout(() => setShowSpinner(true), 300);
  return () => clearTimeout(timer);
}, [isLoading]);

A 300ms delay before showing the spinner: if the request returns within 300ms, the user never sees a loading indicator — smooth.

Common mistakes

Loop animation too fast: a spinner under 0.4s per rotation looks like it’s “panicking”. 0.6–1s is the natural range.

Shimmer with uneven easing: use linear or ease-in-out, not ease-out (the shimmer will “decelerate”, producing a strange feeling at the end of each cycle).

Loading state blocks all interaction: if only part of the page is loading, don’t disable the whole page — only disable that part.

See also: Cubic Bezier In Depth, Stagger Animation, Scroll Reveal Patterns.