Skip to main content

Loading states

Skeletons, spinners, and progress indicators — patterns that communicate work-in-progress without stealing attention. Reduce perceived wait time by matching the eventual layout.

Skeleton Loading

Skeleton screens mirror the shape of real content, giving users an immediate sense of the layout while data loads. Use pulse animations to indicate activity.

Skeleton Loading
tsx
{/* Text skeleton */}
<div className="space-y-3">
  <div className="h-5 w-3/5 rounded-md bg-gray-700/40 animate-pulse" />
  <div className="h-3.5 w-full rounded-md bg-gray-700/30 animate-pulse" />
  <div className="h-3.5 w-4/5 rounded-md bg-gray-700/30 animate-pulse" />
</div>

{/* Avatar + text skeleton */}
<div className="flex items-center gap-4">
  <div className="w-12 h-12 rounded-full bg-gray-700/40 animate-pulse" />
  <div className="flex-1 space-y-2">
    <div className="h-4 w-32 rounded-md bg-gray-700/40 animate-pulse" />
    <div className="h-3 w-48 rounded-md bg-gray-700/30 animate-pulse" />
  </div>
</div>

{/* Image skeleton */}
<div className="h-40 w-full rounded-xl bg-gray-700/30 animate-pulse" />

Spinner Loading

Spinners indicate indeterminate loading. Use them when the duration is unknown or for quick operations. Always pair with descriptive text.

Small
Medium
Large
Loading your data...
Spinner Loading
tsx
{/* CSS spinner */}
<div
  className="w-8 h-8 border-2 border-gray-600 border-t-brand-500 rounded-full"
  style={{ animation: 'spin 0.8s linear infinite' }}
/>

{/* Spinner with text */}
<div className="flex items-center gap-3">
  <div
    className="w-5 h-5 border-2 border-gray-600 border-t-brand-500 rounded-full"
    style={{ animation: 'spin 0.8s linear infinite' }}
  />
  <span className="text-sm text-muted">Loading your data...</span>
</div>

{/* CSS keyframes */}
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

Progress Bar

Progress bars show determinate loading progress. Use them when the completion percentage is known, like file uploads or multi-step processes.

Uploading files...0%
25%
60%
100%
Progress Bar
tsx
function ProgressBar({ value }: { value: number }) {
  return (
    <div>
      <div className="flex justify-between mb-2">
        <span className="text-sm font-medium">Uploading files...</span>
        <span className="text-sm font-mono text-muted">{value}%</span>
      </div>
      <div className="h-2.5 w-full rounded-full bg-gray-700/30 overflow-hidden">
        <div
          className="h-full rounded-full bg-brand-500 transition-all"
          style={{ width: `${value}%` }}
        />
      </div>
    </div>
  );
}

Shimmer Effect

Shimmer adds a sweeping highlight animation over skeleton placeholders, creating a more polished loading experience than simple pulse animations.

Shimmer Effect
tsx
{/* Shimmer overlay on skeleton elements */}
<div className="relative h-40 bg-gray-700/20 overflow-hidden">
  <div
    className="absolute inset-0"
    style={{
      background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent)',
      animation: 'shimmer 1.8s ease-in-out infinite',
    }}
  />
</div>

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

Content Loading

Full-page loading skeletons that replicate your content layout. This prevents layout shifts and gives users a clear preview of what to expect.

Content Loading
tsx
{/* Full page skeleton */}
<div className="space-y-6">
  {/* Header */}
  <div className="space-y-3">
    <div className="h-8 w-48 rounded-lg bg-gray-700/30 animate-pulse" />
    <div className="h-4 w-72 rounded-md bg-gray-700/20 animate-pulse" />
  </div>

  {/* Cards grid */}
  <div className="grid grid-cols-3 gap-4">
    {[0, 1, 2].map((i) => (
      <div key={i} className="rounded-xl border overflow-hidden">
        <div className="h-28 bg-gray-700/20 animate-pulse" />
        <div className="p-3 space-y-2">
          <div className="h-4 w-3/4 rounded-md bg-gray-700/25 animate-pulse" />
          <div className="h-3 w-full rounded-md bg-gray-700/20 animate-pulse" />
        </div>
      </div>
    ))}
  </div>
</div>

Button Loading

Show loading state inside buttons to indicate an action is in progress. Disable the button to prevent duplicate submissions.

Button Loading
tsx
function LoadingButton({ loading, children, ...props }) {
  return (
    <button
      disabled={loading}
      className={`inline-flex items-center gap-2 px-5 py-2.5
        text-sm font-medium rounded-xl transition-all
        ${loading
          ? 'bg-brand-500/50 text-white/70 cursor-not-allowed'
          : 'bg-brand-500 text-white hover:bg-brand-600'
        }`}
      {...props}
    >
      {loading && (
        <div
          className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full"
          style={{ animation: 'spin 0.7s linear infinite' }}
        />
      )}
      {children}
    </button>
  );
}

<LoadingButton loading={isSubmitting}>
  {isSubmitting ? 'Saving...' : 'Save Changes'}
</LoadingButton>

Lazy Loading

Placeholder patterns for images and heavy content. Use blur-up or low-quality placeholders that transition smoothly to the full-resolution content.

Blur placeholder
Loading image...
Color placeholder
240 x 160
Lazy Loading
tsx
{/* Blur-up image placeholder */}
<div className="relative h-40 rounded-xl overflow-hidden bg-gray-700/20">
  <div
    className="absolute inset-0"
    style={{
      background: 'linear-gradient(135deg, rgba(59,130,246,0.15), rgba(147,51,234,0.15))',
      filter: 'blur(20px)',
      animation: 'fade-pulse 2s ease-in-out infinite',
    }}
  />
  {/* Replace with <Image> once loaded */}
  <img
    src={src}
    loading="lazy"
    onLoad={(e) => e.target.classList.remove('opacity-0')}
    className="absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-500"
  />
</div>

{/* Next.js Image with blur placeholder */}
import Image from 'next/image';

<Image
  src="/photo.jpg"
  alt="Description"
  width={400}
  height={300}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
/>

Best Practices

Follow these guidelines to create loading experiences that feel fast and keep users engaged.

Do
Match skeleton shapes to actual content layout
Use progress bars for determinate operations
Show spinners only for short waits (< 5s)
Add descriptive text alongside loading indicators
Use shimmer for richer skeleton experiences
Disable interactive elements during loading
Consider staggering skeleton animations
Use Suspense boundaries for code-split chunks
×Don't
Use a blank white page while loading
Show spinners for operations longer than 10s
Block the entire page for partial updates
Use generic "Loading..." without context
Forget to handle loading error states
Animate too aggressively (respect prefers-reduced-motion)
Stack multiple spinners on the same page
Show loading states for cached / instant data
Accessibility tipAlways add aria-busy="true" to containers that are loading, and aria-live="polite" so screen readers announce when content is ready. Respect prefers-reduced-motion by replacing animations with opacity fades.

Components that work well with loading state patterns.