Terminal Text Reveal Animation

Scroll down to see the animations in action

Scrub Mode (animates while scrolling)

This text animates as you scroll. Each character lights up with the accent color, then transitions to the final color. Scroll back up to reverse it.

Once Mode (plays once on scroll into view)

This text plays the animation once when it enters the viewport. It won't reverse when you scroll back up. Perfect for hero text and headlines.

In an era defined by precision and speed, innovation reshapes the foundation of modern industry. Every component is built with intent, every system designed to perform at scale.

Built for Performance

Every detail is calibrated for efficiency—turning raw energy into refined, sustainable power that drives industries forward.

Innovation has no finish line.

1. CDN Scripts

Add these to your <head> or before closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>

2. Required CSS

[data-terminal-reveal],
[data-terminal-reveal-once] {
opacity: 0;
}

[data-terminal-reveal].is-ready,
[data-terminal-reveal-once].is-ready {
opacity: 1;
}

.word {
display: inline-block;
white-space: nowrap;
}

.char {
display: inline-block;
}

3. JavaScript - Scrub Mode Function

This function animates text while scrolling. Scroll position controls the animation progress.

/**
* Terminal Text Reveal - Scrub Mode
* Animates text based on scroll position (reversible)
*
* @param {string} selector - Attribute selector (e.g., "[data-terminal-reveal]")
*/
function initTerminalRevealScrub(selector = "[data-terminal-reveal]") {
gsap.registerPlugin(ScrollTrigger, SplitText);

document.querySelectorAll(selector).forEach((element) => {
// Get customization options from data attributes
const colorInitial = element.dataset.colorInitial || "#dddddd";
const colorAccent = element.dataset.colorAccent || "#abff02";
const colorFinal = element.dataset.colorFinal || "#000000";

// State tracking
let lastScrollProgress = 0;
const colorTransitionTimers = new Map();
const completedChars = new Set();

// Split text into words, then chars
const wordSplit = SplitText.create(element, {
type: "words",
wordsClass: "word",
});

const charSplit = SplitText.create(wordSplit.words, {
type: "chars",
charsClass: "char",
});

const allChars = charSplit.chars;

// Set initial color
gsap.set(allChars, { color: colorInitial });

// Mark as ready (triggers opacity: 1)
element.classList.add("is-ready");

// Schedule final color transition with delay
const scheduleFinalTransition = (char, index) => {
if (colorTransitionTimers.has(index)) {
clearTimeout(colorTransitionTimers.get(index));
}

const timer = setTimeout(() => {
if (!completedChars.has(index)) {
gsap.to(char, {
duration: 0.1,
ease: "none",
color: colorFinal,
onComplete: () => completedChars.add(index),
});
}
colorTransitionTimers.delete(index);
}, 100);

colorTransitionTimers.set(index, timer);
};

// Create ScrollTrigger with scrub
ScrollTrigger.create({
trigger: element,
start: "top 90%",
end: "top 10%",
scrub: 1,
onUpdate: (self) => {
const progress = self.progress;
const totalChars = allChars.length;
const isScrollingDown = progress >= lastScrollProgress;
const currentCharIndex = Math.floor(progress * totalChars);

allChars.forEach((char, index) => {
// Scrolling up - reset chars
if (!isScrollingDown && index >= currentCharIndex) {
if (colorTransitionTimers.has(index)) {
clearTimeout(colorTransitionTimers.get(index));
colorTransitionTimers.delete(index);
}
completedChars.delete(index);
gsap.set(char, { color: colorInitial });
return;
}

// Skip already completed chars
if (completedChars.has(index)) return;

// Animate current and past chars
if (index <= currentCharIndex) {
gsap.set(char, { color: colorAccent });
if (!colorTransitionTimers.has(index)) {
scheduleFinalTransition(char, index);
}
} else {
gsap.set(char, { color: colorInitial });
}
});

lastScrollProgress = progress;
},
});
});
}

4. JavaScript - Once Mode Function

This function plays the animation once when the element scrolls into view. Non-reversible.

/**
* Terminal Text Reveal - Once Mode
* Plays animation once when element enters viewport (non-reversible)
*
* @param {string} selector - Attribute selector (e.g., "[data-terminal-reveal-once]")
*/
function initTerminalRevealOnce(selector = "[data-terminal-reveal-once]") {
gsap.registerPlugin(ScrollTrigger, SplitText);

document.querySelectorAll(selector).forEach((element) => {
// Get customization options from data attributes
const colorInitial = element.dataset.colorInitial || "#dddddd";
const colorAccent = element.dataset.colorAccent || "#abff02";
const colorFinal = element.dataset.colorFinal || "#000000";
const duration = parseFloat(element.dataset.duration) || 1;
const stagger = parseFloat(element.dataset.stagger) || 0.02;

// Split text into words, then chars
const wordSplit = SplitText.create(element, {
type: "words",
wordsClass: "word",
});

const charSplit = SplitText.create(wordSplit.words, {
type: "chars",
charsClass: "char",
});

const allChars = charSplit.chars;

// Set initial color
gsap.set(allChars, { color: colorInitial });

// Mark as ready (triggers opacity: 1)
element.classList.add("is-ready");

// Create the animation timeline
const tl = gsap.timeline({ paused: true });

// Animate each character: initial -> accent -> final
allChars.forEach((char, index) => {
const charTl = gsap.timeline();

// Flash to accent color
charTl.to(char, {
color: colorAccent,
duration: duration * 0.1,
ease: "power2.out",
});

// Transition to final color
charTl.to(char, {
color: colorFinal,
duration: duration * 0.3,
ease: "power2.inOut",
});

// Add to main timeline with stagger
tl.add(charTl, index * stagger);
});

// Trigger animation once on scroll into view
ScrollTrigger.create({
trigger: element,
start: "top 85%",
once: true,
onEnter: () => tl.play(),
});
});
}

