Pulse to content cross-fade
Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.
/* Transitions.dev — Skeleton loader and reveal */
/*
Usage:
<div class="t-skel" data-state="loading">
<div class="t-skel-skeleton is-pulsing">…</div>
<div class="t-skel-content">…</div>
</div>
State:
- Mount with `.is-pulsing` on the skeleton so it pulses
--pulse-count times.
- When data arrives, add `.is-revealed` to .t-skel — the
skeleton fades out + blurs and the content fades in +
un-blurs over --reveal-dur.
- To replay the loading state without animating the
reverse: add `.is-resetting` to .t-skel, remove
`.is-revealed`, force a reflow, then drop `.is-resetting`.
Bring your own avatar / text / wrapping. The skeleton stays
in the same flex slot as the content so the swap is
layout-free.
*/
: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; }
}
Self-contained component; the styles inject themselves on first import.
// Transitions.dev — Skeleton loader and reveal (React, self-contained)
// Drop into any React project — no extra CSS file needed.
import { useRef, useState } from "react";
// ── Styles ──────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
: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; }
}
`;
if (typeof document !== "undefined" && !document.getElementById("transitions-p14")) {
const __style = document.createElement("style");
__style.id = "transitions-p14";
__style.textContent = __TRANSITION_STYLES;
document.head.appendChild(__style);
}
// Pair with the CSS from the CSS tab.
// `loading` is yours to drive (data fetch, route change, etc.).
// On every fresh load → ready cycle, call `replay()` to flash the
// skeleton again before revealing the content.
export function SkeletonReveal({ skeleton, children }) {
const wrapRef = useRef(null);
const skelRef = useRef(null);
const timerRef = useRef(null);
const [revealed, setRevealed] = useState(false);
const replay = () => {
const wrap = wrapRef.current;
const skel = skelRef.current;
if (!wrap || !skel) return;
if (timerRef.current) window.clearTimeout(timerRef.current);
// Snap-back: kill transitions for one frame so the reverse
// (revealed → skeleton) is instant.
wrap.classList.add("is-resetting");
setRevealed(false);
skel.classList.remove("is-pulsing");
void skel.offsetWidth;
wrap.classList.remove("is-resetting");
skel.classList.add("is-pulsing");
const dur = readMs("--pulse-dur", 1000);
const count = parseFloat(
getComputedStyle(document.documentElement)
.getPropertyValue("--pulse-count")
) || 1;
timerRef.current = window.setTimeout(() => {
setRevealed(true);
timerRef.current = null;
}, dur * count);
};
return (
<>
<button type="button" onClick={replay}>Animate</button>
<div
ref={wrapRef}
className={"t-skel" + (revealed ? " is-revealed" : "")}
>
<div ref={skelRef} className="t-skel-skeleton is-pulsing" aria-hidden="true">
{skeleton}
</div>
<div className="t-skel-content">{children}</div>
</div>
</>
);
}
function readMs(name, fallback) {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue(name).trim();
const n = parseFloat(raw);
return Number.isFinite(n) ? n : fallback;
}