Back to Code Bytes
4 min read
Skeleton Loader and Reveal

Description

Skeleton Loader and Reveal stacks the skeleton and real content in the same slot using absolute positioning. While data loads, the skeleton bars pulse once to signal activity. When data arrives, the skeleton fades out with a soft blur while the content fades in from the same blurred rest state, so both layers animate in the same direction and the swap reads as a single motion rather than two independent events. Because neither layer shifts the layout, there is no reflow when the content appears.

Example

Ada Lovelace

Product Designer

CSS

:root {
  --pulse-dur: 1000ms;
  --pulse-count: 1;
  --pulse-min: 0.5;
  --reveal-dur: 400ms;
  --reveal-blur: 2px;
  --reveal-ease: ease-in-out;
}

/* The wrap stacks two layers on the same coordinates. The
   skeleton owns the cold pulse + the fade-out side of the
   reveal; the content owns the fade-in side. They share the
   same duration / ease so the swap reads as one motion. */
.t-skel {
  position: relative;
}
.t-skel-skeleton,
.t-skel-content {
  position: absolute;
  inset: 0;
}

.t-skel-skeleton {
  z-index: 1;
  opacity: 1;
  filter: blur(0);
  transition:
    opacity var(--reveal-dur) var(--reveal-ease),
    filter var(--reveal-dur) var(--reveal-ease);
}
.t-skel-content {
  z-index: 2;
  opacity: 0;
  filter: blur(var(--reveal-blur));
  transition:
    opacity var(--reveal-dur) var(--reveal-ease),
    filter var(--reveal-dur) var(--reveal-ease);
}
.t-skel.is-revealed .t-skel-skeleton {
  opacity: 0;
  filter: blur(var(--reveal-blur));
}
.t-skel.is-revealed .t-skel-content {
  opacity: 1;
  filter: blur(0);
}
/* Snap-back when replaying: kill transitions so the reverse
   (revealed → skeleton) is instant. Drop `.is-resetting`
   after a forced reflow and the next reveal animates again. */
.t-skel.is-resetting .t-skel-skeleton,
.t-skel.is-resetting .t-skel-content {
  transition: none !important;
}

/* Pulse: place the animation on the bar/avatar children, not
   on the skeleton itself, so the skeleton's opacity / filter
   stay free for the cross-fade transition above. */
.t-skel-skeleton.is-pulsing > * {
  animation: t-skel-pulse var(--pulse-dur) ease-in-out var(--pulse-count);
}
@keyframes t-skel-pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: var(--pulse-min);
  }
}

@media (prefers-reduced-motion: reduce) {
  .t-skel-skeleton,
  .t-skel-content {
    transition: none !important;
  }
  .t-skel-skeleton.is-pulsing > * {
    animation: none !important;
  }
}

React

import { useEffect, useState } from "react";
import "./skeleton-reveal.css"; // paste the CSS above

function SkelRun() {
  const [revealed, setRevealed] = useState(false);
  useEffect(() => {
    const cs = getComputedStyle(document.documentElement);
    const num = (n: string, fb: number) =>
      parseFloat(cs.getPropertyValue(n)) || fb;
    const total = num("--pulse-dur", 1000) * num("--pulse-count", 1);
    const id = window.setTimeout(() => setRevealed(true), total);
    return () => window.clearTimeout(id);
  }, []);
  return (
    <div
      className={"t-skel" + (revealed ? " is-revealed" : "")}
      style={{ position: "relative", width: 260, height: 64 }}
    >
      <div
        className="t-skel-skeleton is-pulsing"
        style={{ display: "flex", alignItems: "center", gap: 12 }}
      >
        {/* your skeleton bars */}
      </div>
      <div
        className="t-skel-content"
        style={{ display: "flex", alignItems: "center", gap: 12 }}
      >
        {/* your real content */}
      </div>
    </div>
  );
}

export function SkeletonReveal() {
  const [key, setKey] = useState(0);
  return (
    <div>
      <SkelRun key={key} />
      <button onClick={() => setKey((k) => k + 1)}>Replay</button>
    </div>
  );
}

Variables

VariableDefaultNotes
--pulse-dur1000mssourced from --p14-pulse-dur
--pulse-count1sourced from --p14-pulse-count
--pulse-min0.5sourced from --p14-pulse-min
--reveal-dur400mssourced from --p14-reveal-dur
--reveal-blur2pxsourced from --p14-reveal-blur
--reveal-easeease-in-outsourced from --p14-reveal-ease

Credit

Adapted from Skeleton Loader and Reveal on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.