BACK

Feb 6, 2025

0 words
0 m

Loading States: Designing Better Experiences While Users Wait

When you open an app, you expect it to respond quickly. Tap a button, click a link, submit a form - you want instant feedback. But sometimes the app needs to fetch data, download assets, or run background work.

If nothing shows up while that happens, users get confused. Did it work? Did the app freeze? Should I refresh?

That’s where loading states come in. They’re the small details that reassure people something is happening. Done well, they make apps feel smooth and polished. Done poorly (or left out completely), they make apps feel broken.

In this guide, we’ll go step by step through different types of loading states, when to use them, and how to code them.

Why Loading States Matter

Imagine opening a banking app, tapping “View Transactions,” and getting a blank white screen for 3 seconds. Most people will panic. Did the app crash? Did they lose money?

Now imagine instead seeing:

  • A spinner telling you data is loading.

  • Or greyed-out transaction rows shaped like the final data (skeleton screen).

  • Or even a progress bar showing 60% complete.

These tiny changes transform the experience from uncertain to reassuring.

A good loading state:

  1. Shows progress is happening. Users know the app is alive.

  2. Sets expectations. They know roughly how long to wait.

  3. Reduces frustration. People are more patient when they see feedback.

The Simplest Loading State: Text

The bare minimum: just show “Loading...”

<p>Loading...</p>

It may not be pretty, but it solves the biggest problem: silence.

When to use this:

  • Internal tools.

  • Debugging.

  • Temporary placeholders until you add polish.

Even with plain text, you can improve accessibility with ARIA:

<p aria-live="polite">Loading user data...</p>

This way, screen readers announce it to the user.

Spinners

Spinners are the classic loading indicator. Everyone recognizes them.

Example: CSS Spinner

<div class="spinner"></div>
.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #ccc;
  border-top-color: #007bff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

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

Result: a rotating circle that signals “something is happening.”

When to use spinners:

  • Short, uncertain waits (1–3 seconds).

  • Logging in.

  • Simple actions (like saving a form).

Pitfalls:

  • Doesn’t show progress.

  • Bad for long waits - users may think it’s stuck.

Skeleton Screens

Instead of showing a spinner, skeleton screens display a “wireframe” of the final UI.

For example, if a profile page shows an avatar, name, and bio, you can render grey boxes in those spots while data loads.

<div class="skeleton skeleton-avatar"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
.skeleton {
  background: #eee;
  border-radius: 4px;
  margin-bottom: 8px;
}

.skeleton-avatar {
  width: 50px;
  height: 50px;
  border-radius: 50%;
}

.skeleton-text {
  height: 16px;
  width: 80%;
}

.skeleton-text.short {
  width: 40%;
}

This creates placeholders shaped like the real data.

Why skeletons feel fast:
They give users a preview of what’s coming. Even if data takes 3 seconds, the brain accepts it better because the layout is visible.

Best use cases:

  • News feeds.

  • Dashboards.

  • Lists of content.

Shimmer Effects

Take skeletons and add animation, and you get shimmer loaders. A gradient sweeps across the skeleton, giving the sense of motion.

.skeleton {
  position: relative;
  overflow: hidden;
  background: #eee;
}

.skeleton::after {
  content: "";
  position: absolute;
  top: 0;
  left: -150px;
  width: 100px;
  height: 100%;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.6), transparent);
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  100% { transform: translateX(150%); }
}

This is the pattern you see on Facebook, LinkedIn, and Twitter.

When to use shimmer loaders:

  • Social feeds.

  • High-traffic apps where polish matters.

Progress Bars

Progress bars show how much is done.

<div class="progress">
  <div class="progress-bar" style="width: 60%"></div>
</div>
.progress {
  background: #eee;
  border-radius: 4px;
  overflow: hidden;
  height: 20px;
}

.progress-bar {
  background: #007bff;
  height: 100%;
  transition: width 0.3s;
}

You can update the width dynamically with JavaScript:

function setProgress(percent) {
  document.querySelector('.progress-bar').style.width = percent + '%';
}

When to use progress bars:

  • File uploads.

  • Installations.

  • Tasks with predictable steps.

React Example with Suspense

React makes it easy to show loading states while components fetch data.

import React, { Suspense } from 'react';

const UserProfile = React.lazy(() => import('./UserProfile'));

function App() {
  return (
    <Suspense fallback={<p>Loading user profile...</p>}>
      <UserProfile />
    </Suspense>
  );
}

Here, the fallback <p> shows until the component loads.

