AnimationsIntermediateApril 27, 2026

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.

View Full Demo →

We create motion-forward digital experiences

No-5 Studio is a motion-forward design studio. We build websites that communicate confidence, craft, and intention through every interaction.

demo.jsx
import TextReveal01 from "./index.jsx";
import styles from "./demo.module.css";

const ITEMS_LOAD = [
  {
    as: "h1",
    text: "We create motion-forward digital experiences",
    splitType: "lines",
    className: styles.heading,
  },
  {
    as: "p",
    text: "No-5 Studio is a motion-forward design studio. We build websites that communicate confidence, craft, and intention through every interaction.",
    splitType: "words",
    delay: 0.3,
  },
];

const ITEMS_SCROLL = [
  {
    as: "h2",
    text: "Scroll-triggered line reveal",
    splitType: "lines",
    scroll: true,
    className: styles.heading,
  },
  {
    as: "p",
    text: "This paragraph reveals word by word as it enters the viewport. The animation plays once by default.",
    splitType: "words",
    scroll: true,
  },
];

const ITEMS_CHARS = [
  {
    as: "h2",
    text: "Character reveal on scroll",
    splitType: "chars",
    scroll: true,
    className: styles.heading,
  },
  {
    as: "p",
    text: "Each letter animates individually with a tight stagger for a typewriter-like entrance.",
    splitType: "chars",
    scroll: true,
  },
];

const ITEMS_SCRUB = [
  {
    as: "h2",
    text: "Scrubbed scroll reveal",
    splitType: "words",
    scroll: "scrub",
    className: styles.heading,
  },
  {
    as: "p",
    text: "This text follows the scroll position — scrub forward to reveal, scrub back to hide.",
    splitType: "words",
    scroll: "scrub",
  },
];

export default function TextReveal01Demo() {
  return (
    <div>
      {/* Hero — load-time reveal */}
      <section className={styles.section} data-bg="dark">
        <p className={styles.label}>Load reveal</p>
        <TextReveal01 items={ITEMS_LOAD} />
      </section>

      {/* Scroll-triggered reveal */}
      <section className={styles.section}>
        <p className={styles.label}>Scroll trigger</p>
        <TextReveal01 items={ITEMS_SCROLL} />
      </section>

      {/* Character reveal */}
      <section className={styles.section} data-bg="dark">
        <p className={styles.label}>Characters</p>
        <TextReveal01 items={ITEMS_CHARS} />
      </section>

      {/* Scrubbed reveal */}
      <section className={styles.section}>
        <p className={styles.label}>Scrub</p>
        <TextReveal01 items={ITEMS_SCRUB} />
      </section>

      <div className={styles.spacer} />
    </div>
  );
}
index.jsx
"use client";

import { useEffect, useRef } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import styles from "./styles.module.css";

gsap.registerPlugin(SplitText, ScrollTrigger);

/* ── GSAP text reveal engine ───────────────────────────────── */

function textReveal01(scope = document, delay = 0, { ignoreManual = false } = {}) {
  const CONFIG = {
    lines: { duration: 1, stagger: 0.06, ease: "expo.out" },
    words: { duration: 1, stagger: 0.03, ease: "expo.out" },
    chars: { duration: 0.6, stagger: 0.01, ease: "expo.out" },
    scrollStart: "top 72%",
    scrubStart: "top 80%",
    scrubEnd: "top 20%",
    once: true,
    markers: false,
  };

  const allSplitEls = scope.querySelectorAll("[data-reveal-01]");
  const autoEls = ignoreManual
    ? [...allSplitEls]
    : [...allSplitEls].filter((el) => !el.hasAttribute("data-manual"));

  gsap.set(autoEls, { visibility: "visible" });

  allSplitEls.forEach((el) => {
    const splitType = el.getAttribute("data-reveal-01");
    const c = CONFIG[splitType];
    if (!c) return;

    let type;
    let mask;
    let linesClass;
    let wordsClass;
    let charsClass;

    switch (splitType) {
      case "lines":
        type = "lines";
        mask = "lines";
        linesClass = "line";
        break;
      case "words":
        type = "words, lines";
        mask = "words";
        wordsClass = "word";
        linesClass = "line";
        break;
      case "chars":
        type = "chars, words, lines";
        mask = "chars";
        charsClass = "char";
        wordsClass = "word";
        linesClass = "line";
        break;
      default:
        return;
    }

    if (!ignoreManual && el.hasAttribute("data-manual")) {
      SplitText.create(el, {
        type,
        mask,
        autoSplit: true,
        ...(linesClass && { linesClass }),
        ...(wordsClass && { wordsClass }),
        ...(charsClass && { charsClass }),
      });
      return;
    }

    const scrollMode = el.getAttribute("data-scroll");
    const useScroll = el.hasAttribute("data-scroll");
    const useScrub = scrollMode === "scrub";

    SplitText.create(el, {
      type,
      mask,
      autoSplit: true,
      ...(linesClass && { linesClass }),
      ...(wordsClass && { wordsClass }),
      ...(charsClass && { charsClass }),
      onSplit(instance) {
        const durationValue = parseFloat(el.dataset.duration);
        const staggerValue = parseFloat(el.dataset.stagger);
        const delayValue = parseFloat(el.dataset.delay);
        const duration = Number.isNaN(durationValue) ? c.duration : durationValue;
        const stagger = Number.isNaN(staggerValue) ? c.stagger : staggerValue;
        const elDelay = Number.isNaN(delayValue) ? 0 : delayValue;
        const ease = el.dataset.ease || c.ease;

        const targets = instance[splitType];
        const once = el.hasAttribute("data-once")
          ? el.getAttribute("data-once") !== "false"
          : CONFIG.once;

        const tween = {
          yPercent: 110,
          duration,
          stagger,
          delay: useScroll ? elDelay : elDelay + delay,
          immediateRender: true,
          ease,
        };

        if (useScrub) {
          tween.scrollTrigger = {
            trigger: el,
            start: CONFIG.scrubStart,
            end: CONFIG.scrubEnd,
            scrub: true,
            markers: CONFIG.markers,
            ...(once && { onLeave: (self) => self.kill(false) }),
          };
        } else if (useScroll) {
          const start = scrollMode || CONFIG.scrollStart;
          tween.scrollTrigger = {
            trigger: el,
            start: `clamp(${start})`,
            markers: CONFIG.markers,
            ...(once ? { once: true } : { toggleActions: "play none none reverse" }),
          };
        }

        return gsap.from(targets, tween);
      },
    });
  });
}

