gsap
Version:
GSAP is a framework-agnostic JavaScript animation library that turns developers into animation superheroes. Build high-performance animations that work in **every** major browser. Animate CSS, SVG, canvas, React, Vue, WebGL, colors, strings, motion paths,
479 lines (434 loc) • 22.7 kB
text/typescript
/*!
* SplitText 3.13.0
* https://gsap.com
*
* @license Copyright 2008-2025, GreenSock. All rights reserved.
* Subject to the terms at https://gsap.com/standard-license
* @author: Jack Doyle
*/
/* eslint-disable */
// Core Types
export type SplitTextTarget = string | NodeList | Node | Node[];
type BoundingRect = {
left: number;
top: number;
width: number;
height: number;
};
// Configuration Types
export interface WordDelimiterConfig {
delimiter: RegExp | string;
replaceWith?: string;
}
export interface SplitTextConfig {
type: string;
mask?: "lines" | "words" | "chars";
wordDelimiter?: string | RegExp | WordDelimiterConfig;
linesClass?: string;
wordsClass?: string;
charsClass?: string;
aria?: "auto" | "hidden" | "none";
tag?: string;
propIndex?: boolean;
deepSlice?: boolean;
smartWrap?: boolean;
specialChars?: string[] | RegExp;
reduceWhiteSpace?: boolean;
autoSplit?: boolean;
ignore?: SplitTextTarget;
prepareText?: PrepareTextFunction;
onSplit?: (splitText: SplitText) => void;
onRevert?: (splitText: SplitText) => void;
}
// Function Types
export type PrepareTextFunction = (text: string, element: Element) => string;
type LineWrapperFunction = (startIndex: number, endIndex: number) => void;
type ContextFunction = (obj?: SplitText) => object | void;
// Wrapper Types
type WrapFunction = {
(text: string): HTMLElement;
collection: HTMLElement[];
};
// Internal Types
interface SplitTextOriginal {
element: Element;
html: string;
ariaL: string | null; // aria-label
ariaH: string | null; // aria-hidden
width?: number;
}
let gsap: any,
_fonts: FontFaceSet | undefined,
_coreInitted: boolean, // set to true when the GSAP core is registered
_initIfNecessary = () => _coreInitted || SplitText.register((window as any).gsap),
_charSegmenter: any = typeof Intl !== "undefined" ? new (Intl as any).Segmenter() : 0, // not all older browsers support Intl.Segmenter
_toArray = (r: string | NodeList | Node | Node[]): Node[] => typeof r === "string" ? _toArray(document.querySelectorAll(r)) : "length" in r ? Array.from(r) : [r],
_elements = (targets: SplitTextTarget): HTMLElement[] => _toArray(targets).filter((e) => e instanceof HTMLElement) as HTMLElement[],
_emptyArray: string[] = [],
_context: ContextFunction = function() {},
_spacesRegEx: RegExp = /\s+/g,
_emojiSafeRegEx: RegExp = /\p{RI}\p{RI}|\p{Emoji}(\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u{200D}\p{Emoji}(\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)*|./gu, // accommodates emojis like 👨👨👦👦 which the more simple /./gu RegExp does not.
// alternate regex for emojis:
//_emojiSafeRegEx: RegExp = /\p{RI}\p{RI}|\p{Emoji}(\p{Emoji_Modifier}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u{200D}\p{Emoji}(\p{Emoji_Modifier}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)+|\p{EPres}(\p{Emoji_Modifier}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?|\p{Emoji}(\p{Emoji_Modifier}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})*|./gu,
_emptyBounds: BoundingRect = {left: 0, top: 0, width: 0, height: 0},
// merges consecutive cells that form a special character into a single cell. Like ["a", "b", "c"] and /ab/g would become ["ab", "c"]. Does not create a new Array - it modifies the existing one in place.
_stretchToFitSpecialChars = (collection: string[], specialCharsRegEx: RegExp | undefined): string[] => {
if (specialCharsRegEx) {
let charsFound: Set<string> = new Set((collection.join("").match(specialCharsRegEx) || _emptyArray)),
i: number = collection.length, slots: number, word: string, char: string, combined: string;
if (charsFound.size) {
while (--i > -1) {
word = collection[i];
for (char of charsFound) {
if (char.startsWith(word) && char.length > word.length) {
slots = 0;
combined = word;
while (char.startsWith((combined += collection[i+(++slots)])) && combined.length < char.length) {}
if (slots && combined.length === char.length) {
collection[i] = char;
collection.splice(i+1, slots);
break;
}
}
}
}
}
}
return collection;
},
_disallowInline = (element: HTMLElement): unknown => window.getComputedStyle(element).display === "inline" && (element.style.display = "inline-block"),
_insertNodeBefore = (newChild: Node | string, parent: Element, existingChild: Node): Node => parent.insertBefore(typeof newChild === "string" ? document.createTextNode(newChild) : newChild as Node, existingChild),
// create a wrapper function that will create a new element with the given type (char, word, or line) and add it to the collection
_getWrapper = (type: string, config: SplitTextConfig, collection: HTMLElement[]): WrapFunction => {
let className: string = (config as any)[type + "sClass"] || "",
{tag = "div", aria = "auto", propIndex = false} = config,
display: string = type === "line" ? "block" : "inline-block",
incrementClass: boolean = className.indexOf("++") > -1,
wrapper = ((text: string): HTMLElement => {
let el: HTMLElement = document.createElement(tag),
i: number = collection.length + 1;
className && (el.className = className + (incrementClass ? " " + className + i : ""));
propIndex && el.style.setProperty("--" + type, i + "");
aria !== "none" && el.setAttribute("aria-hidden", "true");
if (tag !== "span") {
el.style.position = "relative";
el.style.display = display;
}
el.textContent = text;
collection.push(el);
return el;
}) as WrapFunction;
incrementClass && (className = className.replace("++", ""));
wrapper.collection = collection;
return wrapper;
},
// there's some special logic for lines that we need to handle on top of the normal wrapper function
_getLineWrapper = (element: Element, nodes: Node[], config: SplitTextConfig, collection: HTMLElement[]): LineWrapperFunction => {
let lineWrapper: WrapFunction = _getWrapper("line", config, collection),
textAlign: string = window.getComputedStyle(element).textAlign || "left";
return (startIndex: number, endIndex: number): void => {
let newLine: HTMLElement = lineWrapper("");
newLine.style.textAlign = textAlign;
element.insertBefore(newLine, nodes[startIndex]);
for (; startIndex < endIndex; startIndex++) {
newLine.appendChild(nodes[startIndex]);
}
newLine.normalize();
}
},
// this is the main recursive function that splits the text into words and characters. We handle line splitting separately.
_splitWordsAndCharsRecursively = (element: Element, config: SplitTextConfig, wordWrapper: WrapFunction, charWrapper: WrapFunction | null, prepForCharsOnly: boolean, deepSlice: boolean, ignore: Element[] | false, charSplitRegEx: RegExp, specialCharsRegEx: RegExp | undefined, isNested: boolean): void => {
let nodes: Node[] = Array.from(element.childNodes),
i: number = 0,
{wordDelimiter, reduceWhiteSpace = true, prepareText } = config,
elementBounds: BoundingRect = element.getBoundingClientRect(),
lastBounds: BoundingRect = elementBounds,
isPreformatted: boolean = !reduceWhiteSpace && window.getComputedStyle(element).whiteSpace.substring(0, 3) === "pre",
ignoredPreviousSibling: Element | number = 0,
wordsCollection: HTMLElement[] = wordWrapper.collection,
wordDelimIsNotSpace: boolean,
wordDelimString: string,
wordDelimSplitter: RegExp | string | undefined,
curNode: Node, words: string[], curWordEl: Element, startsWithSpace: boolean, endsWithSpace: boolean, j: number, bounds: BoundingRect, curWordChars: string[],
clonedNode: HTMLElement, curSubNode: Node | null, tempSubNode: Node, curTextContent: string, wordText: string, lastWordText: string, k: number;
if (typeof wordDelimiter === "object") {
wordDelimSplitter = (wordDelimiter as WordDelimiterConfig).delimiter || wordDelimiter as (string | RegExp);
wordDelimString = (wordDelimiter as WordDelimiterConfig).replaceWith || "";
} else {
wordDelimString = wordDelimiter === "" ? "" : wordDelimiter || " ";
}
wordDelimIsNotSpace = wordDelimString !== " ";
for (; i < nodes.length; i++) {
curNode = nodes[i];
if (curNode.nodeType === 3) {
curTextContent = curNode.textContent || "";
if (reduceWhiteSpace) {
curTextContent = curTextContent.replace(_spacesRegEx, " ")
} else if (isPreformatted) {
curTextContent = curTextContent.replace(/\n/g, wordDelimString + "\n");
}
prepareText && (curTextContent = prepareText(curTextContent, element));
curNode.textContent = curTextContent;
words = wordDelimString || wordDelimSplitter ? curTextContent.split(wordDelimSplitter || wordDelimString) : curTextContent.match(charSplitRegEx) || _emptyArray;
lastWordText = words[words.length - 1];
endsWithSpace = wordDelimIsNotSpace ? lastWordText.slice(-1) === " " : !lastWordText;
lastWordText || words.pop(); // if the last word is empty, remove it
lastBounds = elementBounds;
startsWithSpace = wordDelimIsNotSpace ? words[0].charAt(0) === " " : !words[0];
startsWithSpace && _insertNodeBefore(" ", element, curNode); // if the word starts with a space, add a space to the beginning of the node
words[0] || words.shift(); // if the first word is empty, remove it
_stretchToFitSpecialChars(words, specialCharsRegEx);
(deepSlice && isNested) || (curNode.textContent = ""); // only clear out the text if we don't need to measure bounds. We must measure bounds if we're either splitting lines -OR- if we're splitting ONLY characters. In that case, it's important to gradually swap out the text content as we slice it up, otherwise we'll get funky wrapping that'd throw off the bounds calculations.
for (j = 1; j <= words.length; j++) {
wordText = words[j-1];
if (!reduceWhiteSpace && isPreformatted && wordText.charAt(0) === "\n") { // when we're NOT reducing white space, and we're in a preformatted element, and the word starts with a newline, then we need to remove the previous sibling (which is a wordDelimiter) and insert a <br> tag.
curNode.previousSibling?.remove(); // we added an extra wordDelimiter in front of all newline characters, so we need to remove it.
_insertNodeBefore(document.createElement("br"), element, curNode);
wordText = wordText.slice(1);
}
if (!reduceWhiteSpace && wordText === "") {
_insertNodeBefore(wordDelimString, element, curNode);
} else if (wordText === " ") {
element.insertBefore(document.createTextNode(" "), curNode);
} else {
wordDelimIsNotSpace && wordText.charAt(0) === " " && _insertNodeBefore(" ", element, curNode);
// if the previous sibling is an ignored element, and we're on the first word, and there's no starting space, and the previous sibling is part of the word collection, then we must combine them (as if this is continuing a word from the previous node before the ignored element(s)).
if (ignoredPreviousSibling && j === 1 && !startsWithSpace && wordsCollection.indexOf((ignoredPreviousSibling as Element).parentNode as HTMLElement) > -1) {
curWordEl = wordsCollection[wordsCollection.length - 1];
curWordEl.appendChild(document.createTextNode(charWrapper ? "" : wordText)); // if we're splitting characters, we'll add them one-by-one below
} else {
curWordEl = wordWrapper(charWrapper ? "" : wordText); // if we're splitting characters, we'll add them one-by-one below
_insertNodeBefore(curWordEl, element, curNode);
ignoredPreviousSibling && j === 1 && !startsWithSpace && curWordEl.insertBefore(ignoredPreviousSibling as Element, curWordEl.firstChild);
}
// split characters if necessary
if (charWrapper) {
curWordChars = _charSegmenter ? _stretchToFitSpecialChars([..._charSegmenter.segment(wordText)].map(s => s.segment), specialCharsRegEx) : wordText.match(charSplitRegEx) || _emptyArray;
for (k = 0; k < curWordChars.length; k++) {
curWordEl.appendChild(curWordChars[k] === " " ? document.createTextNode(" ") : charWrapper(curWordChars[k]));
}
}
// subdivide if necessary so that if a single inline element spills onto multiple lines, it gets sliced up accordingly
if (deepSlice && isNested) {
curTextContent = curNode.textContent = curTextContent.substring(wordText.length+1, curTextContent.length); // remember that we've got to accommodate the word delimiter in the substring.
bounds = curWordEl.getBoundingClientRect();
if (bounds.top > lastBounds.top && bounds.left <= lastBounds.left) {
clonedNode = element.cloneNode() as HTMLElement;
curSubNode = element.childNodes[0];
while (curSubNode && curSubNode !== curWordEl) {
tempSubNode = curSubNode;
curSubNode = curSubNode.nextSibling; // once we renest it in clonedNode, the nextSibling will be different, so grab it here.
clonedNode.appendChild(tempSubNode);
}
(element.parentNode as Element).insertBefore(clonedNode, element);
prepForCharsOnly && _disallowInline(clonedNode);
}
lastBounds = bounds;
}
if (j < words.length || endsWithSpace) {
// always add the delimiter between each word unless we're at the very last word in which case we may need to add a space. Special case: if a word in the middle ends in a space and we're NOT using space as the delimiter, then we need to insert a space before the delimiter too. Example: if wordDelimiter is "t" in "This text is <strong>bold</strong> and there is a <a href="https://gsap.com">link here</a>."
_insertNodeBefore(j >= words.length ? " " : wordDelimIsNotSpace && wordText.slice(-1) === " " ? " " + wordDelimString : wordDelimString, element, curNode);
}
}
}
element.removeChild(curNode);
ignoredPreviousSibling = 0;
} else if (curNode.nodeType === 1) {
if (ignore && ignore.indexOf(curNode as Element) > -1) { // if the current node is in the ignore array, then we need to ignore it and move it inside the end of the last word (but only if the previous sibling is a word).
wordsCollection.indexOf(curNode.previousSibling as HTMLElement) > -1 && wordsCollection[wordsCollection.length - 1].appendChild(curNode);
ignoredPreviousSibling = curNode as Element;
} else {
_splitWordsAndCharsRecursively(curNode as Element, config, wordWrapper, charWrapper, prepForCharsOnly, deepSlice, ignore, charSplitRegEx, specialCharsRegEx, true);
ignoredPreviousSibling = 0;
}
prepForCharsOnly && _disallowInline(curNode as HTMLElement);
}
}
};
export class SplitText {
elements: HTMLElement[];
chars: HTMLElement[];
words: HTMLElement[];
lines: HTMLElement[];
masks: HTMLElement[];
_data: {
orig: SplitTextOriginal[];
anim?: {totalTime: (t?: number) => number, revert: () => void};
animTime?: number;
obs: ResizeObserver | false;
};
vars: SplitTextConfig;
isSplit: boolean = false;
_split: () => void;
constructor(elements: SplitTextTarget, config: SplitTextConfig) {
_initIfNecessary();
this.elements = _elements(elements);
this.chars = [];
this.words = [];
this.lines = [];
this.masks = [];
this.vars = config;
this._split = () => this.isSplit && this.split(this.vars);
let orig: SplitTextOriginal[] = [],
timerId: number,
checkWidths = () => {
let i: number = orig.length,
o: SplitTextOriginal;
while (i--) {
o = orig[i];
let w: number = (o.element as HTMLElement).offsetWidth;
if (w !== o.width) {
o.width = w;
this._split();
return;
}
}
};
this._data = {orig: orig, obs: typeof(ResizeObserver) !== "undefined" && new ResizeObserver(() => { clearTimeout(timerId); timerId = setTimeout(checkWidths, 200) as unknown as number})};
_context(this);
this.split(config);
}
split(config: SplitTextConfig) {
this.isSplit && this.revert();
this.vars = config = config || this.vars || {};
let {type = "chars,words,lines", aria = "auto", deepSlice = true, smartWrap, onSplit, autoSplit = false, specialChars, mask} = this.vars,
splitLines: boolean = type.indexOf("lines") > -1,
splitCharacters: boolean = type.indexOf("chars") > -1,
splitWords: boolean = type.indexOf("words") > -1,
onlySplitCharacters: boolean = splitCharacters && !splitWords && !splitLines,
specialCharsRegEx: RegExp | undefined = specialChars && (("push" in specialChars) ? new RegExp("(?:" + specialChars.join("|") + ")", "gu") : specialChars),
finalCharSplitRegEx: RegExp = specialCharsRegEx ? new RegExp(specialCharsRegEx.source + "|" + _emojiSafeRegEx.source, "gu") : _emojiSafeRegEx,
ignore: HTMLElement[] | false = !!config.ignore && _elements(config.ignore),
{orig, animTime, obs} = this._data,
onSplitResult: any;
if (splitCharacters || splitWords || splitLines) {
this.elements.forEach((element, index) => {
orig[index] = {
element,
html: element.innerHTML,
ariaL: element.getAttribute("aria-label"),
ariaH: element.getAttribute("aria-hidden")
};
aria === "auto" ? element.setAttribute("aria-label", (element.textContent || "").trim()) : aria === "hidden" && element.setAttribute("aria-hidden", "true");
let chars: HTMLElement[] = [],
words: HTMLElement[] = [],
lines: HTMLElement[] = [],
charWrapper = splitCharacters ? _getWrapper("char", config, chars) : null,
wordWrapper = _getWrapper("word", config, words),
i: number, curWord: Element, smartWrapSpan: HTMLElement, nextSibling: Node;
// split words (always) and characters (if necessary)
_splitWordsAndCharsRecursively(element, config, wordWrapper, charWrapper, onlySplitCharacters, deepSlice && (splitLines || onlySplitCharacters), ignore, finalCharSplitRegEx, specialCharsRegEx, false);
// subdivide lines
if (splitLines) {
let nodes: Node[] = _toArray(element.childNodes),
wrapLine = _getLineWrapper(element, nodes, config, lines),
curNode: Node,
toRemove: Node[] = [],
lineStartIndex: number = 0,
allBounds: BoundingRect[] = nodes.map((n) => n.nodeType === 1 ? (n as Element).getBoundingClientRect() : _emptyBounds), // do all these measurements at once so that we don't trigger layout thrashing
lastBounds: BoundingRect = _emptyBounds;
for (i = 0; i < nodes.length; i++) {
curNode = nodes[i];
if (curNode.nodeType === 1) {
if (curNode.nodeName === "BR") { // remove any <br> tags because breaking up by lines already handles that.
toRemove.push(curNode);
wrapLine(lineStartIndex, i+1);
lineStartIndex = i+1;
lastBounds = allBounds[lineStartIndex];
} else {
if (i && allBounds[i].top > lastBounds.top && allBounds[i].left <= lastBounds.left) {
wrapLine(lineStartIndex, i);
lineStartIndex = i;
}
lastBounds = allBounds[i];
}
}
}
lineStartIndex < i && wrapLine(lineStartIndex, i);
toRemove.forEach(el => el.parentNode?.removeChild(el));
}
// remove words if "type" doesn't include "words"
if (!splitWords) {
for (i = 0; i < words.length; i++) {
curWord = words[i];
if (splitCharacters || !curWord.nextSibling || curWord.nextSibling.nodeType !== 3) {
if (smartWrap && !splitLines) { // tried inserting String.fromCharCode(8288) between each character to prevent words from wrapping strangely, but it doesn't work. Also tried adding <wbr> tags between each character, but it doesn't work either.
smartWrapSpan = document.createElement("span"); // replace the word element with a span that has white-space: nowrap
smartWrapSpan.style.whiteSpace = "nowrap";
while (curWord.firstChild) {
smartWrapSpan.appendChild(curWord.firstChild);
}
curWord.replaceWith(smartWrapSpan);
} else {
curWord.replaceWith(...curWord.childNodes);
}
} else {
nextSibling = curWord.nextSibling;
if (nextSibling && nextSibling.nodeType === 3) {
nextSibling.textContent = (curWord.textContent || "") + (nextSibling.textContent || "");
curWord.remove();
}
}
}
words.length = 0;
element.normalize();
}
this.lines.push(...lines);
this.words.push(...words);
this.chars.push(...chars);
});
mask && this[mask] && this.masks.push(...(this as any)[mask as string].map((el: HTMLElement) => {
let maskEl: HTMLElement = el.cloneNode() as HTMLElement;
el.replaceWith(maskEl);
maskEl.appendChild(el);
el.className && (maskEl.className = el.className.replace(/(\b\w+\b)/g, '$1-mask'));
maskEl.style.overflow = "clip";
return maskEl;
}));
}
this.isSplit = true;
_fonts && (autoSplit ? _fonts.addEventListener("loadingdone", this._split) : _fonts.status === "loading" && console.warn("SplitText called before fonts loaded"));
if ((onSplitResult = onSplit && onSplit(this)) && onSplitResult.totalTime) {
this._data.anim = animTime ? onSplitResult.totalTime(animTime) : onSplitResult;
}
splitLines && autoSplit && this.elements.forEach((element, index) => {
orig[index].width = element.offsetWidth;
obs && obs.observe(element);
});
return this;
}
revert() {
let {orig, anim, obs} = this._data;
obs && obs.disconnect();
orig.forEach(({element, html, ariaL, ariaH}) => {
element.innerHTML = html;
ariaL ? element.setAttribute("aria-label", ariaL) : element.removeAttribute("aria-label");
ariaH ? element.setAttribute("aria-hidden", ariaH) : element.removeAttribute("aria-hidden");
});
this.chars.length = this.words.length = this.lines.length = orig.length = this.masks.length = 0;
this.isSplit = false;
_fonts?.removeEventListener("loadingdone", this._split);
if (anim) { // if the user returned an animation in the onSplit, we record the totalTime() here and revert() it and then we'll the totalTime() of the newly returned onSplit animation. This allows things to be seamless
this._data.animTime = anim.totalTime();
anim.revert();
}
this.vars.onRevert?.(this);
return this;
}
static create(elements: SplitTextTarget, config: SplitTextConfig) {
return new SplitText(elements, config);
}
static register(core: any) {
gsap = gsap || core || (window as any).gsap;
if (gsap) {
_toArray = gsap.utils.toArray;
_context = gsap.core.context || _context;
}
if (!_coreInitted && window.innerWidth > 0) {
_fonts = document.fonts;
_coreInitted = true;
}
}
static readonly version: string = "3.13.0";
}
export { SplitText as default };