technical

Animations - Tubelight text effect

Using gsap to create a tubelight-like effect. It is a visually appealing animation style that mimics the flickering or glowing effect of a fluorescent tube light.
Sep 15, 2024
8 min read

In this blog post, we will create a Tubelight Text Effect using GSAP (GreenSock Animation Platform). The tubelight text effect typically refers to a visually appealing animation style that mimics the flickering or glowing effect of a fluorescent tube light.

Demo

Follow the white rabbit

What You Will Learn

  • How animations work in GSAP.
  • How to split text into individual characters for animation.
  • How to apply random colors and opacity to text characters.
  • How to sequence animations through multiple states using GSAP timelines.
  • How to trigger animations efficiently.

You can use React, Vue, or any other framework to implement this effect. I am not using any framework, just typescript, html and css.

Npm Packages

We will use two packages, gsap and split-type for this effect.

npm install gsap split-type

SlowMotion

Before we dig into the code, let’s take a look at the effect in slow motion.

Follow the white rabbit

You will notice that each character goes through three states. The first state sets the initial properties, the second state changes the opacity, and the third state resets the color and opacity. This sequence creates a dynamic and visually appealing effect. Let’s break down the code step by step.

In state 1: We notice a thin vertical line positioned in the center of each character. Also each character has low opacity and has different colors. Also the characters appear randomly but at their correct positions.

In state 2: We notice that the thin vertical line has expanded but still has some opacity for the characters to be visible slightly.

In state 3: Here, we reveal the full character with full opacity and the color of the character is restored to its original color. Also we notice that the vertical line has disappeared.

Lets start with the code

The markup is quite simple.

<div class="tubelight">Follow the white rabbit</div>

Now lets write the code to animate the text. We will start by splitting this text into characters. We will use split-type package which will wrap each character in a span tag. This is an alternate to using the SplitText plugin from GSAP which is a paid plugin.

Lets use it in our code.

type MainElement = HTMLDivElement & {
    mainTimeline: gsap.core.Timeline;
};
// we can have multiple text elements that we want to animate
const nodes = document.querySelectorAll<MainElement>(
    selector
);
nodes.forEach((element) => {
    const splitTextInstance = new SplitText(element, {
        charClass: 'char-animate',
        types: 'chars',
    });
});

We start by defining a custom type MainElement, which extends HTMLDivElement to include the main timeline property. This helps us keep track of the animation state for each text element (not characters). For each character, we will create a new timeline and add to the main timeline.

Next we are going to shuffle the characters and loop over them to create the animation sequence.

...
nodes.forEach((element) => {
    const splitTextInstance = new SplitText(element, {
        charClass: 'char-animate',
        types: 'chars',
    });
    const shuffledChars = shuffleArray(splitTextInstance.chars);
    let mainTimeline = gsap.timeline(); 
    shuffledChars?.forEach((charElement, index) => {
        const timeline = gsap.timeline();
        // sequence of animations 
        mainTimeline.add(timeline, index * 0.010);
    });
});

Here we create a new timeline for each character and add it to the main timeline. The new timeline will contain the sequence of animations for each character. We also add a delay of 0.04 seconds between each character state transition to create a stagger effect.

shuffledChars?.forEach((charElement, index) => {
        const timeline = gsap.timeline();
        timeline
            .set(charElement, {
                className: 'char-animate state-1',
                opacity: '0.6',
                color: getRandomBrightColor(),
                delay: 0.04,
            })
})

For each character, we create a timeline that sets the initial properties (like opacity and color). We also add some classes. The state-1 class is added, denoting its the first state. We also add char-animate class which will stay for all three states. The getRandomBrightColor() function returns a random bright color for the character, got this from chatgpt. Also we add a delay of 0.04 seconds. Its so small, but it makes a difference in the animation.

const getRandomBrightColor = () => {
    const rgb = `rgb(${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)}, ${Math.floor(Math.random() * 255)})`;
    return rgb;
}

Now we will move on to the next state which is state-2.

timeline
    .set(charElement, {
        className: 'char-animate state-1',
        opacity: '0.6',
        color: getRandomBrightColor(),
        delay: 0.04,
    })
    .set(charElement, {
        delay,
        className: 'char-animate state-1 state-2',
        opacity: getRandomOpacity(),
    }) 

We add the state-2 class to the character. We also set the opacity to a random value using the getRandomOpacity() function.

function getRandomOpacity(): string {
    return (Math.random() * 0.9 + 0.1).toFixed(1);
}

Finally we set the third state.

