Skeleton Loader Text
Text lines are covered by animated skeleton placeholder blocks that pulse between two theme colors before fading out to reveal the actual content underneath — mimicking a content-loading skeleton UI pattern.
GSAPScrollTriggerSplitTextSkeletonLoader
Setup — External Scripts
GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>ScrollTrigger CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>SplitText CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>Code
index.html
html
<div class="demo-group">
<div class="ghost-section">
<h1 data-load-skeleton="dark" class="ghost-heading">This heading will reveal with an effect called a 'skeleton loader'.</h1>
</div>
<div class="ghost-section is--light">
<h1 data-load-skeleton="light" class="ghost-heading">→ Fully attribute based<br>→ Set different themes<br>→ Control skeleton styling</h1>
</div>
<div class="ghost-section">
<h1 data-load-skeleton="dark" class="ghost-heading is--small">The idea and concept of Skeleton Loading was introduced in 2013 by Luke Wroblewski.</h1>
</div>
</div>styles.css
css
.ghost-section {
grid-column-gap: 2em;
grid-row-gap: 2em;
flex-flow: column;
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
padding-left: 1em;
padding-right: 1em;
display: flex;
}
.ghost-section.is--light {
color: #121422;
background-color: #cdf7ff;
}
.ghost-heading {
letter-spacing: -.03em;
text-transform: uppercase;
max-width: 15em;
margin-top: 0;
margin-bottom: 0;
font-family: RM Mono, Arial, sans-serif;
font-size: 4em;
font-weight: 400;
line-height: 1;
}
.ghost-heading.is--small {
max-width: 25em;
font-size: 2.5em;
line-height: 1.1;
}
/* Define color themes here */
[data-load-skeleton="dark"] {
--color-skeleton-base: #2E3643;
--color-skeleton-pulse: #53636F;
}
[data-load-skeleton="light"] {
--color-skeleton-base: #B1D5DE;
--color-skeleton-pulse: #8CA8B2;
}
/* Hide actual text line so it's not visible under the placeholder */
[data-load-skeleton] .single-line {
visibility: hidden;
}
/* Skeleton overlay block */
.skeleton-overlay {
position: absolute;
top: 50%;
transform: translate(0px, -50%);
left: 0px;
width: 100%;
height: 80%;
border-radius: 0.25rem;
z-index: 1;
background-color: var(--color-skeleton-base);
}script.js
javascript
gsap.registerPlugin(ScrollTrigger, SplitText)
function cleanup() {
document.querySelectorAll('[data-load-skeleton]').forEach(target => {
if (target.splitInstance) {
target.splitInstance.revert();
delete target.splitInstance;
}
target.querySelectorAll('.skeleton-overlay').forEach(overlay => overlay.remove());
});
ScrollTrigger.getAll().forEach(trigger => {
if (trigger.vars?.trigger?.closest('[data-load-skeleton]')) trigger.kill();
});
}
function initSplit() {
document.querySelectorAll('[data-load-skeleton]').forEach(target => {
const splitInstance = new SplitText(target, { type: "lines", linesClass: "single-line" });
target.splitInstance = splitInstance;
target.setAttribute("aria-label", target.textContent);
splitInstance.lines.forEach(line => {
line.setAttribute("aria-hidden", "true");
const wrapper = document.createElement('div');
wrapper.classList.add('single-line-wrap');
line.parentNode.insertBefore(wrapper, line);
wrapper.appendChild(line);
});
});
}
function initSkeletonLoader() {
document.querySelectorAll('[data-load-skeleton]').forEach(instance => {
const overlays = [];
const lineWrappers = instance.querySelectorAll('.single-line-wrap');
lineWrappers.forEach(wrapper => {
const overlay = document.createElement('div');
overlay.classList.add('skeleton-overlay');
wrapper.style.position = 'relative';
wrapper.appendChild(overlay);
overlays.push(overlay);
});
overlays.forEach((overlay, i) => {
const pulseColor = gsap.getProperty(instance, "--color-skeleton-pulse");
const textEl = overlay.previousElementSibling;
gsap.timeline({
scrollTrigger: { trigger: overlay, start: "top 90%", once: true },
defaults: { duration: 0.5, ease: "power2.inOut" }
})
.to(overlay, {
backgroundColor: pulseColor,
duration: 0.3,
ease: "power1.inOut",
repeat: 2,
yoyo: true,
delay: i * 0.05,
})
.to(overlay, { opacity: 0, onComplete: () => overlay.remove() })
.to(textEl, { autoAlpha: 1 }, "<");
});
});
}
function initTextEffects() {
cleanup();
initSplit();
initSkeletonLoader();
}
function debounce(fn, ms) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
}
let prevWidth = window.innerWidth;
window.addEventListener('resize', debounce(() => {
if (window.innerWidth !== prevWidth) {
prevWidth = window.innerWidth;
initTextEffects();
}
}, 250));
document.addEventListener("DOMContentLoaded", () => {
document.fonts.ready.then(initTextEffects);
});Notes
- •`gsap.getProperty(instance, '--color-skeleton-pulse')` reads the CSS custom property directly so the pulse color is defined entirely in CSS — no duplication in JS.
- •The `cleanup()` function reverts SplitText instances, removes overlays, and kills ScrollTriggers before re-initialising — called on resize to keep everything in sync after text reflows.
- •The resize listener is debounced at 250 ms and only fires when the viewport width actually changes (not on height-only resizes like mobile address-bar hide/show).
- •Each skeleton overlay is staggered by `i * 0.05s` so lines in the same heading reveal in a cascading sequence rather than all at once.
- •Accessibility is handled manually: the full original text is preserved in `aria-label` on the parent, and each split line is marked `aria-hidden`.
Guide
Color themes
Define skeleton colors per theme with CSS custom properties on the attribute selector. Add as many themes as you need and reference them via the attribute value.
[data-load-skeleton="dark"] {
--color-skeleton-base: #2E3643;
--color-skeleton-pulse: #53636F;
}
[data-load-skeleton="brand"] {
--color-skeleton-base: #1a1a2e;
--color-skeleton-pulse: #e94560;
}