Skeleton to Content Crossfade — Free HTML CSS JS Loading Snippet
Skeleton to Content Crossfade · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="stc-wrap">
<button class="stc-btn" id="stcReload">Reload data</button>
<div class="stc-stack" id="stcStack" aria-busy="true" aria-live="polite">
<div class="stc-layer stc-skeleton" id="stcSkeleton">
<div class="stc-sk-row">
<div class="stc-sk stc-sk-avatar"></div>
<div class="stc-sk-lines">
<div class="stc-sk stc-sk-line" style="width:60%"></div>
<div class="stc-sk stc-sk-line" style="width:40%"></div>
</div>
</div>
<div class="stc-sk stc-sk-block"></div>
<div class="stc-sk stc-sk-line" style="width:90%"></div>
<div class="stc-sk stc-sk-line" style="width:75%"></div>
</div>
<div class="stc-layer stc-content" id="stcContent">
<div class="stc-row">
<div class="stc-avatar">JM</div>
<div>
<p class="stc-name">Jordan Mills</p>
<p class="stc-role">Product Designer · 2h ago</p>
</div>
</div>
<div class="stc-block" aria-hidden="true">
<svg viewBox="0 0 200 90" width="100%" height="100%"><rect width="200" height="90" rx="10" fill="#eef2ff"/><path d="M10 70 L50 40 L85 55 L120 20 L160 45 L190 15" stroke="#818cf8" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<p class="stc-text">Shipped the new onboarding flow this morning — early signups are already converting 18% better than the previous version.</p>
</div>
</div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f8fafc;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.stc-wrap{width:100%;max-width:380px}
.stc-btn{padding:9px 16px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:700;cursor:pointer;margin-bottom:14px}
.stc-btn:hover{background:#4f46e5}
.stc-btn:disabled{opacity:.6;cursor:default}
.stc-stack{position:relative;background:#fff;border-radius:16px;padding:20px;box-shadow:0 4px 24px rgba(15,23,42,.08);min-height:220px}
.stc-layer{position:absolute;inset:0;padding:20px;transition:opacity .35s ease;display:flex;flex-direction:column;gap:12px}
.stc-skeleton{opacity:1}
.stc-content{opacity:0;pointer-events:none}
.stc-stack.stc-loaded .stc-skeleton{opacity:0;pointer-events:none}
.stc-stack.stc-loaded .stc-content{opacity:1;pointer-events:auto}
.stc-sk-row{display:flex;gap:10px;align-items:center}
.stc-sk{border-radius:8px;background:linear-gradient(90deg,#eef1f6 25%,#f6f8fb 37%,#eef1f6 63%);background-size:400% 100%;animation:stcShimmer 1.4s ease-in-out infinite}
.stc-sk-avatar{width:40px;height:40px;border-radius:50%;flex-shrink:0}
.stc-sk-lines{flex:1;display:flex;flex-direction:column;gap:7px}
.stc-sk-line{height:11px}
.stc-sk-block{height:90px}
@keyframes stcShimmer{0%{background-position:100% 0}100%{background-position:0 0}}
.stc-row{display:flex;gap:10px;align-items:center}
.stc-avatar{width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-weight:800;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.stc-name{font-size:13.5px;font-weight:700;color:#0f172a}
.stc-role{font-size:11.5px;color:#94a3b8;margin-top:1px}
.stc-block{border-radius:10px;overflow:hidden;height:90px}
.stc-text{font-size:13px;color:#475569;line-height:1.6}(function(){
var stack = document.getElementById('stcStack');
var btn = document.getElementById('stcReload');
function load() {
btn.disabled = true;
stack.classList.remove('stc-loaded');
stack.setAttribute('aria-busy', 'true');
// simulate a network request; swap for a real fetch() in production
setTimeout(function () {
stack.classList.add('stc-loaded');
stack.setAttribute('aria-busy', 'false');
btn.disabled = false;
}, 1400);
}
btn.addEventListener('click', load);
load();
})();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Skeleton to Content Crossfade — Stacked Layers with One Class Toggle

Swapping a skeleton loader for real content usually means the layout jumps or the skeleton just vanishes abruptly. This snippet crossfades between the two states smoothly by stacking both the skeleton and the real content in the same space and animating opacity, driven by a single CSS class toggle.
Two layers, one position
Both .stc-skeleton and .stc-content sit inside .stc-stack with position: absolute; inset: 0, occupying the exact same box. Because they overlap rather than sitting in normal document flow, toggling between them never causes a layout shift — the container's height is set by whichever layer is currently visible, and both layers are always present in the DOM, just at different opacity.
One class controls both transitions
Adding .stc-loaded to the parent .stc-stack is the only state change JavaScript needs to make: the CSS rules .stc-stack.stc-loaded .stc-skeleton { opacity: 0 } and .stc-stack.stc-loaded .stc-content { opacity: 1 } handle both fades from a single toggle, guaranteeing they're always in sync — there's no risk of the skeleton fading out on a different schedule than the content fades in.
pointer-events prevents a dead-zone trap
Both layers also flip pointer-events alongside opacity — the hidden layer gets pointer-events: none so an invisible skeleton (or invisible content, before load) can never intercept clicks meant for the layer that's actually visible. Without this, a user could click exactly where a real button appears to be and hit an invisible skeleton element instead.
The shimmer animation is independent of the crossfade
@keyframes stcShimmer animates a moving gradient across each skeleton block on a continuous loop, entirely separate from the opacity transition — the shimmer keeps running for as long as the skeleton is visible, and simply stops mattering once .stc-loaded fades it out, no coordination between the two animations required.
Accessible loading state
aria-busy="true" on the stack while loading, flipped to "false" once content is ready, tells assistive technology the region is in a busy/loading state without needing to read the skeleton's decorative shimmer bars as content.
Customizing it
Replace the setTimeout in load() with a real fetch() call, populating the content layer's actual data once the response resolves, before adding the .stc-loaded class.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA shimmering skeleton shows for about 1.4 seconds, then crossfades into the real content.
- 2Click "Reload data"The content fades back to the skeleton state, then crossfades to content again after the simulated delay.
- 3Inspect the two layersBoth .stc-skeleton and .stc-content exist in the DOM simultaneously, stacked with position: absolute.
- 4Replace the simulated delayIn the JS, swap the setTimeout in load() for a real fetch() call to your API.
- 5Populate real dataBefore adding the .stc-loaded class, update the content layer's text and images from your fetch response.
- 6Adjust the crossfade speedEdit the transition: opacity duration on .stc-layer to make the fade faster or slower.
Real-world uses
Common Use Cases
aria-busy telling assistive technology that the region is still loading.pointer-events being switched off on it.Got questions?
Frequently Asked Questions
Stacking with position: absolute means both layers occupy the exact same space at all times, so the container's size doesn't jump when swapping between them, and the opacity transition can crossfade smoothly. Removing and inserting DOM nodes instead would require the container to resize abruptly and couldn't animate the exit and entrance together.
The CSS defines both transitions relative to the same parent selector: .stc-stack.stc-loaded .stc-skeleton and .stc-stack.stc-loaded .stc-content. Since both rules key off the identical .stc-loaded class on the identical parent element, JavaScript only ever needs to add or remove that one class, and the browser's CSS engine keeps both opacity transitions perfectly synchronized.
An element with opacity: 0 is still in the document and still receives clicks by default. Without pointer-events: none on the hidden layer, a user clicking where a visible button appears to be could actually be clicking through to an invisible skeleton element sitting on top of or behind it, causing confusing dead clicks.
No — the shimmer keyframe animation keeps running on the skeleton layer indefinitely, but once .stc-loaded sets that layer's opacity to 0, it's simply invisible. There's no performance concern with leaving it running, though you could pause it via animation-play-state if you wanted to be strict about not animating hidden elements.
Replace the setTimeout body in load() with a fetch() call. Once the response resolves, populate the .stc-content layer's actual DOM elements with the real data (name, image, text) first, then add the .stc-loaded class — so the crossfade reveals real data, not stale placeholder content.