AnimationsSimpleApril 9, 2026
Rotating Text
A heading component that cycles through a list of words in-place using a masked slide-up/down transition. Width animates smoothly between words. Supports optional before/after static text to build full sentences.
View Full Demo →Preview
Source
demo.jsx
import RotatingText from "./index.jsx";
import styles from "./demo.module.css";
export default function RotatingTextDemo() {
return (
<div className={styles.page}>
{/* Hero — full sentence with rotating word */}
<section className={styles.hero}>
<p className={styles.label}>[ Rotating Text ]</p>
<RotatingText
as="h1"
before="We build"
words={["websites", "experiences", "brands", "products"]}
after="that scale."
stepDuration={1.75}
className={styles.heroHeading}
/>
</section>
{/* Standalone — rotating word only */}
<section className={styles.section}>
<p className={styles.label}>[ Standalone ]</p>
<RotatingText
as="h2"
words={["Creative", "Bold", "Minimal", "Modern"]}
stepDuration={2}
className={styles.subHeading}
/>
<p className={styles.body}>
Drop it into any heading. Pass <code>before</code> and <code>after</code> to build a
full sentence, or use <code>words</code> alone as a cycling label.
</p>
</section>
</div>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import styles from "./styles.module.css";
gsap.registerPlugin(SplitText);
export default function RotatingText({
as: Tag = "h2",
before = "",
words = [],
after = "",
stepDuration = 1.75,
className = "",
}) {
const headingRef = useRef(null);
// The longest word must be the initial visible text for correct SplitText measurement
const longestWord = words.reduce(
(a, b) => (b.length > a.length ? b : a),
words[0] || ""
);
useEffect(() => {
const heading = headingRef.current;
if (!heading || !words.length) return;
let splitInstance = null;
let delayedCallRef = null;
splitInstance = SplitText.create(heading, {
type: "lines",
mask: "lines",
autoSplit: true,
linesClass: "rotating-line",
onSplit(instance) {
const rotatingSpan = heading.querySelector("[data-rotating-words]");
if (!rotatingSpan) return;
// Build stacked word elements
const wrapper = document.createElement("span");
wrapper.className = styles.inner;
const wordEls = words.map((word) => {
const el = document.createElement("span");
el.className = styles.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 });
const firstWidth = firstWord.getBoundingClientRect().width;
wrapper.style.width = firstWidth + "px";
function showNext() {
const nextIndex = (activeIndex + 1) % wordEls.length;
const prev = wordEls[activeIndex];
const current = wordEls[nextIndex];
const targetWidth = current.getBoundingClientRect().width;
gsap.to(wrapper, {
width: targetWidth,
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;
delayedCallRef = gsap.delayedCall(stepDuration, showNext);
}
if (wordEls.length > 1) {
delayedCallRef = gsap.delayedCall(stepDuration, showNext);
}
});
},
});
return () => {
delayedCallRef?.kill();
splitInstance?.revert();
};
}, [words, stepDuration]);
return (
<Tag
ref={headingRef}
className={`${styles.heading} ${className}`}
data-rotating-title=""
data-step-duration={stepDuration}
>
{before && <span className={styles.static}>{before} </span>}
<span
data-rotating-words={words.join(", ")}
className={styles.rotatingSpan}
>
{longestWord}
</span>
{after && <span className={styles.static}> {after}</span>}
</Tag>
);
}
demo.module.css
.page {
background: #0d0d0d;
color: #efeeec;
}
.hero {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 3rem 4vw 4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.heroHeading {
font-size: clamp(3rem, 9vw, 8.5rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1;
margin: 0;
}
.section {
padding: 12vh 4vw;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.label {
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.3);
margin: 0 0 2rem;
}
.subHeading {
font-size: clamp(2.5rem, 6vw, 6rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1;
margin: 0 0 3rem;
}
.body {
font-size: 1rem;
color: rgba(255, 255, 255, 0.45);
line-height: 1.6;
max-width: 40em;
margin: 0;
}
.body code {
color: rgba(255, 255, 255, 0.7);
font-family: monospace;
}
styles.module.css
.heading {
display: block;
}
.static {
display: inline;
}
.rotatingSpan {
display: inline-block;
position: relative;
}
.inner {
display: inline-block;
}
.word {
display: block;
white-space: nowrap;
position: absolute;
top: 0;
left: 0;
}
/* SplitText-injected line classes — must be global */
:global(.rotating-line) {
padding-bottom: 0.1em;
margin-bottom: -0.1em;
white-space: nowrap;
}
:global(.rotating-line-mask) {
overflow-x: visible !important;
overflow-y: clip !important;
}
Dependencies
gsapgsap/SplitText