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.
{/* 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.
{/* 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.
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 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.
{/* 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.
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-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.
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.Related
Components that work well with loading state patterns.