Outline
For my portfolio site, I wanted to include some SplitText animations; a few more subtle, with a standout variation that felt attention-grabbing and unique. The main goal was to keep it relatively lightweight under the hood, both for performance and because it was just one of many animations I had planned, so I didn’t want something too complex.
I found a really nice version of a SplitText-esque animation that I wanted to try and recreate on Good Fella.The rest of the site is incredible as well, (SOTD for a reason) and you should definitely check them out.
Attempt 1: HTML + CSS
I think it's worth saying: It is good practice to try and make as much of an animation or interaction in HTML and CSS. While this is not possible for *every* animation or interaction; I still think it's valuable to see how much of it can be done with HTML and CSS. Don't avoid Javascript but rather be more tactical with its use; as not every problem needs the Javascript hammer.
Looking at the animation on Good Fella, I knew I needed a div the color of the background, a second highlight color and a final one either black or white (depending on the text color). So this was the HTML setup I had.
1<style>
2
3<div class="wipe-wrapper">
4 <div class="wipe is-1"></div>
5 <div class="wipe is-2"></div>
6 <div class="wipe is-3"></div>
7 <div class="text">Text To Mask</div>
8 </div>
9
10<style>
The .wipe elements are all position absolute, inset 0. The wipe-wrapper is relative to contain them. The wipe elements are stacked on top of each other using different z-indexes. The background-color div gets the highest z-index (.is-3) so that it ends up on top of the others. The text-color div (.is-2) gets z-index: 2 and the highlight-color div (is-1) gets zindex:1.
This was the Initial HTML and CSS setup. Looking back, I could’ve used some pseudo elements to cut down on the number of divs necessary (If I did this I might've got to the final solution sooner).
These .wipe divs are going to end up moving to the right, so a translateX(100%). To stagger the .wipe divs and to make sure they hold their end position, I used a keyframe animation. The .is-active getting applied on .wipe-wrapper acts as the trigger for the animation.
<style>
@keyframes wipeRight{
0%{transform: translateX(0%);}
100%{transform: translateX(100%);}
}
.wipe-wrapper.is-active .wipe{
animation-name: wipeRight;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
.wipe-wrapper.is-active .wipe.is-3{
animation-delay: 0s;
}
.wipe-wrapper.is-active .wipe.is-2{
animation-delay: 0.2s;
}
.wipe-wrapper.is-active .wipe.is-1{
animation-delay: 0.4s;
}
</style>
The animation-fill-mode: forwards makes sure the elements hold their end positions.
For a first attempt it looked pretty good! It captured the essence of what I wanted whilst being quite simple. I thought this method could be used for my final iteration.
Attempt 2: Heavy Vibe-Coding
I think most people can see where this would go wrong - Line breaking. If the text spans multiple lines, the .wipe divs will slide across all the text in a big block, not line by line.
My next thought was to use the same concept coupled with SplitText. If you split the text by lines, type: lines, SplitText will add a .div which wraps each line. I could then append all of the .wipes in that .div and use ScrollTrigger to add .is-active to the split.lines div to trigger the animation
I played around with this for a bit using Chat-GPT, but was encountering too many issues. What happens if I wanted to have more than one text animation on a page? I would have to clone those wipe elements. Then I had to append those into the split.lines .divs, then the .is-active class wasn't behaving right, as I then had to stagger when it got added to achieve the stagger between lines effect.
I realized that brute forcing with Chat-GPT was not going to work. I needed to re-examine how I was approaching this problem. At least that's what I should've done. It was Saturday and I was getting lazy and I really was looking for Chat to give me a plug and play solution. So I decided to shelve this idea and look for a simpler SplitText animation.
Attempt 3: The Solution
After that, I wanted to get a small win and get some form of SplitText animation working. So I decided to do something drastic: read the docs. In there was a snippet I grabbed which had everything I needed to get a simple animation going:
SplitText.create(".split", {
type: "lines, words",
mask: "lines",
autoSplit: true,
onSplit(self) {
return gsap.from(self.words, {
duration: 1,
y: 100,
autoAlpha: 0,
stagger: 0.05
});
}
I was having trouble In previous implementations of SplitText where it was messing up the format of my text, so I used the snippet with autoSplit which helps to avoid weird line breaks.
I got rid of the type: words and set the target of the tween in the onSplit to be self.lines. This got me going with a very simple split text animation. Inspecting the page, I found that the div wrapping each line was getting wrapped in another div. So The html for each line essentially looks like this:
<div>
<div> text in this line</div>
</div>
If you use lineClass: "class-name" to give a custom class to these divs it becomes more clear what is happening:
<div class="wipe-mask">
<div class="wipe">text in this line</div>
</div>
So, the .wipe-mask now wraps the split.lines div. After looking at this it finally hit me: pseudo elements. Between these two divs I now had enough pseudo elements to do the wipe animation. And using the lineClass to give them a custom class, you can now target and style them in CSS.
So that previous CSS for the mask.is-1 etc. can now be used on the ::before and ::after of .wipe-mask and the ::after of .wipe (or whatever your class name is):
<style>
.wipe-mask::after{
content: "";
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: var(--theme--background);
z-index: 3;
}
/* wipe overlay */
.wipe-mask::before{
content: "";
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: var(--theme--text);
z-index: 2;
}
.wipe::after{
content: "";
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: var(--theme--brand);
z-index: 1;
}
</style>
Now we need to move these pseudo elements. Because they are pseudo elements you can't animate them like you would a normal element with GSAP. You have to use a custom property.
What you do is create the property on the element, then on the matching pseudo element we give the translateX() the value of the property. In the below example --transxbg refers to the translation value for the div with the color of the background, in this case .wipe-mask::before (I will explain why you can't use the same property for all of them shortly):
<style>
.wipe-mask{
--transxbg: 0%;
}
.wipe-mask::before{
transform: translateX(var(--transxbg));
}
</style>
Now what you do is you target that custom element and animate it to 101% over the course of the tween like so:
tl.to(".wipe-mask", {
duration: 0.5,
"--transxbg": "101%",
};
Now we have animated a pseudo element! If we flesh out the animations for the other two ::pseudo elements and just look at the onSplit function:
onSplit(self) {
tl = gsap.timeline({ paused: true });
tl.to(".wipe-mask", {
duration: 0.5,
"--transx": "101%",
stagger: 0.2,
});
tl.to(".wipe-mask", {
duration: 0.5,
"--transx2": "101%",
stagger: 0.2,
}, 0.2);
tl.to(".wipe", {
duration: 0.5,
"--transx3": "101%",
stagger: 0.2,
}, 0.5);
return tl;
If we look at the last tween with its absolute time of 0.5s it starts once the first animation is done. So if we used the same property (--transx) for the last tween, it would not work as by the time the final tween is ready to use it --transx is already at 101% so it would animate from 101%->101%. That's why it needs to be 3 different properties.
We had to change the onSplit so it returns a timeline as we have multiple tweens now and add paused: true so that we can play it via ScrollTrigger.
For the final code I wanted to use attributes to target the wrapper of the text([data-split="wipe"]). This was to make it attribute based which is cleaner for Webflow and then I could then use an :is() selector to target the text elements inside the attribute. I also wanted to make sure it worked with multiple on a page so I had to add a foreach() to loop through all of the [data-split="wipe"] wrappers.
gsap.registerPlugin(ScrollTrigger);
document.querySelectorAll("[data-split='wipe']").forEach((wrap) => {
let tl;
SplitText.create(wrap.querySelectorAll(":is(p,h1,h2,h3,h4,h5,h6)"), {
type: "lines",
linesClass: "wipe",
mask: "lines",
autoSplit: true,
onSplit(self) {
tl = gsap.timeline({ paused: true });
tl.to(wrap.querySelectorAll(".wipe-mask"), {
duration: 0.5,
"--transx": "101%",
stagger: 0.2,
ease: "cubic-bezier(0.25, 0.1, 0.25, 1)"
});
tl.to(wrap.querySelectorAll(".wipe-mask"), {
duration: 0.5,
"--transx2": "101%",
stagger: 0.2,
ease: "cubic-bezier(0.25, 0.1, 0.25, 1)"
}, 0.2);
tl.to(wrap.querySelectorAll(".wipe"), {
duration: 0.5,
"--transx3": "101%",
stagger: 0.2,
ease: "cubic-bezier(0.25, 0.1, 0.25, 1)"
}, 0.5);
wrap.classList.add("is-ready");
return tl;
}
});
ScrollTrigger.create({
trigger: wrap,
start: "top center",
once: true,
onEnter: () => tl && tl.play()
});
});
The .is-ready class is just to prevent Flash of Unstyled Content (FOUC). So all of the elements getting animated are set to visibility: hidden; and when .is-ready is applied they go to visibility: visible.
I think this turned out really nice in the end. I learned a lot from this mini-project like making sure to really understand how interactions happen. Build your understanding from simple-> complex. This will allow you to better troubleshoot and get the best out of AI’s like Claude and Chat-GPT and not waste too much time.
I really hoped you enjoyed and learned something from this!