Making Loading States Accessible

Accessibility matters. A spinner with no text is meaningless to someone using a screen reader.

Best practices:

  • Use aria-busy="true" on loading containers.

  • Provide text inside or next to the indicator.

<div aria-busy="true" aria-live="polite">
  <div class="spinner"></div>
  <span>Loading profile data...</span>
</div>

This way, the screen reader announces the message.

Real-World Examples

  • Slack: Shows skeleton channels and messages when switching workspaces.

  • GitHub: Uses spinners for short actions (like starring repos) and progress bars for uploads.

  • Stripe Dashboard: Uses skeleton loaders for metrics, making it feel instant.

Each company picks a pattern depending on the context.

Common Mistakes to Avoid

  • Infinite spinners. If it takes long, show progress or give a cancel option.

  • Blank screens. Always show something, even plain text.

  • Removing loaders too early. Don’t flash content in and out.

  • Accessibility gaps. Always pair visuals with text or ARIA roles.

Advanced Patterns for Loading States

Basic spinners and skeletons are useful, but advanced techniques can make your app feel even smoother and smarter. Let’s explore some patterns that go beyond the basics.

Optimistic UI

With optimistic UI, you don’t wait for the server before updating the interface. You assume success, update the UI instantly, and then reconcile if something goes wrong.

Example: Liking a post

function LikeButton({ postId }) {
  const [liked, setLiked] = React.useState(false);

  const handleClick = () => {
    setLiked(true); // update instantly

    fetch(`/api/like/${postId}`, { method: 'POST' })
      .catch(() => {
        setLiked(false); // roll back if error
      });
  };

  return (
    <button onClick={handleClick}>
      {liked ? "❤️ Liked" : "♡ Like"}
    </button>
  );
}

The user sees the heart fill instantly. If the request fails, it rolls back.

Why it works:

  • Feels instant, no spinner required.

  • Great for actions that are usually successful.

Caution:

  • Requires rollback logic.

  • Can be confusing if errors are frequent.

Prefetching

Prefetching means loading data before the user asks for it, so when they do, it feels instant.

Example: Prefetching on hover

document.querySelectorAll('a').forEach(link => {
  link.addEventListener('mouseenter', () => {
    fetch(link.href); // start fetching early
  });
});

Framework support:

  • Next.js supports <Link prefetch> automatically.

  • Remix and Gatsby also do route-based prefetching.

Why it works:

  • Users perceive speed because data is already there.

  • Great for links, menus, or predictable navigation.

Caution:

  • Can waste bandwidth if users don’t click.

  • Be careful on mobile data connections.

Background Placeholders

Sometimes you don’t need to load everything at once. Show partial content first, then fill in details.

Example: Image placeholders

<img src="tiny-blur.jpg" class="placeholder" />
<img src="high-res.jpg" class="final" />
.placeholder {
  filter: blur(10px);
  position: absolute;
}

.final {
  transition: opacity 0.5s;
}

The user sees a blurred thumbnail instantly, then the sharp image fades in.

Real-world use:

  • Medium pioneered this with progressive image loading.

  • YouTube loads video titles first, thumbnails after.

Why it works:

  • Reduces perceived wait time.

  • Gives structure quickly.

Combining Patterns

You don’t have to pick one. Many apps combine these techniques:

  • Optimistic UI for likes and follows.

  • Skeletons for structured data.

  • Prefetching for predictable navigation.

  • Background placeholders for images and media.

The result: an app that feels fast in almost every situation.

Hands-on Demo: Mini Dashboard That Feels Fast

Below is a small project you can paste into an index.html file and open in a browser. It shows three patterns in one place - skeletons, optimistic UI, and prefetch. It also includes a basic progress bar with ARIA so it is screen reader friendly.

1) Files

