DEV Community

Cover image for How to Build a CSS Skeleton Loader with HTML & CSS
CodeRipple
CodeRipple

Posted on

How to Build a CSS Skeleton Loader with HTML & CSS

A skeleton loader is a great way to improve the perceived loading experience of a website. Instead of showing an empty screen or a traditional spinner, it displays placeholders that resemble the content that is about to appear.

In this tutorial, we'll build a modern CSS Skeleton Loader with a smooth shimmer animation, profile placeholders, a media area, text lines, tags, metadata, and a loading indicator.

The component uses pure HTML and CSS, so no JavaScript is required.

What We're Building

Our skeleton loader includes:

  • Smooth shimmer animation
  • Profile and avatar placeholders
  • Media placeholder
  • Title and text placeholders
  • Tag placeholders
  • Metadata and button placeholders
  • Animated loading indicator
  • Responsive layout
  • Dark modern UI
  • prefers-reduced-motion support
  • No JavaScript dependency

HTML Structure

The HTML represents the structure of a content card while the actual content is loading.

<div class="cr-skeleton" id="crSkeleton">

  <div class="cr-skeleton__card">

    <!-- PROFILE -->
    <div class="cr-skeleton__profile">

      <div
        class="cr-skeleton__avatar cr-skeleton__shimmer"
        aria-hidden="true">
      </div>

      <div class="cr-skeleton__profile-info">

        <div
          class="cr-skeleton__line cr-skeleton__line--name cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

        <div
          class="cr-skeleton__line cr-skeleton__line--role cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

      </div>

      <div
        class="cr-skeleton__badge cr-skeleton__shimmer"
        aria-hidden="true">
      </div>

    </div>


    <!-- MEDIA -->
    <div
      class="cr-skeleton__media cr-skeleton__shimmer"
      aria-hidden="true">

      <div class="cr-skeleton__media-icon">
        <span></span>
      </div>

    </div>


    <!-- CONTENT -->
    <div class="cr-skeleton__content">

      <div
        class="cr-skeleton__line cr-skeleton__line--title cr-skeleton__shimmer"
        aria-hidden="true">
      </div>


      <!-- TEXT -->
      <div class="cr-skeleton__text">

        <div
          class="cr-skeleton__line cr-skeleton__line--full cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

        <div
          class="cr-skeleton__line cr-skeleton__line--full cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

        <div
          class="cr-skeleton__line cr-skeleton__line--medium cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

      </div>


      <!-- TAGS -->
      <div class="cr-skeleton__tags">

        <div
          class="cr-skeleton__tag cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

        <div
          class="cr-skeleton__tag cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

        <div
          class="cr-skeleton__tag cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

      </div>


      <!-- FOOTER -->
      <div class="cr-skeleton__footer">

        <div class="cr-skeleton__meta">

          <div
            class="cr-skeleton__meta-icon cr-skeleton__shimmer"
            aria-hidden="true">
          </div>

          <div
            class="cr-skeleton__line cr-skeleton__line--meta cr-skeleton__shimmer"
            aria-hidden="true">
          </div>

        </div>

        <div
          class="cr-skeleton__button cr-skeleton__shimmer"
          aria-hidden="true">
        </div>

      </div>

    </div>


    <!-- LOADING STATUS -->
    <div class="cr-skeleton__status">

      <span class="cr-skeleton__status-dot"></span>

      <span>Loading content</span>

      <span
        class="cr-skeleton__status-dots"
        aria-hidden="true">

        <i></i>
        <i></i>
        <i></i>

      </span>

    </div>

  </div>

</div>
Enter fullscreen mode Exit fullscreen mode

CSS Styling

Now let's create the layout and shimmer animation.

.cr-skeleton {
  --cr-bg: #070914;
  --cr-card: #0d1222;
  --cr-cyan: #22d3ee;
  --cr-purple: #a855f7;
  --cr-text: #94a3b8;
  --cr-skeleton-base: rgba(148, 163, 184, 0.09);

  width: 100%;
  min-height: 620px;

  display: flex;
  align-items: center;
  justify-content: center;

  padding: 42px 20px;

  background:
    radial-gradient(
      circle at 25% 25%,
      rgba(34, 211, 238, 0.07),
      transparent 32%
    ),
    radial-gradient(
      circle at 78% 75%,
      rgba(168, 85, 247, 0.08),
      transparent 34%
    ),
    #070914;

  font-family:
    Inter,
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    sans-serif;

  box-sizing: border-box;
  overflow: hidden;
}

