EmpireUI
Get Pro
← Blog8 min read#skeleton#loading#animation

Skeleton Loading Animation in CSS: Shimmer, Pulse, Wave Variants

Build shimmer, pulse, and wave skeleton loaders in pure CSS — no libraries needed. Covers keyframes, Tailwind variants, React integration, and accessibility.

abstract grey loading placeholder UI skeleton screen animation

What a Skeleton Loader Actually Is (and Why It Beats a Spinner)

A skeleton loader is a placeholder UI — grey boxes, lines, and circles that match the rough shape of the content that's loading. No spinner. No "Loading..." text. Just a structural preview that tells users *what's coming* before the data arrives. It's been the gold standard for perceived performance since around 2013 when LinkedIn started using the pattern, and it hasn't lost relevance since.

The reason skeletons feel faster than spinners is psychological, not technical. A spinner gives you zero information. A skeleton says: "here's a card, it has an avatar, a title, two lines of text." Your brain starts building the mental model while the data is still in flight. Facebook's 2014 research on this found meaningful reductions in abandonment rates simply by switching from spinners to content-shaped placeholders.

Honestly, the implementation is way simpler than most tutorials make it look. You don't need a library. Three CSS animations — shimmer, pulse, and wave — cover 95% of real-world use cases, and every one of them is doable in under 20 lines of CSS.

That said, picking the *wrong* animation variant can make your UI feel cheap or jittery. Each variant has a specific personality and works best in specific contexts. Let's break them down one by one.

The Pulse Variant: Simplest to Ship

Pulse is your bread-and-butter skeleton animation. You take a grey shape, make it fade opacity up and down on a loop. That's the entire trick. It's visually calm — no motion across the screen, no directional cue — which makes it ideal for quiet loading states like a user profile card or a settings panel.

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0.4; }
}

.skeleton {
  background-color: #e2e8f0;
  border-radius: 4px;
  animation: pulse 1.5s ease-in-out infinite;
}

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

.skeleton-line {
  height: 14px;
  width: 100%;
  margin-top: 8px;
}

.skeleton-line--short {
  width: 60%;
}

In Tailwind CSS v3+, you get this for free with animate-pulse. Drop bg-gray-200 dark:bg-gray-700 animate-pulse rounded on any div and you're done. The 1.5s duration Tailwind uses by default is well-calibrated — faster feels frantic, slower feels broken. Worth noting: if you're customizing the duration, stay between 1.2s and 2s.

One more thing — on dark mode you'll want a slightly brighter grey to maintain visibility. Tailwind's dark:bg-gray-700 maps to #374151, which works well against dark backgrounds without looking too bright. If you're rolling your own CSS, rgb(55, 65, 81) at opacity 0.7 is a safe pick.

The Shimmer Variant: Moving Light Across the Skeleton

Shimmer is the flashier sibling. It runs a bright streak of light across the skeleton element — left to right — mimicking a reflection, like light catching a surface. It's what Airbnb, YouTube, and most major content platforms use because it implies progress. The eye reads the moving light as "something is happening here," which feels more alive than a slow fade.

The technique uses a background-image linear gradient with a translucent white stripe, animated via background-position. The element needs overflow: hidden to clip the moving gradient at the edges, and you want background-size: 200% 100% so the gradient has room to travel the full width without instantly repeating.

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

.skeleton-shimmer {
  background-color: #e2e8f0;
  background-image: linear-gradient(
    90deg,
    transparent 0%,
    rgba(255, 255, 255, 0.6) 50%,
    transparent 100%
  );
  background-size: 200% 100%;
  animation: shimmer 1.4s linear infinite;
  border-radius: 4px;
  overflow: hidden;
}

