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-invert2. Install dependencies
npm install gsap @gsap/reactRequired 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!