Rotating Text

A heading with one rotating word slot that cycles through a comma-separated list. Each word slides out upward and the next slides in from below, with the wrapper width animating smoothly between word lengths.

GSAPSplitTextTypographyLoopAnimation

Setup — External Scripts

GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
SplitText CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>

Code

index.html
html
<h1 data-rotating-title class="rotating-text__heading">Simple
  <span data-rotating-words="routines, tools, systems, help" class="rotating-text__highlight">routines</span>
  that give growing and ambitious teams more clarity.
</h1>
styles.css
css
.rotating-text__heading {
  text-align: center;
  letter-spacing: -.02em;
  margin: 0;
  font-family: Haffer, Arial, sans-serif;
  font-size: clamp(2.5em, 7.5vw, 4.5em);
  font-weight: 500;
  line-height: 1;
}

.rotating-text__highlight {
  color: #33de96;
}

[data-rotating-words] {
  display: inline-block;
  position: relative;
}

.rotating-text__inner {
  display: inline-block;
}

.rotating-text__word {
  display: block;
  white-space: nowrap;
  position: absolute;
  top: 0;
  left: 0;
}

.rotating-line {
  padding-bottom: 0.1em;
  margin-bottom: -0.1em;
  white-space: nowrap;
}

.rotating-line-mask {
  overflow-x: visible !important;
  overflow-y: clip !important;
}
script.js
javascript
function initRotatingText() {
  document.querySelectorAll('[data-rotating-title]').forEach(heading => {
    const stepDuration = parseFloat(heading.getAttribute('data-step-duration') || '1.75');

    SplitText.create(heading, {
      type: 'lines',
      mask: 'lines',
      autoSplit: true,
      linesClass: 'rotating-line',
      onSplit(instance) {
        const rotatingSpan = heading.querySelector('[data-rotating-words]');
        if (!rotatingSpan) return;

        const rawWords = rotatingSpan.getAttribute('data-rotating-words') || '';
        const words = rawWords.split(',').map(w => w.trim()).filter(Boolean);
        if (!words.length) return;

        const wrapper = document.createElement('span');
        wrapper.className = 'rotating-text__inner';

        const wordEls = words.map(word => {
          const el = document.createElement('span');
          el.className = 'rotating-text__word';
          el.textContent = word;
          wrapper.appendChild(el);
          return el;
        });

        rotatingSpan.textContent = '';
        rotatingSpan.appendChild(wrapper);

        requestAnimationFrame(() => {
          const inDuration  = 0.75;
          const outDuration = 0.6;

          gsap.set(wordEls, { yPercent: 150, autoAlpha: 0 });

          let activeIndex = 0;
          const firstWord = wordEls[activeIndex];
          gsap.set(firstWord, { yPercent: 0, autoAlpha: 1 });

          wrapper.style.width = firstWord.getBoundingClientRect().width + 'px';

          function showNext() {
            const nextIndex = (activeIndex + 1) % wordEls.length;
            const prev      = wordEls[activeIndex];
            const current   = wordEls[nextIndex];

            gsap.to(wrapper, { width: current.getBoundingClientRect().width, duration: inDuration, ease: 'power4.inOut' });

            if (prev && prev !== current) {
              gsap.to(prev, { yPercent: -150, autoAlpha: 0, duration: outDuration, ease: 'power4.inOut' });
            }

            gsap.fromTo(current,
              { yPercent: 150, autoAlpha: 0 },
              { yPercent: 0, autoAlpha: 1, duration: inDuration, ease: 'power4.inOut' }
            );

            activeIndex = nextIndex;
            gsap.delayedCall(stepDuration, showNext);
          }

          if (wordEls.length > 1) gsap.delayedCall(stepDuration, showNext);
        });
      }
    });
  });
}

document.addEventListener("DOMContentLoaded", function () {
  initRotatingText();
});

Notes

  • All words from `data-rotating-words` are built as absolutely-positioned spans stacked inside a wrapper — only their `yPercent` and `autoAlpha` change during animation, never the DOM order.
  • The wrapper width animates to match each incoming word's measured pixel width, keeping surrounding text reflow-free.
  • The initial visible word in the HTML should be the longest word in the list so SplitText line-breaking is accurate before the dynamic words replace it.
  • Using `gsap.delayedCall` for the loop avoids `setInterval` drift and integrates cleanly with GSAP's global timeline control.

Guide

Rotating Words

Add `data-rotating-words` to a `<span>` inside the heading. The value is a comma-separated list of words to cycle through. Place the longest word as the visible DOM text so SplitText measures the correct line width before the stack is injected.

<h1 data-rotating-title>
  Hello
  <span data-rotating-words="word 1, word 2, word 3">word 1</span>
  World
</h1>

Step Duration

Use `data-step-duration` on the `[data-rotating-title]` element to set the pause between rotations in seconds (default 1.75).

<h1 data-rotating-title data-step-duration="2.5">
  Hello <span data-rotating-words="word 1, word 2, word 3">word 1</span> World
</h1>