EmpireUI
Get Pro
← Blog7 min read#tailwind-css#loading-states#skeleton-screen

Tailwind Loading States: Spinner, Skeleton, Shimmer Patterns

Build spinners, skeleton screens, and shimmer effects with Tailwind CSS. Real code patterns for every loading state your React app needs — no extra libraries.

Abstract flowing light streaks on dark background representing loading animation and shimmer effects

Why Loading States Make or Break Your UI

Honestly, loading states are the most neglected part of frontend work. Developers spend weeks perfecting the happy path, then ship a blank white screen for 800ms and wonder why users bounce. That gap between action and result is where trust evaporates.

A spinner tells users something is happening. A skeleton screen tells them roughly what's coming. A shimmer effect says "content is loading, and it'll look like this." Each one serves a different use case, and choosing the wrong one kills perceived performance even when your actual network time is fine.

Tailwind CSS makes all three patterns surprisingly easy to build without reaching for an animation library. In Tailwind v4.0.2 specifically, the animate-spin and animate-pulse utilities are baked in, and you can compose animate-[shimmer_1.5s_ease-in-out_infinite] with an arbitrary value for custom keyframes. Let's walk through each pattern with real code.

Building a CSS Spinner with Tailwind animate-spin

The spinner is the oldest loading indicator. It's overused in the wrong contexts — don't put it on skeleton-able content — but for button states, form submissions, and indeterminate waits, it's exactly right. Tailwind's animate-spin class applies a 1-second linear infinite rotation. That's all you need for the base.

The trick is the border technique. You give an element a full border, then make three sides transparent. The remaining visible arc becomes the spinning indicator. Combine rounded-full, a fixed size, and border-t-transparent to get a clean ring.

// SpinnerButton.tsx
interface SpinnerButtonProps {
  loading: boolean;
  children: React.ReactNode;
  onClick: () => void;
}

export function SpinnerButton({ loading, children, onClick }: SpinnerButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={loading}
      className="relative inline-flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-lg disabled:opacity-70 transition-opacity"
    >
      {loading && (
        <span
          className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"
          aria-hidden="true"
        />
      )}
      {children}
    </button>
  );
}

A few things worth noting here. The aria-hidden="true" on the spinner keeps screen readers from announcing spinning nonsense. Keep spinner sizes small — 16px (w-4 h-4) for inline, 32px (w-8 h-8) for full-section overlays. Anything bigger looks like you're panicking.

Skeleton Screens: Matching Your Real Content Layout

Skeleton screens reduce perceived wait time by showing the shape of content before it arrives. The key word is shape. Your skeleton needs to reflect the actual layout — card width, line count, avatar position. A generic gray rectangle tells users nothing and looks like an error.

Tailwind's animate-pulse oscillates opacity between 100% and ~40% over 2 seconds. Stack bg-gray-200 dark:bg-gray-700 rounded animate-pulse on placeholder elements and you've got the foundation. For dark mode support, use the dark: variant — otherwise skeletons look broken in dark themes.

// CardSkeleton.tsx
export function CardSkeleton() {
  return (
    <div className="p-4 rounded-xl border border-gray-100 dark:border-gray-800 space-y-3">
      {/* Avatar + name row */}
      <div className="flex items-center gap-3">
        <div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse" />
        <div className="space-y-1.5 flex-1">
          <div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
          <div className="h-2.5 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
        </div>
      </div>
      {/* Body lines */}
      <div className="space-y-2">
        <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
        <div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
        <div className="h-3 w-3/4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
      </div>
      {/* Image placeholder */}
      <div className="h-40 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />
    </div>
  );
}

Notice the last body line is w-3/4. That single detail makes the skeleton look like real text instead of a broken layout — real paragraphs never end at exactly 100% width. Small things like that are what separate polished loading states from amateur ones.

Shimmer Effect: Custom Keyframes in Tailwind v4

Shimmer goes one step further than pulse. Instead of fading in-place, a highlight sweeps left to right across the element — like a light reflection moving over a surface. It's more expensive to implement but significantly more polished feeling. If you're working on an ecommerce product page or a content feed, shimmer reads as "loading" rather than "broken."

In Tailwind v4.0.2 you define custom keyframes in your CSS config and reference them with arbitrary value syntax. The shimmer uses a gradient moving from rgba(255,255,255,0) through rgba(255,255,255,0.15) back to transparent — that 0.15 opacity is the sweet spot for visible-but-subtle on both light and dark backgrounds.

/* globals.css — Tailwind v4 with @keyframes */
@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.shimmer {
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.15) 50%,
    rgba(255, 255, 255, 0) 100%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

Then in your component, layer the .shimmer class on top of a base background color using absolute positioning. The shimmer div sits absolute inset-0 over a relative parent with overflow-hidden. This way the sweep stays clipped to the skeleton shape. For dark mode, swap the gradient stops to rgba(255,255,255,0.05) through rgba(255,255,255,0.12) — the lighter base color on dark cards means less contrast is needed.

Composing Loading State Components in React

Here's the thing: you don't want to scatter loading logic across every component. A clean pattern is a <Skeleton> wrapper that accepts a loading boolean and renders either children or a skeleton placeholder. This keeps your page components clean and makes loading states easy to test.

Think about state transitions too. Abruptly swapping skeleton for content causes a jarring flash. A 150ms fade-in on the real content — transition-opacity duration-150 with an opacity class toggled via React state — smooths the swap without being noticeable. If you're also handling theme toggles, make sure your skeleton colors are tied to CSS custom properties so they switch instantly with the theme, not a beat later.

