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
| Variable | Default | Notes |
|---|---|---|
--pulse-dur | 1000ms | sourced from --p14-pulse-dur |
--pulse-count | 1 | sourced from --p14-pulse-count |
--pulse-min | 0.5 | sourced from --p14-pulse-min |
--reveal-dur | 400ms | sourced from --p14-reveal-dur |
--reveal-blur | 2px | sourced from --p14-reveal-blur |
--reveal-ease | ease-in-out | sourced from --p14-reveal-ease |
Credit
Adapted from Skeleton Loader and Reveal on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.