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 →

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;
}
  • gsap
  • gsap/SplitText

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.