.cr-skeleton *,
.cr-skeleton *::before,
.cr-skeleton *::after {
  box-sizing: border-box;
}


/* CARD */

.cr-skeleton__card {
  position: relative;

  width: min(100%, 410px);

  padding: 22px;

  border: 1px solid rgba(148, 163, 184, 0.13);
  border-radius: 24px;

  background:
    linear-gradient(
      145deg,
      rgba(255, 255, 255, 0.035),
      rgba(255, 255, 255, 0.008)
    ),
    rgba(13, 18, 34, 0.96);

  box-shadow:
    0 28px 70px rgba(0, 0, 0, 0.42),
    0 0 45px rgba(34, 211, 238, 0.025),
    inset 0 1px 0 rgba(255, 255, 255, 0.035);

  overflow: hidden;
}

.cr-skeleton__card::before {
  content: "";

  position: absolute;
  top: 0;
  left: 17%;
  right: 17%;

  height: 1px;

  background:
    linear-gradient(
      90deg,
      transparent,
      var(--cr-cyan),
      var(--cr-purple),
      transparent
    );

  opacity: 0.8;

  box-shadow:
    0 0 16px rgba(34, 211, 238, 0.28);

  pointer-events: none;
}

.cr-skeleton__card::after {
  content: "";

  position: absolute;

  width: 180px;
  height: 180px;

  top: -100px;
  right: -80px;

  border-radius: 50%;

  background:
    radial-gradient(
      circle,
      rgba(168, 85, 247, 0.08),
      transparent 70%
    );

  pointer-events: none;
}


/* SHIMMER */

.cr-skeleton__shimmer {
  position: relative;
  overflow: hidden;

  background:
    linear-gradient(
      100deg,
      var(--cr-skeleton-base) 0%,
      var(--cr-skeleton-base) 34%,
      rgba(34, 211, 238, 0.15) 43%,
      rgba(168, 85, 247, 0.18) 49%,
      rgba(255, 255, 255, 0.18) 52%,
      rgba(34, 211, 238, 0.13) 57%,
      var(--cr-skeleton-base) 67%,
      var(--cr-skeleton-base) 100%
    );

  background-size: 300% 100%;

  animation: crSkeletonShimmer 2s linear infinite;
}

@keyframes crSkeletonShimmer {
  0% {
    background-position: 140% 0;
  }

  100% {
    background-position: -140% 0;
  }
}


/* PROFILE */

.cr-skeleton__profile {
  position: relative;
  z-index: 2;

  display: grid;
  grid-template-columns: 48px 1fr 54px;
  align-items: center;

  gap: 12px;
  margin-bottom: 17px;
}

.cr-skeleton__avatar {
  width: 48px;
  height: 48px;

  border-radius: 14px;

  border: 1px solid rgba(34, 211, 238, 0.08);
}

.cr-skeleton__profile-info {
  display: flex;
  flex-direction: column;

  gap: 8px;
  min-width: 0;
}

.cr-skeleton__badge {
  width: 54px;
  height: 22px;

  justify-self: end;

  border-radius: 999px;
}


/* SKELETON LINES */

.cr-skeleton__line {
  display: block;

  height: 9px;

  border-radius: 999px;
}

.cr-skeleton__line--name {
  width: 56%;
  height: 11px;
}

.cr-skeleton__line--role {
  width: 38%;
  height: 7px;

  opacity: 0.8;
}


/* MEDIA */

.cr-skeleton__media {
  position: relative;
  z-index: 2;

  width: 100%;
  height: 170px;

  display: flex;
  align-items: center;
  justify-content: center;

  margin-bottom: 19px;

  border: 1px solid rgba(148, 163, 184, 0.08);
  border-radius: 17px;
}

.cr-skeleton__media-icon {
  position: relative;

  width: 42px;
  height: 42px;

  display: flex;
  align-items: center;
  justify-content: center;

  border: 1px solid rgba(148, 163, 184, 0.09);
  border-radius: 12px;

  background: rgba(7, 9, 20, 0.25);

  box-shadow:
    0 8px 25px rgba(0, 0, 0, 0.12);
}

.cr-skeleton__media-icon::before {
  content: "";

  position: absolute;

  width: 19px;
  height: 15px;

  border:
    1.4px solid rgba(203, 213, 225, 0.28);

  border-radius: 4px;
}

