Simple but Effective Skeleton Loaders

May 2, 2025

Skeleton loaders are a simple and effective way to display a user interface before data has loaded. Instead of showing an uninformative loading spinner, they allow users to orient themselves and anticipate where relevant information will appear.

A skeleton loader with a shimmer animation

Although building a skeleton loader isn’t overly complicated, I didn’t find a solution that ticked all the boxes. I tried a few approaches and eventually came up with something I really like.

If you’re not interested in the explanation, head over to the final code.

Requirements and Concept

The existing solutions I found generally fall into two categories:

  1. They offer basic primitives to define placeholder shapes, or
  2. They use SVGs, allowing any custom shape to be drawn.

I wasn’t satisfied with predefined shapes because I want my skeleton loaders to closely resemble the actual UI. For the same reason, I wasn’t thrilled with the SVG approach either — I want the skeleton to behave just like the real interface, using the same responsive layout, grids, and structure.

Here were my main requirements for the skeleton loader:

  • I must be able to define the elements using HTML and CSS, so I can lay them out exactly as I do with the real content.
  • The shimmer effect should appear uniform across all elements.
  • The code should remain simple and maintainable.
  • It should perform well.

Implementation

Let’s start with a simple CSS class that will be responsible for displaying the skeleton loader.

.skeleton {
  /* Make sure the skeleton wrapper doesn't influence the layout. */
  display: contents;

  * {
    /* Make all elements silver. If you want, you can also add a
      border radius here.  */
    background-color: #e8e8e8;
  }
}

We can now use the .skeleton class like this:

<div class="skeleton">
  <div class="profile-image"></div>
  <div class="profile-name"></div>
</div>

<style>
  .profile-image {
    margin-bottom: 16px;
    border-radius: 100%;
    aspect-ratio: 1;
    width: 100px;
  }
  .profile-name {
    width: 100px;
    height: 20px;
  }
</style>

This already gets us halfway there (CodePen):

Basic skeleton

Add Shimmer Effect

Now for the fun part: let’s add the shimmer animation. A shimmer effect is essentially a gradient that sweeps across the element from one side to the other. I find that tilting the gradient slightly makes the animation feel more dynamic.

Let’s update our .skeleton CSS class to use the animation (CodePen):

@keyframes skeleton-shimmer {
  0% {
    background-position-x: 200%;
  }
  100% {
    background-position-x: 0%;
  }
}
.skeleton {
  display: contents;

  /* Define the colors as css custom props */
  --_default-color: #e8e8e8;
  --_shimmer-color: #fff;

  * {
    /* Apply the animation that moves the background */
    animation: skeleton-shimmer 2s ease-out infinite;

    /* Create a background that has a small shimmer "stripe"
      and tilt it 100 degrees. */
    background: linear-gradient(
      100deg,
      var(--_default-color),
      var(--_default-color) 50%,
      var(--_shimmer-color) 60%,
      var(--_default-color) 70%
    );

    /* Make the background twice the width so we can offset it. */
    background-size: 200% 100%;
  }
}

In our simple example, it might not be immediately obvious, but each element currently has its own independent animation. In a more complex layout, this results in something like the following:

A skeleton loader where each element has a separate shimmer animation

This isn’t what we want — we want a single, unified shimmer effect that runs across all elements. Fortunately, the fix is quite simple: use the CSS property background-attachment: fixed; (CodePen). This causes the background gradient to be "fixed" relative to the viewport, ensuring that all elements stay in sync.

:global(*) {
  /* ... */
  background-attachment: fixed;
}

Layout Elements

Now that we have the basic building blocks, let’s create a more complex skeleton loader. Let’s say we want to include a profile image with three lines of text next to it.

For that we’d create a few grid containers to lay out the content. The code for the skeleton loader might look like this:

<div class="skeleton">
  <div class="image"></div>
  <div class="lines">
    <div></div>
    <div></div>
    <div></div>
  </div>
</div>

<style>
  .skeleton {
    border: 1px solid black;
    padding: 2.5rem;
    width: 80%;
    * {
      border-radius: 0.5rem;
    }
  }
  .skeleton {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 1rem;
  }
  .lines {
    display: grid;
    gap: 1rem;
    > * {
      height: 1.5rem;
      &:last-child {
        width: 80%;
      }
    }
  }
  .image {
    aspect-ratio: 1;
  }
</style>

If you try this, you’ll notice that the lines results in a single gray rectangle (CodePen). This is expected, as our .lines wrapper div is treated the same as the other elements.

To fix this, we can add a .wrapper class to these elements to exclude them from being rendered as placeholder boxes. Here are the changes needed in our .skeleton css class:

.skeleton {
  :not(.wrapper) {
    /* ... */
  }
}

Now, we can simply add the wrapper class to the .lines div, and it will no longer be rendered (CodePen).

Final Code

To sum it up, here is the complete final code of the .skeleton css class:

@keyframes skeleton-shimmer {
  0% {
    background-position-x: 200%;
  }
  100% {
    background-position-x: 0%;
  }
}
.skeleton {
  display: contents;

  /* Define the colors as css custom props */
  --_default-color: #e8e8e8;
  --_shimmer-color: #fff;

  :not(.wrapper) {
    /* Apply the animation that moves the background */
    animation: skeleton-shimmer 2s ease-out infinite;

    /* Create a background that has a small shimmer "stripe"
      and tilt it 100 degrees. */
    background: linear-gradient(
      100deg,
      var(--_default-color),
      var(--_default-color) 50%,
      var(--_shimmer-color) 60%,
      var(--_default-color) 70%
    );

    /* Make the background twice the width so we can offset it. */
    background-size: 200% 100%;
    background-attachment: fixed;
  }
}

Accessibility

When adding a skeleton loader, you should make sure that you use the aria-live="polite" and aria-busy attributes to make screenreaders properly handle your content.

Bonus Svelte Component

Here is a Svelte implementation of this idea. There is <Loading> component that sets the appropriate aria- attributes and renderes the <Skeleton> component while it’s loading.

This implementation allows you to add a skeleton loader like this:

<Loading {data}>
  {#snippet loaded(data)}
    <!-- Will be rendered when `data` is set -->
    {data}
  {/snippet}

  {#snippet skeleton()}
    <!--
      Uses the <Skeleton> component to render the
      skeleton when `data` is undefined
    -->
    <div class="wrapper lines">
      <div></div>
      <div></div>
      <div></div>
    </div>
  {/snippet}
</Loading>

Of course there are multiple ways to approach this, so feel free to play around with it and maybe you’ll find something that suits you better.

Need a Break?

I built Pausly to help people like us step away from the screen for just a few minutes and move in ways that refresh both body and mind. Whether you’re coding, designing, or writing, a quick break can make all the difference.

Give it a try
Pausly movement