timeline
    .set(charElement, {
        className: 'char-animate state-1',
        opacity: '0.6',
        color: getRandomBrightColor(),
        delay: 0.04,
    })
    .set(charElement, {
        delay,
        className: 'char-animate state-1 state-2',
        opacity: getRandomOpacity(),
    })
    .set(charElement, {
        delay,
        className: 'char-animate state-1 state-3',
        opacity: 1,
        color: 'inherit',
    })

Here, we add state-3 class to the character. We also set the opacity to 1 and the color to inherit. This resets the character to its original color.

CSS

We need to add some CSS to style the characters and the animation states. I will skip the explanation since its quite easy to follow.

.char-animate {
  position: relative;
  color: transparent;
}
.char-animate.state-1::before {
  width: 1px;
}
.char-animate::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  background: black;
  width: 0;
  height: 1.2em;
  -ms-transform: translate(-50%, -55%);
  transform: translate(-50%, -55%);
}

.dark .char-animate::before {
  background: orange;
}

.char-animate.state-2::before {
  width: 0.9em;
  opacity: 0.9;
}

.char-animate.state-3 {
  color: inherit;
}
.char-animate.state-3::before {
  display: none;
}

Playing the Animation

The returned object contains a play method that reverses the animation timeline and plays it. This allows you to control when the animation starts.

const play = () => {
    nodes.forEach((element) => {
        element.animationTimeline.revert()
        element.animationTimeline.play();
    });
}

What revert() does is that it reverts any animations or properties applied by GSAP back to their original state. This ensures the timeline starts from a clean slate, unaffected by previous animation states.

And finally play() starts the animation.

Also we want to trigger the animation when the text comes into the viewport. For that we will use ScrollTrigger.

import gsap from "gsap";
import ScrollTrigger from 'gsap/dist/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

We can set it up like this.

ScrollTrigger.create({
    trigger: selector,
    once: true,
    start: 'top 90%',
    end: 'bottom 20%',
    onEnter: play
});

The start and end properties are used to define the viewport position when the animation starts and ends.

top 90% means that the animation will start when the top of the trigger element reaches 90% of the viewport’s height.

bottom 20% means that the animation will end when the bottom of the trigger element reaches 20% of the viewport’s height.

And that is it. Lets put it all together.

The complete code

type MainElement = HTMLDivElement & {
    mainTimeline: gsap.core.Timeline;
};

export const tubeLightTextEffect = (selector: string, delay = 0.04) => {
    const nodes = document.querySelectorAll<MainElement>(
        selector
    );
    let mainTimeline = gsap.timeline();
    nodes.forEach((element) => {
        const splitTextInstance = new SplitText(element, {
            charClass: 'text-animation',
            types: 'chars',
        });
        const shuffledChars = shuffleArray(splitTextInstance.chars);

        mainTimeline = gsap.timeline({ paused: true, defaults: { opacity: 0 } });

        shuffledChars?.forEach((charElement, index) => {
            const timeline = gsap.timeline();
            timeline
                .set(charElement, {
                    className: 'text-animation state-1',
                    opacity: '0.6',
                    color: getRandomBrightColor(),
                    delay: 0.04,
                })
                .set(charElement, {
                    delay,
                    className: 'text-animation state-1 state-2',
                    opacity: getRandomOpacity(),
                })
                .set(charElement, {
                    delay,
                    className: 'text-animation state-1 state-3',
                    opacity: 1,
                    color: 'inherit',
                })

            mainTimeline.add(timeline, index * 0.010);
        });

        element.mainTimeline = mainTimeline;
    });

    const play = () => {
        nodes.forEach((element) => {
            element.mainTimeline.revert()
            element.mainTimeline.play();
        });
    }
    ScrollTrigger.create({
        trigger: selector,
        once: true,
        start: 'top 90%',
        end: 'bottom 20%',
        onEnter: play
    });

    return {
        play
    }
}


function getRandomOpacity(): string {
    return (Math.random() * 0.9 + 0.1).toFixed(1);
}

function shuffleArray<T>(array: T[] | null): T[] | null {
    if (!array) return null;
    for (let i = array.length - 1; i > 0; i--) {
        const randomIndex = Math.floor(Math.random() * (i + 1));
        [array[i], array[randomIndex]] = [array[randomIndex], array[i]];
    }
    return array;
}

You can execute it this way.

tubeLightTextEffect('.tubelight');

Conclusion

I hope you understood the mechanics of this effect. Animations with CSS are always most performant. However, GSAP is a great tool for complex animations with javascript. It provides advanced timeline controls that allow you to create complex sequences of animations with precision. You can pause, resume, reverse, or control the speed of animations easily. It batches multiple animations together, reducing the number of times the browser has to perform layout and paint operations. By leveraging the GPU for animations, it reduces the workload on the CPU, leading to smoother and more responsive animations.

Did you like the post?