Command Palette

Search for a command to run...

Design Engineering
Your loading skeleton is lying toyou

Your loading skeleton is lying to you.

Most skeleton screens use CSS pulse animations that feel mechanical. Spring-based skeletons breathe.

4 min read·Design Engineering

Open any app with a skeleton loading state. Watch the gray rectangles pulse. They fade from opacity: 0.3 to opacity: 0.7 and back, on a fixed 1.5-second CSS animation loop.

Now watch someone breathing. Inhale, pause, exhale, pause. The rhythm isn't mechanical. It's organic. The pause at the top is longer than the pause at the bottom. The acceleration into each breath is different from the deceleration out.

Skeleton screens are supposed to communicate "content is coming, stay patient." Instead, they communicate "I am a robot performing a loop."


The CSS pulse problem

@keyframes pulse {
  0%,
  100% {
    opacity: 0.3;
  }
  50% {
    opacity: 0.7;
  }
}
.skeleton {
  animation: pulse 1.5s ease-in-out infinite;
}

This is a symmetric triangle wave with easing. Every cycle is identical. The timing is fixed. After three loops, your brain locks onto the pattern and stops receiving "loading." It starts receiving "stuck."

Fixed-frequency repetition triggers a specific psychological response: habituation. Your attention system ignores periodic stimuli after a few cycles. The skeleton becomes invisible, and the perceived wait time increases.


Spring-based breathing

Replace the CSS keyframe with a spring that oscillates between two opacity values:

<motion.div
  animate={{ opacity: [0.3, 0.6] }}
  transition={{
    type: "spring",
    stiffness: 10,
    damping: 2,
    mass: 1.5,
    repeat: Infinity,
    repeatType: "mirror",
  }}
  className="h-4 rounded bg-muted"
/>

The spring produces asymmetric timing. The rise toward 0.6 is different from the fall toward 0.3. There's a natural ease at the peaks, a "breath hold" that CSS keyframes can't replicate without complex multi-stop gradients.

More importantly, the spring's oscillation is never perfectly periodic. Floating-point math in the physics simulation introduces micro-variations in timing that your brain reads as organic rather than mechanical.


The shimmer alternative

Some apps use a shimmer, a gradient that sweeps left-to-right across the skeleton. This is better than pulse because it implies directionality (content is "arriving"), but most implementations use a CSS animation with fixed duration:

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

A spring-based shimmer carries its velocity through the sweep. The gradient accelerates as it enters, maintains momentum through the middle, and decelerates as it exits. It feels like something passing through rather than a cursor scanning.


Staggered skeleton groups

Individual skeleton elements breathing in sync feels robotic. Stagger the spring onset:

{
  [0, 1, 2, 3].map((i) => (
    <motion.div
      key={i}
      animate={{ opacity: [0.3, 0.6] }}
      transition={{
        type: "spring",
        stiffness: 10,
        damping: 2,
        mass: 1.5,
        repeat: Infinity,
        repeatType: "mirror",
        delay: i * 0.15,
      }}
      className="h-4 rounded bg-muted"
    />
  ));
}

The 150ms stagger creates a wave effect. Elements breathe in sequence rather than in unison. This communicates "multiple things are loading" more effectively than synchronized pulsing.


When skeletons are wrong

Skeletons work for known layouts. You know a card has a title, image, and description, so you show placeholder shapes. They fail for unknown content. You don't know how many items a list will have, or whether the response will be empty.

For unknown content, a spring-based loading indicator (a dot, a subtle bar) communicates "working" without committing to a layout that might be wrong.

The worst skeleton: one that shows 10 placeholder cards when the API returns 2 items. The skeleton lied about what was coming. The user's mental model was violated.


The cost

Spring-based skeleton animations require JavaScript. CSS pulse animations are zero-JS. For a page with 30 skeleton elements, the difference matters.

My approach: use CSS pulse for the initial render (it appears instantly, no JS needed), then upgrade to spring animation once React hydrates. The transition is imperceptible. By the time JS loads, the user has only seen 1-2 CSS pulse cycles before the spring takes over.

const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
 
// CSS pulse first, spring after hydration

The skeleton that breathes feels alive. The one that pulses feels like a machine waiting. Both communicate "loading." One communicates it with empathy.

We break down every design decision on Twitter.

Follow @ruixen_ui

Read more like this

Your loading skeleton is lying to you. | Ruixen UI | Ruixen UI