AnimationsSimpleApril 9, 2026

Looping Words

A word-cycling component that scrolls through a list vertically using elastic easing. An underline selector animates its width to match each incoming word. Works inline inside headings or as a standalone element.

View Full Demo →
    demo.jsx
    import LoopingWords from "./index.jsx";
    import styles from "./demo.module.css";
    
    export default function LoopingWordsDemo() {
      return (
        <div className={styles.page}>
    
          {/* Hero — centered standalone */}
          <section className={styles.hero}>
            <LoopingWords
              words={["GSAP", "LOOPING", "WORDS", "ANIMATE", "CYCLE"]}
              delay={0.5}
              stepDuration={1.75}
              moveDuration={1.2}
              selectorDuration={0.5}
              className={styles.words}
            />
          </section>
    
          {/* Inline inside a heading */}
          <section className={styles.section}>
            <p className={styles.label}>[ Inline in heading ]</p>
            <h2 className={styles.heading}>
              We create{" "}
              <LoopingWords
                words={["brands", "products", "websites", "experiences"]}
                delay={1}
                stepDuration={2}
                moveDuration={1.2}
                selectorDuration={0.5}
              />
            </h2>
          </section>
    
        </div>
      );
    }
    
    index.jsx
    "use client";
    
    import { useEffect, useRef } from "react";
    import { gsap } from "gsap";
    import styles from "./styles.module.css";
    
    export default function LoopingWords({
      words = [],
      delay = 1,
      stepDuration = 2,
      moveDuration = 1.2,
      selectorDuration = 0.5,
      className = "",
    }) {
      const listRef = useRef(null);
      const selectorRef = useRef(null);
      const tlRef = useRef(null);
    
      useEffect(() => {
        const wordList = listRef.current;
        const edgeElement = selectorRef.current;
        if (!wordList || !edgeElement || !words.length) return;
    
        const totalWords = wordList.children.length;
        const wordHeight = 100 / totalWords;
        let currentIndex = 0;
    
        // Mutable mirror of DOM order for recycling
        const wordEls = Array.from(wordList.children);
    
        function updateEdgeWidth() {
          const centerIndex = (currentIndex + 1) % totalWords;
          const centerWord = wordEls[centerIndex];
          const centerWordWidth = centerWord.getBoundingClientRect().width;
          const listWidth = wordList.getBoundingClientRect().width;
          const percentageWidth = (centerWordWidth / listWidth) * 100;
    
          gsap.to(edgeElement, {
            width: `${percentageWidth}%`,
            duration: selectorDuration,
            ease: "expo.out",
          });
        }
    
        function moveWords() {
          currentIndex++;
    
          gsap.to(wordList, {
            yPercent: -wordHeight * currentIndex,
            duration: moveDuration,
            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 });
                wordEls.push(wordEls.shift());
              }
            },
          });
        }
    
        updateEdgeWidth();
    
        tlRef.current = gsap
          .timeline({ repeat: -1, delay })
          .call(moveWords)
          .to({}, { duration: stepDuration })
          .repeat(-1);
    
        return () => {
          tlRef.current?.kill();
        };
      }, [words, delay, stepDuration, moveDuration, selectorDuration]);
    
      return (
        <div
          className={`${styles.wrapper} ${className}`}
          style={{ "--word-count": words.length }}
        >
          <div className={styles.clip}>
            <ul
              ref={listRef}
              className={styles.list}
              data-looping-words-list=""
            >
              {words.map((word, i) => (
                <li key={i} className={styles.item}>
                  {word}
                </li>
              ))}
            </ul>
          </div>
          <div
            ref={selectorRef}
            className={styles.selector}
            data-looping-words-selector=""
          />
        </div>
      );
    }
    
    demo.module.css
    .page {
      background: #ede9f6;
      color: #1a1830;
    }
    
    /* Hero — full-viewport, component centered */
    .hero {
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    
    .words {
      font-size: clamp(4rem, 12vw, 10rem);
      font-weight: 900;
      letter-spacing: -0.02em;
      text-transform: uppercase;
    }
    
    /* Inline section */
    .section {
      padding: 12vh 4vw;
      border-top: 1px solid rgba(26, 24, 48, 0.1);
    }
    
    .label {
      font-size: 0.75rem;
      letter-spacing: 0.12em;
      text-transform: uppercase;
      color: rgba(26, 24, 48, 0.35);
      margin: 0 0 2rem;
    }
    
    .heading {
      font-size: clamp(2.5rem, 6vw, 6rem);
      font-weight: 500;
      letter-spacing: -0.03em;
      line-height: 1.2;
      margin: 0;
    }
    
    styles.module.css
    .wrapper {
      --color-black: #111;
      --space-2xs: 0.25rem;
      --selector-color: #3333cc;
    
      display: inline-flex;
      flex-direction: column;
      align-items: flex-start;
      position: relative;
    }
    
    /* Clips to 3-word window with fade top/bottom */
    .clip {
      overflow: hidden;
      height: calc(3em * 1.2);
      -webkit-mask-image: linear-gradient(
        to bottom,
        transparent 0%,
        black 28%,
        black 72%,
        transparent 100%
      );
      mask-image: linear-gradient(
        to bottom,
        transparent 0%,
        black 28%,
        black 72%,
        transparent 100%
      );
    }
    
    .list {
      list-style: none;
      margin: 0;
      padding: 0;
      display: flex;
      flex-direction: column;
    }
    
    .item {
      display: block;
      white-space: nowrap;
      line-height: 1.2;
    }
    
    /* Corner bracket selector — overlays the center (active) word slot */
    .selector {
      position: absolute;
      top: calc(1em * 1.2 - 4px);   /* compensate for vertical padding */
      left: -10px;                   /* compensate for horizontal padding */
      height: calc(1em * 1.2);
      box-sizing: content-box;
      padding: 4px 10px;             /* breathing room between text and corners */
      width: 0;
      background:
        /* top-left */
        linear-gradient(to right,  var(--selector-color) 2px, transparent 2px) 0    0,
        linear-gradient(to bottom, var(--selector-color) 2px, transparent 2px) 0    0,
        /* top-right */
        linear-gradient(to left,   var(--selector-color) 2px, transparent 2px) 100% 0,
        linear-gradient(to bottom, var(--selector-color) 2px, transparent 2px) 100% 0,
        /* bottom-left */
        linear-gradient(to right,  var(--selector-color) 2px, transparent 2px) 0    100%,
        linear-gradient(to top,    var(--selector-color) 2px, transparent 2px) 0    100%,
        /* bottom-right */
        linear-gradient(to left,   var(--selector-color) 2px, transparent 2px) 100% 100%,
        linear-gradient(to top,    var(--selector-color) 2px, transparent 2px) 100% 100%;
      background-repeat: no-repeat;
      background-size: 12px 12px;
    }
    
    • gsap

    Apr 27, 2026

    ANIMATIONS

    Cursor Hover Label

    A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

    Apr 27, 2026

    ANIMATIONS

    Section Transition 01

    GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

    Apr 27, 2026

    ANIMATIONS

    Text Reveal 01

    GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.