When GSAP was acquired by Webflow, we knew SplitText would serve as a foundation for text effects, so we decided to completely rewrite it from the ground up.
To create engaging text animations where the letters or words or lines independently move, you either have to manually build the HTML structure by putting each letter/word/line into its own element which is quite cumbersome, or you need to use JavaScript to do it dynamically. SplitText is one of the most popular plugins for GSAP and it does exactly that.
We began with an ambitious set of goals:
- Cut the file size in half to minimize load times
- Add screen reader accessibility
- Add responsive re-splitting so that when fonts load or the element resizes and the text reflows onto different lines, it doesn’t cause funky line breaks.
- Allow a single inline element (like <strong>) to get sliced up to fit inside multiple line elements.
- Add masking for easy reveal effects
- Allow certain nested elements to be immune from splitting
- Improve how complex characters like emojis and foreign language characters get split
- Make custom word delineation more flexible to accommodate special edge cases
- Make it possible to use SplitText apart from the GSAP core (standalone)
- Convert to TypeScript
In this article, I’ll share some of the technical challenges, solutions, and takeaways from the process.
Character splitting accuracy
At first glance, splitting text into separate characters might seem incredibly simple. For example, you may try using .split("") which works fine for simple letters:
"Easy".split(""); // ["E", "a", "s", "y"]
But watch what happens when special characters like emojis are split like this:
"💚".split(""); // ['\uD83D', '\uDC9A']
Oops! That one emoji is actually a combination of 2 characters. And gets worse:
"👨👨👦👦".split(""); // ['\uD83D', '\uDC68', '', '\uD83D', '\uDC68', '', '\uD83D', '\uDC66', '', '\uD83D', '\uDC66']
That's 11 characters for one emoji! There are certain foreign language characters that behave similarly.
Using Array.from() (introduced in ES6), works with some emojis but not more complex ones:
Array.from("💚").length; // 1
Array.from("👨👨👦👦").length; // 7
The best solution is to use Intl.Segmenter():
[...new Intl.Segmenter().segment("👨👨👦👦")].map(s => s.segment); // ["👨👨👦👦"]
But in older browsers that don't support Intl.Segmenter(), a RegExp-based split() must be used as a fallback.
Takeaway: an “obvious” solution may fall apart with edge cases, so test against tricky scenarios. Be open to newer browser API’s that could be pressed into service, but have a fallback in place wherever possible.
Handling nested elements
Consider a nested <strong>...</strong> element that wraps onto two lines…
<p id="target">
What if a <strong>nested element
wraps onto multiple lines?</strong>
</p>
…to wrap each line inside its own <div>, we must break that <strong> into multiple elements (one <strong> for each line):
<p id="target">
<div class="line1">What if a <strong>nested element</strong></div>
<div class="line2"><strong>wraps onto multiple lines?</strong></div>
</p>
That's what the deepSlice feature in SplitText does for any nested element(s). When it senses a new line while recursively splitting text inside a nested element, it uses cloneNode() and reorganizes the contents.
Since this feature duplicates and restructures nested elements, it could affect CSS selectors (like p strong:first-of-type), so it was important for us to give users a way to opt-out (deepSlice: false). And since a key goal was keeping file size to a minimum, we had to consider whether or not the kb cost was worthwhile. In this case it was.
Takeaway: anticipate complicated edge cases and offer solutions that users can opt out of if they prefer not to have DOM elements duplicated/restructured. And if the solution is too costly (in terms of file size or performance), it may not be worthwhile.
File size reduction
Here are some of the strategies used to cut the file size by 50%:
- Replaced an extremely long RegExp for emoji-safe splitting with Intl.Segmenter() (for modern browsers) and a fallback to a simpler RegExp for legacy browsers.
- Ruthlessly eliminated redundancies, putting common functionality into helper functions, and made quite a few passes through the code to make everything as short and efficient as possible.
- Instead of concatenating all the elements into one big string like "<div class='char'>S</div><div class='char'>p</div>..." that gets shoved into .innerHTML, it now manipulates elements directly, like document.createElement("div"). This required less code and was more flexible.
- Eliminated a few features that almost nobody used, like position: "absolute" which was a relic from the days when some browsers didn’t support the transform CSS property.
Takeaway: it often takes quite a few passes over a codebase to ruthlessly chop down the file size and make it as efficient as possible. Some of those efficiencies come at the expense of code readability, however, so you must decide what is most important in your particular project. In this case, tight file size was far more important than having readable code.
Automatic re-splitting
If only words and/or characters are split, those reflow naturally when the container resizes but if you split by lines, each line element encloses around a specific set of words/characters. If the container then resizes narrower or if the font loads after the split, for example, the text may reflow causing some of the words to belong in different lines (the last word in a line may shift down to the next). The only way to avoid strange line breaks is to re-split (restore the original innerHTML and have SplitText run its splitting logic again) so that the line elements enclose the proper words.
So for SplitText, we added an autoSplit: true feature that uses a "loadingdone" event listener on document.fonts as well as a ResizeObserver on the element to automatically revert and re-split when either of those events occur. To maximize performance, width change events are debounced so that re-splitting only occurs when they stop for 200ms or more. And it ignores height changes since those don't cause text reflows.
Takeaway: always consider what will happen when the viewport resizes and give users the ability to react to those changes in a performant way (like debouncing).
Conversion to TypeScript
This was my first time coding a project completely in TypeScript. I see why many developers appreciate the protections it offers and the improved code hinting. It did make the file more verbose and it took a little longer, but I'm still inclined to use TypeScript more in the future. Much of the motivation comes from the fact that the rest of the Webflow team has a strong preference for TypeScript too.
Honestly, I can see valid arguments for and against using TypeScript but the two biggest deciding factors for me are:
- It did help more quickly identify potential errors in the code during development. It's difficult to know for sure if it saved more time than it cost to add all the TypeScript information, but my guess is that it would do so at least in larger projects.
- Consistency with the rest of the Webflow team's practices and preferences. Historically I haven't needed to consider working on a team, so this is new to me but it seems important to have cohesive practices as a team.
Conclusion
I've skipped over a lot of the complexities for the sake of brevity in this article, but it's difficult to appreciate all the little "gotchas" involved in effectively splitting apart text inside an element into characters, words, and lines until you try it yourself.
If you'd like to see how SplitText handles splitting compared to the most popular alternative, here's a demo: https://codepen.io/GreenSock/full/VYwPVoB
I must admit it was very satisfying to cut the file size down by 50% while also adding about 14 new features and successfully converting it all to TypeScript. SplitText now feels ready to serve as a solid foundation for Webflow's built-in text animation capabilities. And what's even more exciting is that the SplitText plugin itself is now free for everyone, thanks to Webflow 🥳.
We’re looking for product and engineering talent to join us on our mission to bring development superpowers to everyone.