For lists that load incrementally, consider staggered skeleton animations. Give each skeleton card a different animation-delay — 0ms, 100ms, 200ms — so the pulse ripples down the list instead of every card pulsing in sync. In Tailwind you'd do this with inline style style={{ animationDelay: ${index * 100}ms }} since arbitrary animation delays aren't practical as utility classes.

Loading Overlays and Full-Page States

Button spinners and card skeletons cover most cases. But sometimes you need to block the entire page — during a route transition, a multi-step form submission, or an initial data hydration. Full-page loading overlays need different treatment. They should appear after a 200ms delay (instant overlays on fast connections feel broken) and disappear with a fade-out.

The overlay pattern in Tailwind: fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-gray-950/80 backdrop-blur-sm. That bg-white/80 with backdrop-blur-sm gives a glassmorphism feel without you needing to reach for a separate library — it's the same principle explored in Tailwind glassmorphism patterns. The blur keeps context visible without distracting from the loading indicator.

For the delay, a simple approach: mount the overlay immediately but use a CSS transition on opacity. Start at opacity-0 and transition to opacity-100 after a 200ms delay using transition-opacity delay-200. If the data loads in under 200ms, the overlay never becomes visible. Users on fast connections never see a flash. Users on slow connections get the full overlay experience.

Accessibility Considerations for Loading States

Visual loading indicators mean nothing to screen reader users. You need to communicate loading state through ARIA. The pattern is straightforward: add role="status" and aria-live="polite" to a visually hidden element, then update its text content when loading starts and stops. Polite means the screen reader waits for the user to finish reading before announcing — aria-live="assertive" would interrupt, which is too aggressive for most loading states.

Skeleton screens need aria-busy="true" on the container while loading. This signals to assistive technology that the region is in the process of updating. Pair it with aria-label="Loading content" so there's a meaningful announcement. When content loads, remove aria-busy and let the real content speak for itself.

What about reduced motion? The prefers-reduced-motion media query is your friend. Tailwind has motion-reduce: variants built in. Apply motion-reduce:animate-none on your spinner and shimmer elements, and consider showing a static skeleton instead of an animated one for users who've opted out of motion. It's a simple addition that respects accessibility preferences without extra dependencies. Check how Tailwind v4 features handle this at the framework level for broader context.

Performance Tips: When Not to Show a Loader

Not every async operation needs a loading state. Are you sure your users can even perceive a 50ms fetch? The rule of thumb: under 100ms needs nothing, 100–1000ms needs a subtle indicator, over 1000ms needs a skeleton or progress bar. Reflexively adding spinners to every fetch is a common mistake that makes fast apps feel slow.

Optimistic UI is the opposite of loading states — you update the UI immediately and roll back on error. For mutations like toggling a like button or reordering a list, optimistic updates feel instant and delightful. Reserve loading states for reads, form submissions where the result matters, and anything that takes over a second. The less your users see loading indicators, the more they trust your app.

Prefetching and suspense boundaries in React 19 change the calculus further. If you're using <Suspense> with streaming SSR, your skeleton components become the fallback prop. Keep them lightweight — no heavy client components, no external fonts. A skeleton that's heavier than the content it replaces defeats the whole purpose.

FAQ

How do I add a custom shimmer animation in Tailwind v4 without a plugin?

Define your @keyframes shimmer block in your global CSS file (e.g., globals.css) and use the arbitrary animation syntax in Tailwind: animate-[shimmer_1.5s_ease-in-out_infinite]. In Tailwind v4.0.2, arbitrary values work for animation names as long as the keyframes are defined somewhere in the CSS cascade.

What's the difference between animate-pulse and a shimmer effect?

animate-pulse fades the entire element's opacity in and out in place. A shimmer moves a highlight gradient across the element from left to right. Shimmer is more polished and closer to what users recognize from Facebook/LinkedIn skeletons, but it requires custom keyframes. Pulse works out of the box with zero setup.

Should I use a spinner or skeleton for a loading list?

Skeleton every time for lists. A spinner gives no hint of how many items are coming or what they'll look like. A skeleton that matches your list item layout sets expectations and makes the load feel faster. Spinners are for indeterminate waits where you genuinely can't predict the output shape — like file uploads or long AI generation tasks.

How do I handle loading states in dark mode with Tailwind?

Use the dark: variant on your skeleton background colors. The standard pattern is bg-gray-200 dark:bg-gray-700 for the base and bg-gray-300 dark:bg-gray-600 for shimmer highlights. For custom shimmer gradients, you'll need separate CSS or a data attribute approach — define a .dark .shimmer rule with adjusted rgba opacity values (try rgba(255,255,255,0.05) to rgba(255,255,255,0.12) on dark backgrounds).

How do I respect prefers-reduced-motion for loading animations?

Add motion-reduce:animate-none to any animated skeleton or spinner class. For shimmer, you can fall back to a static background: bg-gray-200 motion-reduce:animate-none. Users who've set reduced motion in their OS preferences will see a static placeholder instead of movement, which is both accessible and perfectly functional.

Can I stagger skeleton animations for a list without Tailwind utility classes?

Yes, use inline React styles for the delay: style={{ animationDelay: ${index * 100}ms }}. There's no practical utility class for per-item animation delays since you'd need one per index. Keep delays modest — 100ms per item, max 500ms total — otherwise the stagger itself becomes a wait.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Animation Utilities: Built-In Classes and Custom KeyframesTailwind Badge & Pill: Status Indicators for Every DesignButton Loading State Animation: Spinner to Check FlowSkeleton Loading Screens: Dark Mode Shimmer Patterns