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 →Preview
Source
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;
}
Dependencies
gsap