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;
}