.cr-skeleton__media-icon::after {
  content: "";

  position: absolute;

  width: 4px;
  height: 4px;

  top: 12px;
  right: 11px;

  border-radius: 50%;

  background: rgba(34, 211, 238, 0.55);

  box-shadow:
    0 0 7px rgba(34, 211, 238, 0.3);
}

.cr-skeleton__media-icon span {
  position: absolute;

  width: 13px;
  height: 7px;

  bottom: 12px;

  border-left:
    1.4px solid rgba(203, 213, 225, 0.25);

  border-top:
    1.4px solid rgba(203, 213, 225, 0.25);

  transform:
    rotate(45deg)
    skew(-8deg, -8deg);
}


/* CONTENT */

.cr-skeleton__content {
  position: relative;
  z-index: 2;
}

.cr-skeleton__line--title {
  width: 67%;
  height: 14px;

  margin-bottom: 15px;
}


/* TEXT */

.cr-skeleton__text {
  display: flex;
  flex-direction: column;

  gap: 8px;
}

.cr-skeleton__line--full {
  width: 100%;
  height: 8px;
}

.cr-skeleton__line--medium {
  width: 72%;
  height: 8px;
}


/* TAGS */

.cr-skeleton__tags {
  display: flex;
  align-items: center;

  gap: 7px;

  margin-top: 17px;
}

.cr-skeleton__tag {
  width: 52px;
  height: 21px;

  border-radius: 999px;
}

.cr-skeleton__tag:nth-child(2) {
  width: 63px;
}

.cr-skeleton__tag:nth-child(3) {
  width: 47px;
}


/* FOOTER */

.cr-skeleton__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;

  gap: 15px;

  margin-top: 19px;
  padding-top: 16px;

  border-top:
    1px solid rgba(148, 163, 184, 0.08);
}

.cr-skeleton__meta {
  flex: 1;

  display: flex;
  align-items: center;

  gap: 8px;
}

.cr-skeleton__meta-icon {
  flex: 0 0 auto;

  width: 26px;
  height: 26px;

  border-radius: 8px;
}

.cr-skeleton__line--meta {
  width: 70px;
  height: 8px;
}

.cr-skeleton__button {
  width: 90px;
  height: 32px;

  border-radius: 10px;
}


/* LOADING STATUS */

.cr-skeleton__status {
  position: relative;
  z-index: 2;

  display: flex;
  align-items: center;
  justify-content: center;

  gap: 7px;

  margin-top: 17px;

  color: var(--cr-text);

  font-size: 10px;
  font-weight: 600;

  letter-spacing: 0.025em;
}

.cr-skeleton__status-dot {
  width: 6px;
  height: 6px;

  flex: 0 0 auto;

  border-radius: 50%;

  background: var(--cr-cyan);

  box-shadow:
    0 0 5px rgba(34, 211, 238, 0.9),
    0 0 12px rgba(34, 211, 238, 0.45);

  animation:
    crSkeletonPulse 1.5s ease-in-out infinite;
}

@keyframes crSkeletonPulse {
  0%,
  100% {
    opacity: 0.45;
    transform: scale(0.85);
  }

  50% {
    opacity: 1;
    transform: scale(1.15);
  }
}


/* LOADING DOTS */

.cr-skeleton__status-dots {
  display: inline-flex;
  align-items: center;

  gap: 3px;
}

.cr-skeleton__status-dots i {
  display: block;

  width: 3px;
  height: 3px;

  border-radius: 50%;

  background: var(--cr-purple);

  box-shadow:
    0 0 6px rgba(168, 85, 247, 0.45);

  animation:
    crSkeletonDots 1.2s ease-in-out infinite;
}

.cr-skeleton__status-dots i:nth-child(2) {
  animation-delay: 0.14s;
}

.cr-skeleton__status-dots i:nth-child(3) {
  animation-delay: 0.28s;
}

@keyframes crSkeletonDots {
  0%,
  60%,
  100% {
    opacity: 0.25;
    transform: translateY(0);
  }

  30% {
    opacity: 1;
    transform: translateY(-3px);
  }
}


/* MOBILE */

