AnimationsIntermediateApril 8, 2026

SplitText Line Reveal

Paragraph text is split into individual lines. Each line slides up from below its overflow-hidden mask container as the section enters the viewport. Uses a manual canvas-based line splitter (no paid plugin required).

View Full Demo →

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

Our process starts with animation. Every project begins with an animation language — a set of principles that define how your brand moves through the digital world.

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

const PARAGRAPHS = [
  "No-5 Studio is a motion-forward design studio. We build websites that communicate confidence, craft, and intention through every interaction.",
  "Our process starts with animation. Every project begins with an animation language — a set of principles that define how your brand moves through the digital world.",
];

export default function SplittextLineRevealDemo() {
  return (
    <div>
      {/* Hero — text component starts below the fold */}
      <section className={styles.hero}>
        <h1 className={styles.heroTitle}>
          <span>No-5</span>
          <span>Studio</span>
        </h1>
        <p className={styles.heroSub}>Motion-forward design</p>
      </section>

      {/* Component — lines reveal as you scroll into this section */}
      <section className={styles.content}>
        <p className={styles.contentLabel}>About</p>
        <SplittextLineReveal paragraphs={PARAGRAPHS} />
      </section>

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

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

gsap.registerPlugin(ScrollTrigger);

// Manual text split — splits text into lines by word-wrapping into a hidden container
function splitIntoLines(text, containerWidth, fontSize) {
  const words = text.split(" ");
  const lines = [];
  let currentLine = "";

  // Use canvas to measure text width
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  ctx.font = `${fontSize}px -apple-system, sans-serif`;

  for (const word of words) {
    const test = currentLine ? `${currentLine} ${word}` : word;
    if (ctx.measureText(test).width <= containerWidth) {
      currentLine = test;
    } else {
      if (currentLine) lines.push(currentLine);
      currentLine = word;
    }
  }
  if (currentLine) lines.push(currentLine);
  return lines;
}

export default function SplitTextLineReveal({ paragraphs = [] }) {
  const containerRef = useRef(null);

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

    const paras = el.querySelectorAll(`.${styles.para}`);

    paras.forEach((para) => {
      const text = para.textContent;
      const width = para.getBoundingClientRect().width;
      const fontSize = parseFloat(getComputedStyle(para).fontSize);
      const lines = splitIntoLines(text, width, fontSize);

      para.innerHTML = lines
        .map(
          (line) =>
            `<span class="${styles.lineOuter}"><span class="${styles.lineInner}">${line}</span></span>`
        )
        .join("");

      const lineEls = para.querySelectorAll(`.${styles.lineInner}`);

      gsap.from(lineEls, {
        yPercent: 105,
        duration: 1,
        stagger: 0.07,
        ease: "power3.out",
        scrollTrigger: {
          trigger: para,
          start: "top 80%",
        },
      });
    });

    return () => ScrollTrigger.getAll().forEach((t) => t.kill());
  }, []);

  return (
    <div ref={containerRef} className={styles.container}>
      {paragraphs.map((text, i) => (
        <p key={i} className={styles.para}>
          {text}
        </p>
      ))}
    </div>
  );
}
demo.module.css
.hero {
  height: 100vh;
  background: #0d0d0d;
  color: #ffffff;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 3rem;
}

.heroTitle {
  display: flex;
  flex-direction: column;
  font-size: clamp(4rem, 12vw, 10rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 0.9;
  margin-bottom: 1.5rem;
}

.heroSub {
  font-size: 0.8125rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.3;
}

.content {
  background: #ffffff;
  padding: 4rem 3rem;
}

.contentLabel {
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: #9b9b9b;
  margin-bottom: 2.5rem;
}

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

.para {
  font-size: 1.125rem;
  line-height: 1.65;
  color: #1a1a1a;
}

/* Each line is wrapped in these during JS split */
.lineOuter {
  display: block;
  overflow: hidden;
}

.lineInner {
  display: block;
}
  • 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.