5. Initialize on DOMContentLoaded

document.addEventListener("DOMContentLoaded", () => {
// Initialize scrub mode (animates while scrolling)
initTerminalRevealScrub("[data-terminal-reveal]");

// Initialize once mode (plays once on scroll into view)
initTerminalRevealOnce("[data-terminal-reveal-once]");
});

6. HTML Usage

<!-- Scrub Mode (animates while scrolling, reversible) -->
<p data-terminal-reveal>
Your text here will animate as you scroll.
</p>

<!-- Once Mode (plays once on scroll into view) -->
<h1 data-terminal-reveal-once>
Your headline plays animation once when visible.
</h1>

<!-- Custom colors -->
<p
data-terminal-reveal
data-color-initial="#444444"
data-color-accent="#ff6b6b"
data-color-final="#ffffff"
>
Custom colored animation.
</p>

<!-- Custom timing (once mode only) -->
<h2
data-terminal-reveal-once
data-color-accent="#00ffff"
data-duration="1.5"
data-stagger="0.03"
>
Slower animation with more stagger.
</h2>

7. Data Attributes Reference

AttributeDefaultDescription
data-terminal-reveal-Enables scrub mode (scroll-linked animation)
data-terminal-reveal-once-Enables once mode (plays once on scroll into view)
data-color-initial#ddddddStarting color of the text
data-color-accent#abff02Flash/highlight color during reveal
data-color-final#000000Final color after animation completes
data-duration1Animation duration in seconds (once mode only)
data-stagger0.02Delay between each character (once mode only)
Webstudio Tip: In Webstudio, add data-terminal-reveal or data-terminal-reveal-once as a custom attribute to any text element. Then add the CSS to your project's global styles and the JavaScript to a custom code embed before the closing </body> tag.

8. Complete Copy-Paste Script

Copy this entire script and paste it before your closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>
<script>
gsap.registerPlugin(ScrollTrigger, SplitText);

function initTerminalRevealScrub(selector = "[data-terminal-reveal]") {
document.querySelectorAll(selector).forEach((element) => {
const colorInitial = element.dataset.colorInitial || "#dddddd";
const colorAccent = element.dataset.colorAccent || "#abff02";
const colorFinal = element.dataset.colorFinal || "#000000";
let lastScrollProgress = 0;
const colorTransitionTimers = new Map();
const completedChars = new Set();

const wordSplit = SplitText.create(element, { type: "words", wordsClass: "word" });
const charSplit = SplitText.create(wordSplit.words, { type: "chars", charsClass: "char" });
const allChars = charSplit.chars;

gsap.set(allChars, { color: colorInitial });
element.classList.add("is-ready");

const scheduleFinalTransition = (char, index) => {
if (colorTransitionTimers.has(index)) clearTimeout(colorTransitionTimers.get(index));
const timer = setTimeout(() => {
if (!completedChars.has(index)) {
gsap.to(char, { duration: 0.1, ease: "none", color: colorFinal, onComplete: () => completedChars.add(index) });
}
colorTransitionTimers.delete(index);
}, 100);
colorTransitionTimers.set(index, timer);
};

ScrollTrigger.create({
trigger: element,
start: "top 90%",
end: "top 10%",
scrub: 1,
onUpdate: (self) => {
const progress = self.progress;
const totalChars = allChars.length;
const isScrollingDown = progress >= lastScrollProgress;
const currentCharIndex = Math.floor(progress * totalChars);

allChars.forEach((char, index) => {
if (!isScrollingDown && index >= currentCharIndex) {
if (colorTransitionTimers.has(index)) {
clearTimeout(colorTransitionTimers.get(index));
colorTransitionTimers.delete(index);
}
completedChars.delete(index);
gsap.set(char, { color: colorInitial });
return;
}
if (completedChars.has(index)) return;
if (index <= currentCharIndex) {
gsap.set(char, { color: colorAccent });
if (!colorTransitionTimers.has(index)) scheduleFinalTransition(char, index);
} else {
gsap.set(char, { color: colorInitial });
}
});
lastScrollProgress = progress;
},
});
});
}

function initTerminalRevealOnce(selector = "[data-terminal-reveal-once]") {
document.querySelectorAll(selector).forEach((element) => {
const colorInitial = element.dataset.colorInitial || "#dddddd";
const colorAccent = element.dataset.colorAccent || "#abff02";
const colorFinal = element.dataset.colorFinal || "#000000";
const duration = parseFloat(element.dataset.duration) || 1;
const stagger = parseFloat(element.dataset.stagger) || 0.02;

const wordSplit = SplitText.create(element, { type: "words", wordsClass: "word" });
const charSplit = SplitText.create(wordSplit.words, { type: "chars", charsClass: "char" });
const allChars = charSplit.chars;

gsap.set(allChars, { color: colorInitial });
element.classList.add("is-ready");

const tl = gsap.timeline({ paused: true });

allChars.forEach((char, index) => {
const charTl = gsap.timeline();
charTl.to(char, { color: colorAccent, duration: duration * 0.1, ease: "power2.out" });
charTl.to(char, { color: colorFinal, duration: duration * 0.3, ease: "power2.inOut" });
tl.add(charTl, index * stagger);
});

ScrollTrigger.create({
trigger: element,
start: "top 85%",
once: true,
onEnter: () => tl.play(),
});
});
}

document.addEventListener("DOMContentLoaded", () => {
initTerminalRevealScrub("[data-terminal-reveal]");
initTerminalRevealOnce("[data-terminal-reveal-once]");
});
</script>