/* ── React component ────────────────────────────────────────── */

export default function TextReveal01({ items = [] }) {
  const containerRef = useRef(null);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const ctx = gsap.context(() => {
      textReveal01(el);
    }, el);

    return () => ctx.revert();
  }, []);

  return (
    <div ref={containerRef} className={styles.container}>
      {items.map((item, i) => {
        const Tag = item.as || "p";
        const dataAttrs = {};
        dataAttrs["data-reveal-01"] = item.splitType || "lines";
        if (item.scroll != null) {
          dataAttrs["data-scroll"] = item.scroll === true ? "" : item.scroll;
        }
        if (item.duration != null) dataAttrs["data-duration"] = String(item.duration);
        if (item.stagger != null) dataAttrs["data-stagger"] = String(item.stagger);
        if (item.delay != null) dataAttrs["data-delay"] = String(item.delay);
        if (item.ease != null) dataAttrs["data-ease"] = item.ease;
        if (item.once != null) dataAttrs["data-once"] = String(item.once);
        if (item.manual) dataAttrs["data-manual"] = "";

        return (
          <Tag
            key={i}
            className={`${styles.text} ${item.className || ""}`}
            {...dataAttrs}
          >
            {item.text}
          </Tag>
        );
      })}
    </div>
  );
}
demo.module.css
.section {
  min-height: 100vh;
  background: #ffffff;
  color: #1a1a1a;
  padding: 6rem 3rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.section[data-bg="dark"] {
  background: #0d0d0d;
  color: #ffffff;
}

.label {
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.35;
  margin-bottom: 2rem;
}

.heading {
  font-size: clamp(2rem, 5vw, 3.5rem);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.1;
}

.spacer {
  height: 30vh;
  background: #ffffff;
}
styles.module.css
.container {
  display: flex;
  flex-direction: column;
  gap: 2rem;
  padding: 4rem 2rem;
  max-width: 720px;
  margin: 0 auto;
}

.text {
  font-size: var(--text-lg);
  line-height: var(--leading-relaxed);
  color: var(--color-foreground, #1a1a1a);
}

/* ── Required hidden-state + SplitText mask rules ──────────── */

:global([data-reveal-01]) {
  visibility: hidden;
}

:global([data-reveal-01] > *) {
  margin-bottom: -0.1em;
}

:global([data-reveal-01] .word-mask),
:global([data-reveal-01] .char-mask) {
  vertical-align: top;
}

:global([data-reveal-01] .line-mask > *),
:global([data-reveal-01] .word-mask > *),
:global([data-reveal-01] .char-mask > *) {
  padding-bottom: 0.1em;
  will-change: transform;
}
  • 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 9, 2026

ANIMATIONS

Cascading Slider

GSAP-driven carousel that positions slides across 7 slots (active, left/right siblings, far left/right, and two hidden staging slots). Slides clip-path into view as they move, with the active slide title fading in after the transition completes. Keyboard and click navigable.