Dark-mode shimmer is worth a moment of thought. Instead of a white streak on a grey base, flip it: use a slightly lighter grey base (#2d3748) with a rgba(255,255,255,0.08) highlight stripe. The difference is subtle but stops the animation from looking like a white flash on a dark screen.

Quick aside: shimmer gets expensive at scale. If you have 20+ shimmer elements animating simultaneously — say, a long list of cards — each background-position change triggers a repaint. Consider using a pseudo-element overlay approach instead, where a single ::after with position: absolute and width: 100% covers the skeleton, and *only that one element* is animating. All skeleton children just inherit the base grey. Fewer compositing layers, same visual result.

The Wave Variant: Staggered Timing Across Multiple Elements

Wave is pulse, but with each skeleton element starting its animation at a different time offset. The result is a cascading ripple — the avatar pulses, then the first text line, then the second — rolling down the card like a wave. It's subtle but it gives the skeleton a sense of organic rhythm that flat pulse doesn't have.

You implement it with animation-delay on each child. The math is simple: pick a delay step (usually 100ms to 150ms) and multiply it by the element's index. In plain CSS you'd use :nth-child selectors. In React you'd pass an index prop and set it inline.

// React skeleton card with wave animation
function SkeletonCard() {
  const lines = [100, 75, 60]; // widths as percentages

  return (
    <div className="p-4 rounded-xl border border-gray-100 space-y-3">
      {/* Avatar */}
      <div
        className="w-12 h-12 rounded-full bg-gray-200 animate-pulse"
        style={{ animationDelay: '0ms' }}
      />
      {/* Text lines */}
      {lines.map((width, i) => (
        <div
          key={i}
          className="h-3 rounded bg-gray-200 animate-pulse"
          style={{
            width: `${width}%`,
            animationDelay: `${(i + 1) * 120}ms`,
          }}
        />
      ))}
    </div>
  );
}

In practice, wave works best for vertically stacked content — feed items, comment threads, notification lists. It doesn't translate well to horizontal layouts like a photo grid because the left-to-right reading eye expects the shimmer to also move horizontally, not pulse sequentially.

Look, you can combine wave delays with shimmer too — give each skeleton element a different animation-delay on the shimmer keyframe. The effect is a ripple of light moving across your layout. It's a bit aggressive for most UIs, but it can look genuinely impressive on a hero loading state for a dashboard. If you're building something with strong motion personality, also check out Empire UI's aurora components — they pair surprisingly well with shimmer skeletons during page transitions.

React Component: Production-Ready Skeleton System

Wiring this up in a real project means you want a composable skeleton API — not a one-size-fits-all <Skeleton /> component that you have to fight every time the shape changes. The pattern below gives you Skeleton.Box, Skeleton.Text, and Skeleton.Circle primitives that you assemble into whatever shape your real content has.

// skeleton.tsx
type Variant = 'pulse' | 'shimmer' | 'wave';

interface SkeletonProps {
  className?: string;
  variant?: Variant;
  delay?: number; // ms, for wave stagger
}

const baseClass = 'bg-gray-200 dark:bg-gray-700 rounded';

const variantClass: Record<Variant, string> = {
  pulse: 'animate-pulse',
  shimmer: 'skeleton-shimmer', // custom class from CSS above
  wave: 'animate-pulse',       // delay applied inline
};

export const Skeleton = {
  Box({ className = '', variant = 'pulse', delay = 0 }: SkeletonProps) {
    return (
      <div
        className={`${baseClass} ${variantClass[variant]} ${className}`}
        style={delay ? { animationDelay: `${delay}ms` } : undefined}
        aria-hidden="true"
      />
    );
  },
  Text({ className = '', variant = 'pulse', delay = 0 }: SkeletonProps) {
    return (
      <div
        className={`h-4 ${baseClass} ${variantClass[variant]} ${className}`}
        style={delay ? { animationDelay: `${delay}ms` } : undefined}
        aria-hidden="true"
      />
    );
  },
  Circle({ size = 48, variant = 'pulse', delay = 0 }: SkeletonProps & { size?: number }) {
    return (
      <div
        className={`rounded-full ${baseClass} ${variantClass[variant]}`}
        style={{ width: size, height: size, ...(delay ? { animationDelay: `${delay}ms` } : {}) }}
        aria-hidden="true"
      />
    );
  },
};

Then you compose these to mirror your real content's structure: if the real card has a 48px avatar, a bold title, and two body lines, your skeleton card is <Skeleton.Circle size={48} />, <Skeleton.Text className="w-3/4" />, <Skeleton.Text className="w-full" />, <Skeleton.Text className="w-1/2" />. One-to-one mapping. No guessing.

For pages with complex layouts, Empire UI's template system generates matched skeleton states automatically when you scaffold with the MCP server — you describe the card layout once and get both the real component and the skeleton placeholder. That's the MCP page workflow if you haven't tried it yet.

Worth noting: if you're using React Suspense with <Suspense fallback={<SkeletonCard />}>, make sure your fallback component is *statically sized*. If the skeleton height doesn't match the real content height, you'll get layout shift when the content loads — exactly the problem you were trying to avoid.

Accessibility: Skeletons and Screen Readers

Screen readers don't care about your beautiful shimmer animation. They'll either read nothing (if you use aria-hidden="true") or announce a confusing blob of empty text. Getting this right is simpler than you'd think.

The correct pattern is to wrap your entire skeleton in aria-hidden="true" and provide a separate, visually-hidden live region that announces loading status to assistive technology. This keeps the DOM clean for sighted users while still communicating state to screen reader users.

function PostList({ isLoading, posts }) {
  return (
    <section>
      {/* Screen reader live region */}
      <span
        role="status"
        aria-live="polite"
        className="sr-only"
      >
        {isLoading ? 'Loading posts...' : 'Posts loaded.'}
      </span>

      {/* Visual skeleton or real content */}
      {isLoading ? (
        <div aria-hidden="true">
          {[...Array(4)].map((_, i) => (
            <SkeletonCard key={i} variant="wave" delay={i * 120} />
          ))}
        </div>
      ) : (
        posts.map(p => <PostCard key={p.id} {...p} />)
      )}
    </section>
  );
}

The sr-only class (Tailwind: position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0)) keeps the text invisible on screen. aria-live="polite" means the screen reader announces the status change at the next opportunity — not interrupting what the user is currently hearing.

