Loading States and Skeleton Screens
There's a psychological quirk in how humans perceive time. Engaged waiting feels shorter than passive waiting. This is why loading states matter. Not because they make your app faster, but because they make it feel faster.
A blank screen for 2 seconds feels like eternity. A skeleton screen with subtle animation for the same 2 seconds feels manageable. Same wait, completely different experience.
Types of Loading States
Not all loading is the same. Different situations call for different approaches.
Full page load: User navigates to a new page. Everything needs to load.
Partial content load: Page is there but specific content (images, data from API) is still loading.
Action feedback: User did something (clicked save, submitted form) and is waiting for confirmation.
Background operation: Something is processing but user can continue working.
Each needs a different treatment. Don't use the same spinner for everything.
The Humble Spinner
Spinners are the oldest trick in the book. They're not wrong. They serve a purpose. But they're best for specific situations.
Use spinners when:
- Loading is expected to be very short (<1 second)
- You don't know what content is coming
- It's a focused action (button click, form submit)
Don't use spinners when:
- Loading takes more than 1-2 seconds
- You know the structure of incoming content
- It's a full page or large content area
Spinner design tips: keep it simple, brand-colored, and sized appropriately for context. A tiny spinner for button loading, a medium one for card loading. Don't make it the center of attention. It should be noticed but not stared at.
Skeleton Screens: The Modern Standard
Skeleton screens show the shape of content before it loads. Gray boxes where images will be. Lines where text will be. The structure without the substance.
Why they work:
They set expectations. Users can see what's coming. The mental model is already forming.
They feel progressive. Something is already there. It's not empty - it's filling in.
They reduce layout shift. When content arrives, it slides into place instead of pushing things around.
They're more engaging than spinners. The subtle pulse animation gives your brain something to track.
Building Skeleton Screens
Skeleton elements should match the rough dimensions of real content. If your card has a 200x150 image, the skeleton should have a 200x150 gray rectangle.
For text, use gray bars of varying widths. The first line might be 90% width, the second 80%, the third 60%. This mimics how real text has varied line lengths.
Basic CSS skeleton:
.skeleton {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
The shimmer animation creates that characteristic "loading" pulse that skeleton screens are known for.
Transitioning from Skeleton to Content
When content arrives, don't just snap from skeleton to real content. Fade the skeleton out as the real content fades in. Or let the skeleton elements "fill in" with actual content.
Avoid:
- Showing skeleton, then blank, then content (jarring gap)
- Long cross-fade (feels laggy)
- Skeleton remaining visible alongside content
A subtle fade over 150-200ms works well. Fast enough to feel instant, slow enough to be smooth.
Progress Indicators
When you know how long something will take, or can estimate progress, show it.
Determinate progress bars fill from 0-100% based on actual progress. File uploads, multi-step processes, download completion.
Indeterminate progress bars animate continuously when you don't know duration. Good for uncertain API calls.
Progress gives users a sense of control. "40% done" is less stressful than "processing..." even if they take the same time.
For fake progress: if you don't have real metrics but want to show determinate progress, advance quickly to 80%, then slow down. It feels like things are happening, and you're not stuck at 99% for ages.
Content Loading Strategies
How you load content affects perceived performance.
Eager loading: Load everything upfront. Simple but slow. User waits for entire page.
Lazy loading: Load content as needed (when user scrolls to it). Fast initial load but content appears on scroll.
Progressive loading: Load critical content first, then enhance. User sees something fast, it gets better over time.
Optimistic UI: Update UI immediately as if the action succeeded, then reconcile with server. Feels instant but needs good error handling.
The best approach is usually progressive: ship the shell fast with skeletons, then fill in data as it arrives. Above-the-fold content loads first.
Handling Different Durations
Adapt your loading state to expected wait time.
<200ms: Don't show anything. The content appears instantly enough that a loading state would just flash annoyingly.
200ms - 1s: Simple loading indicator. Spinner or skeleton depending on context. No need to be fancy.
1-10s: Skeleton screens with animation. Maybe some messaging: "Loading your dashboard..."
>10s: Progress indicator with explanation. "Processing your upload (this may take a minute)." Consider allowing users to navigate away with background processing.
The tricky part is you don't always know how long something will take. Network conditions vary. APIs have bad days. Build in flexibility.
Empty vs Loading
Users should never confuse "loading" with "empty." These are different states requiring different UI.
Loading: Skeletons, spinners, animation. Something is happening.
Empty: Static message, illustration. "No results found" or "You haven't created anything yet."
The transition matters too. Don't go straight from loading to empty without a moment to register. A brief fade communicates "we checked, there's nothing" rather than "we gave up."
Loading State Anti-patterns
Flash of loading state. If content loads in 50ms, showing a skeleton for 50ms is worse than showing nothing. Add a slight delay (200ms) before showing loading state.
Layout shifts. Content loads and pushes other content around. Use skeleton dimensions that match real content. Reserve space.
Multiple spinners. Five different areas loading with five different spinners looks chaotic. Coordinate your loading states or use a unified skeleton approach.
Loading forever. If something fails, show an error. Don't spin eternally. Set timeouts and handle failures gracefully.
Disabled interactions during load. Unless necessary, let users do other things while content loads. Don't lock the whole UI for one loading operation.
Testing Your Loading States
You need to see your loading states to test them. With fast connections, they might never appear.
Chrome DevTools → Network tab → Throttle to "Slow 3G". Now you'll see exactly what users on bad connections experience.
Test scenarios:
- Initial page load
- Navigation between pages
- Pulling fresh data (refresh)
- Actions that trigger loading
- Multiple simultaneous loads
- Very slow response (10+ seconds)
- Failed response (what happens on error?)
Implementation Tips
Build loading states as part of your component library. A Card component should have a Card.Skeleton variant. This ensures consistency and makes it easy to add skeletons wherever needed.
Consider a Suspense-like pattern (React has this built in, other frameworks have equivalents). Define loading states at component level and let the framework handle showing them appropriately.
Use animation sparingly. A subtle shimmer is good. Twenty different bouncing elements is overwhelming.
Quick Wins
If you're starting from nothing, do these:
- Add a simple spinner to buttons that trigger async actions
- Build skeleton components for your main content types
- Add delay before showing loading state (avoid flash)
- Handle error cases (don't spin forever)
- Test on slow connections
Loading states are easy to ignore when you're on fast wifi with local development. Your users aren't that lucky. Design for the wait, and the wait won't feel so long.