You can keep it all in one file to try it fast. Then split later if you want.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Fast-feel Dashboard</title>
  <style>
    :root { --bg:#0f172a; --card:#111827; --muted:#e5e7eb; --accent:#3b82f6; --ok:#16a34a; --err:#dc2626; }
    body { margin: 0; font: 16px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: #fff; }
    .wrap { max-width: 960px; margin: 0 auto; padding: 24px; }
    h1 { margin: 0 0 16px; font-size: 28px; }
    .row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
    .card { background: var(--card); border-radius: 12px; padding: 16px; box-shadow: 0 1px 0 rgba(255,255,255,0.05) inset; }
    .muted { color: var(--muted); }

    /* Skeletons */
    .skeleton { position: relative; overflow: hidden; background: #1f2937; border-radius: 8px; }
    .sk-line { height: 14px; margin: 8px 0; }
    .sk-thumb { height: 48px; width: 48px; border-radius: 50%; }
    .skeleton::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent); animation: shimmer 1.2s infinite; }
    @keyframes shimmer { to { transform: translateX(100%); } }

    /* Spinner */
    .spinner { width: 32px; height: 32px; border: 3px solid #374151; border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px; }
    @keyframes spin { to { transform: rotate(360deg); } }

    /* Progress bar */
    .progress { height: 10px; background: #1f2937; border-radius: 999px; overflow: hidden; }
    .bar { height: 100%; width: 0%; background: var(--accent); transition: width .2s; }

    /* Buttons */
    .btn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid #334155; border-radius: 10px; background: #0b1220; color: #fff; cursor: pointer; }
    .btn:hover { background: #0a1020; }
    .btn[disabled] { opacity: .6; cursor: not-allowed; }
    .btn-like[data-liked="true"] { border-color: var(--ok); color: var(--ok); }

    ul { list-style: none; padding: 0; margin: 0; }
    li { display: flex; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid #1f2937; }
    .thumb { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; }
  </style>
</head>
<body>
  <div class="wrap">
    <h1>Dashboard</h1>

    <div class="row">
      <!-- Feed card with skeletons that swap to real content -->
      <section class="card" id="feed">
        <h2>Latest posts</h2>
        <div id="feed-skeleton">
          <div class="skeleton sk-thumb"></div>
          <div class="skeleton sk-line" style="width:70%"></div>
          <div class="skeleton sk-line" style="width:50%"></div>
          <div class="skeleton sk-line" style="width:60%"></div>
        </div>
        <ul id="feed-list" hidden></ul>
      </section>

      <!-- Actions card shows optimistic UI like a like button -->
      <section class="card" id="actions">
        <h2>Actions</h2>
        <button class="btn btn-like" id="likeBtn" aria-pressed="false" data-liked="false">♡ Like</button>
        <p class="muted" id="likeMsg" role="status" aria-live="polite"></p>

        <div style="margin-top:16px">
          <button class="btn" id="uploadBtn">Simulate upload</button>
          <div class="progress" style="margin-top:8px" aria-label="Upload progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
            <div class="bar" id="bar"></div>
          </div>
          <p class="muted" id="upMsg" role="status" aria-live="polite"></p>
        </div>
      </section>
    </div>

    <section class="card" style="margin-top:16px">
      <h2>Navigation</h2>
      <p class="muted">Hover a link to prefetch the page so it opens fast.</p>
      <nav id="nav">
        <a class="btn" href="/fake/page-1">Page 1</a>
        <a class="btn" href="/fake/page-2">Page 2</a>
        <a class="btn" href="/fake/page-3">Page 3</a>
      </nav>
      <p id="navMsg" class="muted" role="status" aria-live="polite"></p>
    </section>
  </div>

  <script>
    // Simulated API helpers
    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    async function fakeFetch(url) {
      await sleep(900 + Math.random()*800); // variable delay
      if (url.includes('error') && Math.random() < 0.2) throw new Error('Network');
      return { ok: true, json: async () => ([
        { id: 1, name: 'Ayesha', avatar: 'https://picsum.photos/seed/a/96', text: 'Learning day by day.' },
        { id: 2, name: 'Rafi',   avatar: 'https://picsum.photos/seed/b/96', text: 'Shipping a small feature.' },
        { id: 3, name: 'Maya',   avatar: 'https://picsum.photos/seed/c/96', text: 'Coffee and code.' }
      ]) };
    }

    // 1) Skeletons -> real content
    async function loadFeed() {
      const sk = document.getElementById('feed-skeleton');
      const list = document.getElementById('feed-list');
      const res = await fakeFetch('/api/posts');
      const items = await res.json();
      list.innerHTML = items.map(item => `
        <li>
          <img class="thumb" src="${item.avatar}" alt="${item.name} avatar" />
          <div>
            <strong>${item.name}</strong>
            <div class="muted">${item.text}</div>
          </div>
        </li>`).join('');
      sk.hidden = true;
      list.hidden = false;
    }

    // 2) Optimistic like button with rollback
    const likeBtn = document.getElementById('likeBtn');
    const likeMsg = document.getElementById('likeMsg');
    likeBtn.addEventListener('click', async () => {
      const liked = likeBtn.dataset.liked === 'true';
      // optimistic update
      likeBtn.dataset.liked = String(!liked);
      likeBtn.setAttribute('aria-pressed', String(!liked));
      likeBtn.textContent = !liked ? '❤️ Liked' : '♡ Like';
      likeMsg.textContent = !liked ? 'Saved like' : 'Removed like';

      try {
        const res = await fakeFetch('/api/like');
        if (!res.ok) throw new Error('Server');
      } catch {
        // rollback
        likeBtn.dataset.liked = String(liked);
        likeBtn.setAttribute('aria-pressed', String(liked));
        likeBtn.textContent = liked ? '❤️ Liked' : '♡ Like';
        likeMsg.textContent = 'Could not save. Try again.';
      }
    });

    // 3) Accessible progress bar for uploads
    const uploadBtn = document.getElementById('uploadBtn');
    const bar = document.getElementById('bar');
    const prog = document.querySelector('[role="progressbar"]');
    const upMsg = document.getElementById('upMsg');

    uploadBtn.addEventListener('click', async () => {
      uploadBtn.disabled = true;
      upMsg.textContent = 'Uploading...';
      for (let i = 0; i <= 100; i += Math.floor(Math.random()*12)+5) {
        await sleep(180);
        bar.style.width = i + '%';
        prog.setAttribute('aria-valuenow', String(i));
      }
      bar.style.width = '100%';
      prog.setAttribute('aria-valuenow', '100');
      upMsg.textContent = 'Upload complete';
      await sleep(600);
      bar.style.width = '0%';
      prog.setAttribute('aria-valuenow', '0');
      uploadBtn.disabled = false;
      upMsg.textContent = '';
    });

    // 4) Prefetch on hover
    const navMsg = document.getElementById('navMsg');
    document.querySelectorAll('#nav a').forEach(a => {
      a.addEventListener('mouseenter', async () => {
        navMsg.textContent = `Prefetching ${a.getAttribute('href')}...`;
        try { await fakeFetch(a.getAttribute('href')); navMsg.textContent = 'Ready.'; }
        catch { navMsg.textContent = 'Could not prefetch.'; }
      });
    });

    // kick things off
    loadFeed();
  </script>
</body>
</html>

What to look for

  • The feed shows skeletons first, then real content.

  • The like button switches state instantly. If the request fails, it rolls back.

  • The upload button runs a progress bar that updates aria-valuenow so screen readers can track progress.

  • Links prefetch on hover so navigation feels fast.

2) React Version (Optional)

Here is a minimal React example that uses the same ideas - a skeleton list, an optimistic like button, and a simulated upload with an accessible progress bar.

import React, { useEffect, useState } from 'react';

const sleep = (ms:number) => new Promise(r => setTimeout(r, ms));
const fakeFetch = async (url:string) => { await sleep(700 + Math.random()*600); return { ok:true, json:async()=>[
  { id:1, name:'Ayesha', avatar:'https://picsum.photos/seed/a/96', text:'Learning day by day.'},
  { id:2, name:'Rafi',   avatar:'https://picsum.photos/seed/b/96', text:'Shipping a small feature.'},
  { id:3, name:'Maya',   avatar:'https://picsum.photos/seed/c/96', text:'Coffee and code.'},
] } };

export default function App(){
  const [items, setItems] = useState<any[]|null>(null);
  const [liked, setLiked] = useState(false);
  const [progress, setProgress] = useState(0);

  useEffect(() => { (async () => {
    const res = await fakeFetch('/api/posts');
    setItems(await res.json());
  })(); }, []);

  async function toggleLike(){
    const prev = liked;
    setLiked(!prev); // optimistic
    try { const res = await fakeFetch('/api/like'); if(!res.ok) throw new Error('x'); }
    catch { setLiked(prev); }
  }

  async function simulateUpload(){
    for (let i=0; i<=100; i+= Math.floor(Math.random()*12)+5){
      await sleep(120);
      setProgress(i);
    }
    setProgress(100);
    await sleep(500);
    setProgress(0);
  }

  return (
    <div style={{ padding: 16, color:'#fff', background:'#0f172a', minHeight:'100vh' }}>
      <h1>Dashboard</h1>

      <section style={{ background:'#111827', padding:16, borderRadius:12, marginBottom:16 }}>
        <h2>Latest posts</h2>
        {!items ? (
          <div>
            <div style={{ height:48, width:48, borderRadius:24, background:'#1f2937', marginBottom:8 }} />
            <div style={{ height:14, width:'70%', background:'#1f2937', marginBottom:8 }} />
            <div style={{ height:14, width:'50%', background:'#1f2937', marginBottom:8 }} />
          </div>
        ) : (
          <ul style={{ listStyle:'none', padding:0 }}>
            {items.map(it => (
              <li key={it.id} style={{ display:'flex', gap:12, alignItems:'center', padding:'10px 0', borderBottom:'1px solid #1f2937' }}>
                <img src={it.avatar} alt={`${it.name} avatar`} width={48} height={48} style={{ borderRadius:24 }} />
                <div>
                  <strong>{it.name}</strong>
                  <div style={{ color:'#e5e7eb' }}>{it.text}</div>
                </div>
              </li>
            ))}
          </ul>
        )}
      </section>

      <section style={{ background:'#111827', padding:16, borderRadius:12 }}>
        <h2>Actions</h2>
        <button type="button" onClick={toggleLike} aria-pressed={liked} style={{ padding:'8px 12px', borderRadius:10 }}>
          {liked ? '❤️ Liked' : '♡ Like'}
        </button>

        <div style={{ marginTop:16 }}>
          <button type="button" onClick={simulateUpload} style={{ padding:'8px 12px', borderRadius:10 }}>Simulate upload</button>
          <div role="progressbar" aria-label="Upload progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress} style={{ height:10, background:'#1f2937', borderRadius:999, overflow:'hidden', marginTop:8 }}>
            <div style={{ height:'100%', width:progress+'%', background:'#3b82f6', transition:'width .2s' }} />
          </div>
        </div>
      </section>
    </div>
  );
}

What happens:

  • The feed shows skeletons, then real posts.

  • The like button updates instantly, then rolls back if needed.

  • Upload simulates a progress bar with proper aria-valuenow updates.

  • Links prefetch on hover so navigation feels fast.

Pattern Picker

Use this quick guide to pick the right UI for the expected wait.

Expected wait

Unknown duration

Known duration

Structured content

< 1s

No loader, keep layout stable

No loader

Avoid loaders

1–3s

Spinner + text

Indeterminate progress bar

Skeleton

3–10s

Skeleton or progress with hints

Determinate progress bar

Skeleton + shimmer

> 10s

Progress bar + cancel or background task

Progress bar + ETA, offer cancel

Skeleton, consider backgrounding

Accessibility Deep Dive

  • Use aria-live="polite" for non-critical updates, aria-live="assertive" for high priority messages.

  • Always label progress bars with aria-label or aria-labelledby, and keep aria-valuenow in sync.

  • Do not disable focus outlines. If you use a full-page loader, ensure the focus is trapped inside it and returned to the triggering control when loading finishes.

  • Respect reduced motion. Turn off shimmer and heavy animations under prefers-reduced-motion.

  • For buttons that trigger loading, set aria-busy="true" on the container and update the button text to include the action, for example Saving….

Error-friendly Loading

  • Time out long requests and swap the loader for a message with Retry and Cancel.

  • Distinguish between empty and error states. Empty: “No items yet.” Error: “Could not load items.”

  • Keep the layout stable to avoid content shift when swapping loaders for content.

Performance Notes

  • Lazy load large images and non-critical JS with loading="lazy" and dynamic imports.

  • Preconnect to critical domains with <link rel="preconnect">.

  • Serve small blurred placeholders to reduce perceived cost of images.

Testing and Checks

  • Test tab order during loading. The focused element should not disappear.

  • Use Lighthouse and Web Vitals. Watch LCP and CLS when loaders swap in and out.

  • Simulate slow 3G in DevTools to check that loaders actually render.

Common Mistakes to Avoid

  • Infinite spinners with no end state.

  • Blank screens while data loads.

  • Removing loaders too early (causing flicker).

  • Ignoring accessibility (e.g. spinners with no text).

Summary Checklist

  • ✅ Use text loaders as the simplest fallback.

  • ✅ Use spinners for short, uncertain waits.

  • ✅ Use skeletons and shimmers for structured data.

  • ✅ Use progress bars when you know the steps.

  • ✅ Always make loaders accessible with ARIA and live regions.

  • ✅ Respect reduced-motion for shimmer effects.

  • ✅ Try optimistic UI, prefetching, and background placeholders for advanced polish.

Designing good loading states is not decoration. It’s UX. They shape how people feel about your app’s speed, reliability, and trustworthiness.

Taseen Tanvir

1:12:28 UTC

Create a free website with Framer, the website builder loved by startups, designers and agencies.