@media (max-width: 520px) {

  .cr-skeleton {
    min-height: auto;
    padding: 30px 15px;
  }

  .cr-skeleton__card {
    width: 100%;
    padding: 19px;
    border-radius: 21px;
  }

  .cr-skeleton__profile {
    grid-template-columns:
      44px 1fr 48px;

    gap: 10px;
  }

  .cr-skeleton__avatar {
    width: 44px;
    height: 44px;

    border-radius: 13px;
  }

  .cr-skeleton__badge {
    width: 48px;
    height: 21px;
  }

  .cr-skeleton__media {
    height: 155px;

    border-radius: 15px;
  }

  .cr-skeleton__line--title {
    width: 75%;
  }

  .cr-skeleton__button {
    width: 82px;
  }
}


/* REDUCED MOTION */

@media (prefers-reduced-motion: reduce) {

  .cr-skeleton__shimmer {
    animation: none !important;

    background:
      var(--cr-skeleton-base);
  }

  .cr-skeleton__status-dot,
  .cr-skeleton__status-dots i {
    animation: none !important;
  }
}
Enter fullscreen mode Exit fullscreen mode

How the Shimmer Effect Works

The main effect comes from the .cr-skeleton__shimmer class.

Instead of using a single background color, we use a wide linear gradient containing darker placeholder colors together with subtle cyan, purple, and light highlights.

background-size: 300% 100%;
animation: crSkeletonShimmer 2s linear infinite;
Enter fullscreen mode Exit fullscreen mode

The background is much wider than the placeholder itself. We then move its background position from one side to the other:

@keyframes crSkeletonShimmer {
  0% {
    background-position: 140% 0;
  }

  100% {
    background-position: -140% 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates the moving shimmer that makes the skeleton feel active while content is loading.

Why Use a Skeleton Loader?

Traditional loading spinners tell users that something is happening, but they don't give much information about what will appear.

A skeleton loader can visually represent the approximate structure of the incoming content.

For example, this component already reserves space for:

  • Avatar
  • User information
  • Media
  • Heading
  • Description
  • Tags
  • Metadata
  • Action button

This makes it useful for interfaces such as blog cards, profile cards, dashboards, feeds, product cards, and content-heavy applications.

No JavaScript Required

One useful feature of this component is that the visual loading animation is handled entirely with CSS.

You don't need JavaScript to run the shimmer animation.

JavaScript would only be needed in a real application if you wanted to control when the skeleton disappears and when the actual loaded content becomes visible.

Responsive Design

The component includes a mobile breakpoint:

@media (max-width: 520px) {
  .cr-skeleton__card {
    width: 100%;
    padding: 19px;
  }

  .cr-skeleton__media {
    height: 155px;
  }
}
Enter fullscreen mode Exit fullscreen mode

This allows the loader to adapt to smaller screens while preserving the same overall content structure.

Reduced Motion Support

Continuous animations can be distracting for users who prefer reduced motion.

The component respects the user's operating-system preference:

@media (prefers-reduced-motion: reduce) {

  .cr-skeleton__shimmer {
    animation: none !important;
    background: var(--cr-skeleton-base);
  }

  .cr-skeleton__status-dot,
  .cr-skeleton__status-dots i {
    animation: none !important;
  }
}
Enter fullscreen mode Exit fullscreen mode

When reduced motion is enabled, the placeholders remain visible but the continuous animations stop.

Live Demo

You can test the component on CodePen:

https://codepen.io/editor/getcoderipple/pen/01a10bf4-ac96-7955-a1ae-cd0ee3d150f0?file=%2Findex.html&orientation=left&panel=files&show=split

Customizing the Loader

The design uses CSS custom properties, so you can quickly adapt the loader to your own project.

For example:

.cr-skeleton {
  --cr-cyan: #22d3ee;
  --cr-purple: #a855f7;
  --cr-text: #94a3b8;
  --cr-skeleton-base: rgba(148, 163, 184, 0.09);
}
Enter fullscreen mode Exit fullscreen mode

You can change these values to match your website's branding.

You can also modify:

  • Card width
  • Media height
  • Shimmer speed
  • Placeholder widths
  • Border radius
  • Glow intensity
  • Background colors

Final Thoughts

Skeleton loaders are a simple way to make loading states feel more polished and structured.

This version combines a practical content-card layout with a subtle cyan and purple shimmer while keeping the implementation lightweight with pure HTML and CSS.

If you'd like the complete tutorial, live preview, and copy-ready source code, you can find it on CodeRipple:

https://getcoderipple.com/css-skeleton-loader/

Happy coding! 🚀

Top comments (0)