Skip to main content
โšก Calmops

Loading States & Skeleton Screens: Best Practices

Loading states are critical for user experience. They manage expectations, reduce perceived wait time, and keep users engaged while content loads. This guide covers best practices for implementing effective loading states.

Why Loading States Matter

Loading states serve several purposes:

  1. Manage expectations - Users know something is happening
  2. Reduce perceived wait time - Content appears to load faster
  3. Prevent frustration - Users won’t leave or reload
  4. Show progress - When possible, indicate how much longer

Types of Loading States

1. Full Page Loading

For initial page loads:

.loader-fullpage {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: white;
  z-index: 9999;
}

.loader-fullpage .spinner {
  width: 48px;
  height: 48px;
  border: 4px solid #e2e8f0;
  border-top-color: #2563eb;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

2. Inline Loading

For content within a page:

.btn-loading {
  position: relative;
  pointer-events: none;
}

.btn-loading::after {
  content: '';
  position: absolute;
  width: 16px;
  height: 16px;
  top: 50%;
  left: 50%;
  margin: -8px 0 0 -8px;
  border: 2px solid transparent;
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

3. Button Loading State

<button class="btn btn-primary" id="submit-btn">
  <span class="btn-text">Submit</span>
  <span class="btn-loader"></span>
</button>
.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.75rem 1.5rem;
}

.btn .btn-loader {
  display: none;
}

.btn.loading .btn-text {
  visibility: hidden;
}

.btn.loading .btn-loader {
  display: block;
  position: absolute;
  width: 20px;
  height: 20px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}
// JavaScript to handle loading state
document.getElementById('submit-btn').addEventListener('click', async function() {
  this.classList.add('loading');
  this.disabled = true;
  
  try {
    await submitForm();
    // Handle success
  } catch (error) {
    // Handle error
  } finally {
    this.classList.remove('loading');
    this.disabled = false;
  }
});

Skeleton Screens

Skeleton screens mimic the layout of content while it loads, providing a better experience than spinners.

Basic Skeleton

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Text Skeleton

.skeleton-text {
  height: 1em;
  margin-bottom: 0.5rem;
}

.skeleton-text:last-child {
  width: 70%;
}

/* Line variations */
.skeleton-text-sm { height: 0.75rem; width: 60%; }
.skeleton-text-md { height: 1rem; width: 100%; }
.skeleton-text-lg { height: 1.5rem; width: 80%; }

Avatar Skeleton

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

.skeleton-avatar-sm { width: 32px; height: 32px; }
.skeleton-avatar-lg { width: 64px; height: 64px; }
.skeleton-avatar-xl { width: 96px; height: 96px; }

Card Skeleton Layout

<article class="skeleton-card">
  <div class="skeleton-card-image skeleton"></div>
  <div class="skeleton-card-body">
    <div class="skeleton skeleton-text skeleton-text-lg"></div>
    <div class="skeleton skeleton-text"></div>
    <div class="skeleton skeleton-text"></div>
    <div class="skeleton skeleton-text" style="width: 70%"></div>
    <div class="skeleton-card-footer">
      <div class="skeleton skeleton-avatar"></div>
      <div class="skeleton skeleton-text skeleton-text-sm"></div>
    </div>
  </div>
</article>
.skeleton-card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.skeleton-card-image {
  height: 200px;
  background: #e2e8f0;
}

.skeleton-card-body {
  padding: 1rem;
}

.skeleton-card-footer {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 1rem;
}

List Skeleton

<ul class="skeleton-list">
  <li class="skeleton-list-item">
    <div class="skeleton skeleton-avatar"></div>
    <div class="skeleton-list-content">
      <div class="skeleton skeleton-text skeleton-text-md"></div>
      <div class="skeleton skeleton-text skeleton-text-sm"></div>
    </div>
  </li>
  <li class="skeleton-list-item">
    <div class="skeleton skeleton-avatar"></div>
    <div class="skeleton-list-content">
      <div class="skeleton skeleton-text skeleton-text-md"></div>
      <div class="skeleton skeleton-text skeleton-text-sm"></div>
    </div>
  </li>
</ul>
.skeleton-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.skeleton-list-item {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  border-bottom: 1px solid #f0f0f0;
}

.skeleton-list-content {
  flex: 1;
}

Progressive Loading

Blur-Up Image Loading

.image-container {
  position: relative;
  overflow: hidden;
  background: #e2e8f0;
}

.image-container img {
  opacity: 0;
  transition: opacity 0.3s ease;
}

.image-container img.loaded {
  opacity: 1;
}

.image-container .placeholder {
  position: absolute;
  inset: 0;
  filter: blur(20px);
  transform: scale(1.1);
  transition: opacity 0.3s ease;
}

.image-container img.loaded + .placeholder {
  opacity: 0;
}
// Load low-res first, then high-res
const container = document.querySelector('.image-container');
const img = container.querySelector('img');

img.onload = () => {
  img.classList.add('loaded');
};

Content Fade-In

.content-fade {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.content-fade.loaded {
  opacity: 1;
  transform: translateY(0);
}

Handling Different Data States

React Component Example

function UserList() {
  const { data, isLoading, error } = useUsers();
  
  if (isLoading) {
    return <UserListSkeleton />;
  }
  
  if (error) {
    return <ErrorState message={error.message} />;
  }
  
  return <UserListContent users={data} />;
}

function UserListSkeleton() {
  return (
    <div className="skeleton-list">
      {[...Array(5)].map((_, i) => (
        <div key={i} className="skeleton-list-item">
          <div className="skeleton skeleton-avatar" />
          <div className="skeleton-list-content">
            <div className="skeleton skeleton-text skeleton-text-md" />
            <div className="skeleton skeleton-text skeleton-text-sm" />
          </div>
        </div>
      ))}
    </div>
  );
}

Vue Component Example

<template>
  <div v-if="loading" class="skeleton-list">
    <div v-for="i in 5" :key="i" class="skeleton-list-item">
      <div class="skeleton skeleton-avatar" />
      <div class="skeleton-list-content">
        <div class="skeleton skeleton-text skeleton-text-md" />
        <div class="skeleton skeleton-text skeleton-text-sm" />
      </div>
    </div>
  </div>
  <div v-else-if="error" class="error-state">
    {{ error.message }}
  </div>
  <ul v-else class="user-list">
    <li v-for="user in users" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
</template>

Best Practices

Do’s

/* DO: Use skeleton screens for content */
.skeleton-card {
  /* Mimic actual content layout */
}

/* DO: Keep animations subtle */
.skeleton {
  animation: shimmer 1.5s infinite;
  /* Not too fast, not too slow */
}

/* DO: Use appropriate colors */
.skeleton {
  background: #e2e8f0; /* Light gray */
  /* Match your background */
}

Don’ts

/* DON'T: Use generic spinners everywhere */
.spinner {
  /* Only for very short loads */
}

/* DON'T: Block entire page unnecessarily */
.page-wrapper.loading {
  /* Only show full-page loader when truly needed */
}

/* DON'T: Show skeletons for instant loads */

Responsive Considerations

/* Mobile: Simpler skeletons */
@media (max-width: 768px) {
  .skeleton-card-image {
    height: 150px;
  }
  
  .skeleton-text {
    /* Fewer lines on mobile */
  }
}

Accessibility

/* Add aria-live for screen readers */
.loading-region[aria-live="polite"] {
  /* Announce loading state */
}

/* Don't trap focus in loading state */
.loading-overlay {
  /* Only trap focus if truly modal */
}
<!-- Announce loading to screen readers -->
<div aria-live="polite" class="sr-only">
  Loading user list...
</div>

Performance

Lazy Load Skeleton Images

// Only show skeleton when loading takes > 300ms
let timer;
const showSkeleton = () => {
  timer = setTimeout(() => {
    document.querySelector('.content').classList.add('loading');
  }, 300);
};

const hideSkeleton = () => {
  clearTimeout(timer);
  document.querySelector('.content').classList.remove('loading');
};

// Use with fetch
showSkeleton();
fetch('/api/data')
  .then(res => res.json())
  .then(data => {
    hideSkeleton();
    render(data);
  });

Summary

Key loading state principles:

  • Skeleton screens - Better than spinners for content
  • Match layout - Skeletons should mimic actual content
  • Subtle animation - 1-1.5s shimmer animation
  • Progressive loading - Show low-res first, then fade in
  • Handle errors - Don’t forget error states
  • Accessibility - Announce states to screen readers
  • Performance - Don’t block unnecessarily

Use the right type of loading state for each situation:

  • Spinners for quick (< 1s) actions
  • Skeletons for content loading
  • Progress bars for known durations
  • Full-page loader for initial page load

Comments