Skip to main content

Animation

Text invert

Scroll-driven headline color wipe

Client · GSAP

Live preview

Interact with the component below — same behavior as on the About page.

Loading preview…

Summary

A scroll-linked text reveal where each line of a headline transitions from muted to full foreground color. Lines are split at runtime, animated with GSAP ScrollTrigger scrub, and styled with background-clip text gradients that adapt to light and dark themes.

  • Line-by-line split with a lightweight SplitText helper
  • ScrollTrigger scrub ties wipe progress to scroll position
  • Core-only CLI — your markup, your layout
  • Light/dark gradient via CSS (.tp_text_invert)

Add to your project

Run the CLI to copy files into your project. Pick your language and styling below — the command updates automatically.

1. Add via CLI (recommended)

npx @shashank-portfolio/cli add text-invert

2. Install dependencies

npm install gsap @gsap/react

Required packages

  • gsap ^3.13.0
  • @gsap/react ^2.1.0 · useTextInvert hook

3. Manual installation

Create each file below in your project. Next.js paths are shown — for Vite + React, use the same names under src/ (e.g. src/lib/split-text.ts).

type SplitTextOptions = {
  type: "lines";
};

type SplitTextEntry = {
  element: HTMLElement;
  html: string;
};

type SplitTextTarget = string | Element | Element[];

/**
 * Lightweight line splitter for scroll text effects.
 * Groups words by offsetTop and respects explicit <br /> breaks.
 */
export class SplitText {
  lines: HTMLElement[] = [];
  private entries: SplitTextEntry[] = [];

  constructor(target: SplitTextTarget, options: SplitTextOptions) {
    if (options.type !== "lines") return;

    const elements = this.resolveElements(target);
    elements.forEach((element) => this.splitIntoLines(element));
  }

  revert(): void {
    this.entries.forEach(({ element, html }) => {
      element.innerHTML = html;
    });
    this.lines = [];
    this.entries = [];
  }

  private resolveElements(target: SplitTextTarget): HTMLElement[] {
    if (typeof target === "string") {
      return Array.from(document.querySelectorAll<HTMLElement>(target));
    }

    if (Array.isArray(target)) {
      return target.filter((el): el is HTMLElement => el instanceof HTMLElement);
    }

    return target instanceof HTMLElement ? [target] : [];
  }

  private splitIntoLines(element: HTMLElement): void {
    this.entries.push({ element, html: element.innerHTML });

    const rowTexts = this.extractRows(element);
    element.replaceChildren();

    rowTexts.forEach((rowText) => {
      const trimmed = rowText.trim();
      if (!trimmed) return;

      const wrappedLines = this.measureWrappedLines(element, trimmed);
      wrappedLines.forEach((lineText) => {
        const lineEl = document.createElement("div");
        lineEl.textContent = lineText;
        element.appendChild(lineEl);
        this.lines.push(lineEl);
      });
    });
  }

  /** Split copy at <br /> while preserving nested markup text. */
  private extractRows(element: HTMLElement): string[] {
    const rows: string[] = [];
    let buffer = "";

    const flush = (): void => {
      rows.push(buffer);
      buffer = "";
    };

    const walk = (node: Node): void => {
      if (node.nodeType === Node.TEXT_NODE) {
        buffer += node.textContent ?? "";
        return;
      }

      if (node.nodeType !== Node.ELEMENT_NODE) return;

      const el = node as HTMLElement;
      if (el.tagName === "BR") {
        flush();
        return;
      }

      el.childNodes.forEach(walk);
    };

    element.childNodes.forEach(walk);
    flush();

    return rows.length ? rows : [element.textContent ?? ""];
  }

  private measureWrappedLines(element: HTMLElement, text: string): string[] {
    const width = element.offsetWidth;
    if (width <= 0) {
      return [text];
    }

    const styles = window.getComputedStyle(element);
    const measureHost = document.createElement("div");
    measureHost.setAttribute("aria-hidden", "true");
    measureHost.style.cssText = [
      "position:absolute",
      "visibility:hidden",
      "pointer-events:none",
      "top:0",
      "left:0",
      `width:${width}px`,
      `font:${styles.font}`,
      `letter-spacing:${styles.letterSpacing}`,
      `word-spacing:${styles.wordSpacing}`,
      `text-transform:${styles.textTransform}`,
    ].join(";");

    element.appendChild(measureHost);

    const words = text.split(/\s+/).filter(Boolean);
    const wordSpans: HTMLSpanElement[] = [];

    words.forEach((word, index) => {
      const span = document.createElement("span");
      span.style.display = "inline-block";
      span.textContent = word + (index < words.length - 1 ? " " : "");
      measureHost.appendChild(span);
      wordSpans.push(span);
    });

    const lineGroups: HTMLSpanElement[][] = [];
    let currentLine: HTMLSpanElement[] = [];
    let currentTop = -1;

    wordSpans.forEach((span) => {
      const top = span.offsetTop;
      if (currentTop === -1) {
        currentTop = top;
      }
      if (top !== currentTop) {
        lineGroups.push(currentLine);
        currentLine = [];
        currentTop = top;
      }
      currentLine.push(span);
    });

    if (currentLine.length) {
      lineGroups.push(currentLine);
    }

    measureHost.remove();

    const lines = lineGroups.map((group) =>
      group.map((span) => span.textContent).join("")
    );

    return lines.length ? lines : [text];
  }
}

4. Use in your app

"use client";

import { useRef } from "react";
import { useTextInvert } from "@/lib/use-text-invert";

export function AboutSection() {
  const scopeRef = useRef<HTMLElement>(null);
  useTextInvert({ scopeRef });

  return (
    <section ref={scopeRef}>
      <span className="tp_text_invert">What we do</span>
      <h2 className="tp_text_invert">
        We tell visual stories through smooth motions
      </h2>
    </section>
  );
}

Let’s connect

I’m always open to discussing new projects, creative ideas, or opportunities to be part of your visions. Just reach out!

2026 © Built with Next.js

By Love ❤️