Skeleton to Content
Seven shimmer bones (1.8 s) inside a 280 px card — 48 px avatar, 2 header lines, 3 text lines, 100 px image block; one click adds .loaded: bones fade out in 500 ms (opacity + scale(.98)), content slides in 8 px with a 150 ms delay.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Loaders has 29 effects, including 4 free. Explore the category →
3 usage examples



How it works
The .toggle-skel-card card stacks two layers in the same block: .toggle-skel-bones (the skeleton) and .toggle-skel-content (the real content). The skeleton holds 7 bones — a 48 px circle for the avatar, two header lines (60% and 40% wide), three text lines (100%, 90%, 65%) and a 100 px-tall image rectangle. Each bone uses background: rgba(255,255,255,.08); a ::after pseudo-element animates shimmerSlide — a linear-gradient at 10% opacity sweeping from −200% to 200% over 1.8 s ease-in-out, looping indefinitely. The real content layer shows an indigo-violet gradient avatar (#6366f1 → #a855f7) with the letter “A”, the name “Alice Durand”, the handle @alice_dev, a paragraph and a dark-blue image block.
The switch relies on the .loaded class being toggled on #toggleSkelCard. On the skeleton side: transition: opacity .5s, transform .5s — when .loaded is present, opacity: 0 and transform: scale(.98) fade the bones out over 500 ms while they also become position: absolute; inset: 20px to free the space. On the content side: display: none at base, display: block as soon as .loaded is set, then transition: opacity .5s .15s, transform .5s .15s — the content enters from 0 to 1 in opacity and from translateY(8px) to 0, both delayed by 150 ms. The button label toggles: “Load content” → “Show skeleton”.
Why the click is wired in JS rather than through an onclick attribute. The delivered script is an IIFE — (function(){ … })() — and everything declared inside it is invisible from the global scope. An onclick="myFunction()" attribute, however, is evaluated in that global scope: a function enclosed in the IIFE does not exist there, and the click would throw a ReferenceError without triggering anything. The delivered code removes the problem at its source: it fetches #toggleSkelCard and #toggleSkelBtn with getElementById, returns immediately if either is missing (if (!card || !btn) return;), then attaches btn.addEventListener('click', …) from inside the IIFE — the listener sees card and btn through its closure, never through window. On each click it runs card.classList.toggle('loaded') and swaps the button label to match the state. The HTML therefore carries no onclick and no global function is created: nothing can collide with another effect pasted on the same page. Consequence for integrators: there is no window.toggleSkeleton() — to trigger the switch from your own code, act on the .loaded class of #toggleSkelCard or click the button programmatically (btn.click()).
Accessibility
- prefers-reduced-motion not handled in the CSS or JS: the shimmer (1.8 s loop) and the toggle transitions (500 ms) run regardless of system preferences. Measured under
reducedMotion: reduceemulation:animationPlayState: runningon the bones. Add@media (prefers-reduced-motion: reduce) { .skel-bone::after, .skel-bone-circle::after { animation: none } .toggle-skel-bones, .toggle-skel-content { transition: none } }. - No ARIA attributes on the component: no
role="status"oraria-liveon the card, noaria-busyduring the skeleton state. A screen reader has no indication that the card is loading. Integrators should addaria-busy="true"to.toggle-skel-cardin skeleton mode, flip it to"false"on state change, and place anaria-live="polite"region outside the card to announce when content is ready. - The button is a real
<button>: focusable and keyboard-activatable, with a visible text label that the delivered listener updates on every switch (“Load content” / “Show skeleton”). To make the state explicit for assistive technologies, addaria-pressedsynchronized with.loadedin that same listener — and keep the label fixed if you do: a “Show skeleton” button announced as “pressed” would be contradictory. - Real content is dark-background-only: the content layer uses
rgba(255,255,255,…)colors — they are unreadable on a light background. In production, adapt the content colors if your interface supports a light theme.
Browser compatibility
Uses transition, @keyframes, linear-gradient, border-radius, rgba(), getElementById(), addEventListener() and classList.toggle(). No external dependencies, no global variable.
Without JavaScript, .loaded is never added: the skeleton stays visible indefinitely with the shimmer looping. If #toggleSkelCard or #toggleSkelBtn is missing from the DOM, the script exits before attaching the listener, with no console error — same outcome, a permanent skeleton. Without @keyframes (IE 9 unprefixed), the bones display as flat shapes with no shimmer. CSS transitions are ignored under IE 9, so the state change would be instant.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="toggle-skel-wrapper">
<div class="toggle-skel-card" id="toggleSkelCard">
<div class="toggle-skel-bones">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<div class="skel-bone-circle" style="width:48px;height:48px;flex-shrink:0"></div>
<div style="flex:1">
<div class="skel-bone" style="height:14px;width:60%;margin-bottom:8px"></div>
<div class="skel-bone" style="height:10px;width:40%"></div>
</div>
</div>
<!-- 3 text lines -->
<div class="skel-bone" style="height:10px;width:100%;margin-bottom:8px"></div>
<div class="skel-bone" style="height:10px;width:90%;margin-bottom:8px"></div>
<div class="skel-bone" style="height:10px;width:65%;margin-bottom:16px"></div>
<!-- image block -->
<div class="skel-bone" style="height:100px;width:100%;border-radius:10px"></div>
</div>
<div class="toggle-skel-content">
<!-- real content — replace with your data -->
</div>
</div>
<button class="toggle-skel-btn" id="toggleSkelBtn">Load content</button>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Shimmer duration (CSS — animation: 1.8s ease-in-out 0s infinite on ::after) |
1.8 s | Speed of the light sweep across each bone. 1s for a snappier feel; 2.5s for a softer one. All bones share the same duration and stay in sync. |
Skeleton fade-out duration (CSS — transition: opacity .5s, transform .5s on .toggle-skel-bones) |
.5 s | Time the bones take to disappear when .loaded is added. .3s for a faster switch; .8s for a long cross-fade during a slow API response. |
Content entry delay (CSS — delay in transition: opacity .5s .15s, transform .5s .15s) |
.15 s | How long the content waits before starting to enter. 0s = content and skeleton move simultaneously; .3s = bones nearly gone before content appears. |
Slide distance (CSS — transform: translateY(8px) on .toggle-skel-content) |
8 px | How far the content travels upward on entry. 4px for a barely noticeable motion; 20px for a more assertive entrance. Switch to translateX(8px) for a lateral entry. |
Component width (CSS — .toggle-skel-wrapper { width: 280px }) |
280 px | All percentage-based bones are relative to this width. Set to 100% to adapt to the parent container without touching the internal proportions. |
Image block height (inline HTML — height:100px on the last bone and on .toggle-skel-content-img) |
100 px | Change both values together so the image bone and its real counterpart share the same height, keeping the layout stable across the switch. |
Bone color (CSS — background: rgba(255,255,255,.08)) |
white at 8% opacity | Calibrated for the dark #0a0a0f background. On a light background, switch to rgba(0,0,0,.08); on a tinted background, match the dominant hue at 10–15%. |
FAQ
onclick="toggleSkeleton()" attribute resolves its name in the global scope; a function declared inside (function(){ … })() is not there, and the click would throw ReferenceError: toggleSkeleton is not defined without changing the card at all. The delivered code therefore attaches the listener from inside the IIFE, with btn.addEventListener('click', …), where card and btn are reachable through the closure — this is the catalog convention: vanilla, IIFE, no globals, so no risk of collision between two effects pasted on the same page. You can still expose a function (window.toggleSkeleton = function(){ card.classList.toggle('loaded') } inside the IIFE) if your integration requires an onclick, but you give up that isolation; prefer the listener as delivered, or btn.click() from your own code..loaded class on #toggleSkelCard. Simplest route: remove the <button> from the HTML (the delivered script then exits without doing anything — you can even leave it out) and set the class in your callback: fetch('/api/profile').then(r => r.json()).then(data => { /* inject data into .toggle-skel-content */ document.getElementById('toggleSkelCard').classList.add('loaded'); }). Prefer add over toggle: a second call will not flip back to the skeleton. Alternative, if you keep the button hidden: document.getElementById('toggleSkelBtn').click() goes through the delivered listener, which also handles the label. Either way, the script runs on load and looks up its elements immediately — place it after the card's HTML, or inside DOMContentLoaded.