Looping Words with Selector

A vertically looping word list where the centred word is highlighted by a corner-bracket selector that smoothly resizes to match each word's width. Words cycle on a timer with an elastic ease, and the list DOM is recycled so the loop is infinite.

GSAPLoopTypographyAnimation

Setup — External Scripts

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

Code

index.html
html
<div class="looping-words">
  <div class="looping-words__containers">
    <ul data-looping-words-list="" class="looping-words__list">
      <li class="looping-words__list">
        <p class="looping-words__p">GSAP</p>
      </li>
      <li class="looping-words__list">
        <p class="looping-words__p">Looping</p>
      </li>
      <li class="looping-words__list">
        <p class="looping-words__p">Words</p>
      </li>
      <li class="looping-words__list">
        <p class="looping-words__p">Selector</p>
      </li>
      <li class="looping-words__list">
        <p class="looping-words__p">Made with</p>
      </li>
    </ul>
  </div>
  <div class="looping-words__fade"></div>
  <div data-looping-words-selector="" class="looping-words__selector">
    <div class="looping-words__edge"></div>
    <div class="looping-words__edge is--2"></div>
    <div class="looping-words__edge is--3"></div>
    <div class="looping-words__edge is--4"></div>
  </div>
</div>
styles.css
css
.looping-words {
  height: 2.7em;
  padding-left: .1em;
  padding-right: .1em;
  font-family: PP Neue Corp Tight, Arial, sans-serif;
  font-size: 10em;
  font-weight: 700;
  line-height: .9;
  position: relative;
}

.looping-words__list {
  text-align: center;
  text-transform: uppercase;
  white-space: nowrap;
  flex-flow: column;
  align-items: center;
  margin-top: 0;
  margin-bottom: 0;
  padding-left: 0;
  list-style: none;
  display: flex;
  position: relative;
}

.looping-words__fade {
  pointer-events: none;
  background-image: linear-gradient(rgb(230, 226, 247) 5%, rgba(230, 226, 247, 0) 40%, rgba(230, 226, 247, 0) 60%, rgb(230, 226, 247) 95%);
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.looping-words__edge {
  border-top: .035em solid #2600ff;
  border-left: .035em solid #2600ff;
  width: .125em;
  height: .125em;
  position: absolute;
  top: 0;
  left: 0;
}

.looping-words__edge.is--2 {
  left: auto;
  right: 0;
  transform: rotate(90deg);
}

.looping-words__edge.is--3 {
  inset: auto 0 0 auto;
  transform: rotate(180deg);
}

.looping-words__edge.is--4 {
  top: auto;
  bottom: 0;
  transform: rotate(270deg);
}

.looping-words__selector {
  pointer-events: none;
  width: 100%;
  height: .9em;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

.looping-words__containers {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
}

.looping-words__p {
  margin: 0;
}
script.js
javascript
function initLoopingWordsWithSelector() {
  const wordList   = document.querySelector('[data-looping-words-list]');
  const words      = Array.from(wordList.children);
  const totalWords = words.length;
  const wordHeight = 100 / totalWords;
  const edgeElement = document.querySelector('[data-looping-words-selector]');
  let currentIndex = 0;

  function updateEdgeWidth() {
    const centerIndex  = (currentIndex + 1) % totalWords;
    const centerWord   = words[centerIndex];
    const centerWordWidth = centerWord.getBoundingClientRect().width;
    const listWidth    = wordList.getBoundingClientRect().width;
    const percentageWidth = (centerWordWidth / listWidth) * 100;

    gsap.to(edgeElement, {
      width: `${percentageWidth}%`,
      duration: 0.5,
      ease: 'Expo.easeOut',
    });
  }

  function moveWords() {
    currentIndex++;

    gsap.to(wordList, {
      yPercent: -wordHeight * currentIndex,
      duration: 1.2,
      ease: 'elastic.out(1, 0.85)',
      onStart: updateEdgeWidth,
      onComplete() {
        if (currentIndex >= totalWords - 3) {
          wordList.appendChild(wordList.children[0]);
          currentIndex--;
          gsap.set(wordList, { yPercent: -wordHeight * currentIndex });
          words.push(words.shift());
        }
      },
    });
  }

  updateEdgeWidth();

  gsap.timeline({ repeat: -1, delay: 1 })
    .call(moveWords)
    .to({}, { duration: 2 })
    .repeat(-1);
}

document.addEventListener('DOMContentLoaded', () => {
  initLoopingWordsWithSelector();
});

Notes

  • The list recycles itself by moving the first `<li>` to the end once `currentIndex` approaches the end, then correcting `yPercent` with `gsap.set` — creating a seamless infinite loop without duplicating DOM nodes.
  • The selector width is measured from the actual rendered `getBoundingClientRect()` width of the centred word relative to the list width, so it adapts automatically to variable-length words.
  • Increase `duration: 1.2` on the `wordList` tween for a slower word change; decrease `duration: 0.5` on the `edgeElement` tween for a snappier selector resize.
  • The `.looping-words__fade` gradient overlay softens words entering and leaving at the top and bottom, reinforcing the illusion that the list continues infinitely.