Also: don't forget prefers-reduced-motion. Both shimmer and wave involve continuous looping animation, which can cause problems for users with vestibular disorders. A one-liner media query handles it — set animation: none inside @media (prefers-reduced-motion: reduce) and the skeleton just becomes a static grey placeholder, which is completely fine. The shape still tells the story.

Styling Skeletons to Match Your Design System

The default grey (#e2e8f0) works for most light-mode designs, but if you're building with a specific visual language you'll want to adapt the skeleton palette to match. For glassmorphism UIs, a semi-transparent skeleton — rgba(255,255,255,0.12) with backdrop-filter: blur(4px) — blends into the glass surface instead of looking like a grey block on a frosted card.

For darker styles like cyberpunk or vaporwave, drop your skeleton base to something like #1a1a2e with a shimmer streak of rgba(0, 255, 200, 0.15). That teal-ish highlight reads as "loading" in the same visual language as the UI around it. You can prototype these combinations in minutes using the gradient generator to find the right shimmer color.

Neumorphism is the most specific case. Because neumorphism relies on matched light and shadow on a specific background color, a skeleton that doesn't match the background exactly breaks the illusion completely. Your skeleton background color needs to be *identical* to your page surface (#e0e5ec for a typical neumorphic light theme), with the box shadows still present. The pulse or wave animation then fades the shadows rather than the fill.

One more thing — corner radii matter more than you'd think. If your real content cards use border-radius: 16px and your skeletons use rounded-md (6px), the swap from skeleton to content will feel jarring even if the sizing is perfect. Match radii exactly. This is a small thing that separates "good skeleton" from "polished skeleton."

FAQ

What's the difference between a shimmer and a pulse skeleton animation?

Pulse fades the skeleton's opacity in and out — simple, no motion across the screen. Shimmer runs a bright streak of light horizontally across the element, implying directional progress. Shimmer feels more dynamic; pulse feels calmer.

Can I use CSS skeleton animations without any JavaScript library?

Yes, completely. All three variants — pulse, shimmer, wave — are pure CSS keyframe animations. Tailwind's animate-pulse gives you pulse for free. Shimmer needs about 15 lines of custom CSS; wave just adds animation-delay offsets.

How do I stop skeleton animations from breaking accessibility?

Add aria-hidden="true" to the skeleton container so screen readers skip it, then use a visually-hidden role="status" aria-live="polite" element to announce "Loading..." and "Loaded." Also add animation: none inside @media (prefers-reduced-motion: reduce).

Should I match skeleton sizing exactly to the real content?

Yes — even small mismatches in height, border-radius, or spacing between skeleton and real content cause layout shift when content loads, which is jarring and hurts Core Web Vitals CLS scores. Build your skeleton as a structural mirror of the real component.

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

Read next

CSS Loading Spinners: 12 Variants From Dots to Rings to BarsCSS Scroll Snap: Precise Scrolling Sections Without JavaScriptY2K Loading Animation in CSS: Spinners, Progress Bars and Digital ClocksProgress Bar in Tailwind: Animated, Striped, Segmented Variants