Skip to content

Instantly share code, notes, and snippets.

@mrkzsz
Created May 17, 2025 10:18
Show Gist options
  • Save mrkzsz/e5eff32aa32dfa8525cf55c0037d256b to your computer and use it in GitHub Desktop.
Save mrkzsz/e5eff32aa32dfa8525cf55c0037d256b to your computer and use it in GitHub Desktop.
Matrix/Typescript text animation.

Matrix Text Animation Guide.

A beginner guide to implementing a flexible character-by-character text animations on your website.

Quick Start

  1. Add a matrix-group class to any container element
  2. Place text elements like <p> or <h2> inside with appropriate animation classes
  3. Add these inside sections with the correct section classes
<section class="herosection active">
  <div class="matrix-group">
    <h1 class="matrixtext1">This text will animate character by character</h1>
    <p class="matrixtext2">And this will animate slightly slower</p>
  </div>
</section>
or
<section class="herosection active matrix-group">
    <h1 class="matrixtext1">This text will animate character by character</h1>
    <p class="matrixtext2">And this will animate slightly slower</p>
</section>
or
<div class="herosection">
  <div class="matrix-group">
    <h1 class="matrixtext1">This text will animate character by character</h1>
    <p class="matrixtext2">And this will animate slightly slower</p>
  </div>
</div>

Changing Animation Speeds

.matrixtext1.animate span {
    animation-delay: calc(var(--char-index) * 0.015s); //change the number
}
.matrixtext2.animate span {
    animation-delay: calc(var(--char-index) * 0.02s); //change the number
}
.matrixtext3.animate span {
    animation-delay: calc(var(--char-index) * 0.03s); //change the number
}

Changing Delay Times

// Add more conditions here for additional stall classes (e.g., "stall3", "stall4")
if (element.classList.contains("stall2")) {
    await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 seconds
} else if (element.classList.contains("stall1")) {
    await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 second
} else if (element.classList.contains("stall")) {
    await new Promise((resolve) => setTimeout(resolve, 500)); // 0.5 seconds
} 

Creating Custom Animation Classes

  1. Add a new CSS class:
.matrixtext4.animate span {
    animation-delay: calc(var(--char-index) * 0.05s);
}
  1. Update the JavaScript animation delay calculation:
let animationDelayFactor = 0.008; // Default for matrixtext1
if (element.classList.contains('matrixtext2')) animationDelayFactor = 0.02;
if (element.classList.contains('matrixtext3')) animationDelayFactor = 0.03;
if (element.classList.contains('matrixtext4')) animationDelayFactor = 0.05; // Add this line

Section Switching

The system works with modern section switching techniques using CSS:

section {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  z-index: -1000;
  pointer-events: none;
  transition: opacity 0.5s ease;
}

section.active {
  opacity: 1;
  z-index: 10;
  pointer-events: all;
}

Changing Container Class Name

  1. Update your HTML to use a new container class:
<div class="text-fx">
    <!-- Your animated elements -->
</div>
  1. Update the JavaScript selector:
const matrixGroups = document.querySelectorAll(".text-fx");

Changing Section Names

  1. Update HTML with your section class names:
<section class="intro-section active">
    <!-- content -->
</section>
  1. Update the JavaScript section checker:
function isInActiveSection(element) {
    const section = element.closest('.intro-section, .features-section, .team-section');
    if (!section) {
        return true; 
    }
    return section.classList.contains('active');
}
  1. Update the section observer:
const sectionsToWatch = document.querySelectorAll('.intro-section, .features-section, .team-section');

How It Works

The animation system:

  1. Initializes with:

    • IntersectionObserver to detect visible matrix groups
    • MutationObserver to detect when sections become active
  2. Processes elements by:

    • Storing original text
    • Wrapping each character in <span> tags with position indices
    • Initially hiding elements
  3. Triggers animations when:

    • A matrix group enters the viewport
    • The containing section has the active class
    • The animation hasn't already run
  4. Animates characters with:

    • Staggered delays based on character position
    • Smooth cubic-bezier animation curves
    • HTML restoration after animation completes
  5. Handles section changes by:

    • Detecting when sections become active
    • Finding matrix groups in newly active sections
    • Restarting animations when needed

Best Practices

  • Reserve animations for important content
  • Use shorter text for faster animations
  • Use longer text with slower animations
  • Mix different speeds for visual variety
  • Consider white-space: pre for text with many spaces
  • Use stall classes strategically
  • Time text animations to complement section transitions

Browser Compatibility

Works in all modern browsers with these features:

  • IntersectionObserver
  • MutationObserver
  • async/await
  • CSS Variables
  • Array spread syntax

May require polyfills for older browsers.

Certified ai slop that seems to work fine.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matrix Text Animation Demo</title>
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: 'Arial', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
height: 100%;
scroll-behavior: smooth;
}
/* Section styles */
section {
min-height: 100vh;
padding: 4rem 2rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 0.7;
transition: opacity 0.5s ease;
}
section.active {
opacity: 1;
}
/* Matrix text animation styles */
[class^="matrixtext"] {
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
/* backdrop-filter: blur(2px); */
}
[class^="matrixtext"].animate {
opacity: 1;
visibility: visible;
}
[class^="matrixtext"].animate span {
opacity: 0;
animation: charReveal 0.3s forwards;
animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.matrixtext1.animate span {
animation-delay: calc(var(--char-index) * 0.015s);
}
.matrixtext2.animate span {
animation-delay: calc(var(--char-index) * 0.02s);
}
.matrixtext3.animate span {
animation-delay: calc(var(--char-index) * 0.03s);
}
@keyframes charReveal {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Additional styling for the demo */
.container {
max-width: 1200px;
margin: 0 auto;
text-align: center;
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
}
h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1.2rem;
margin-bottom: 2rem;
max-width: 800px;
}
.matrix-group {
margin: 2rem 0;
}
.nav-buttons {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
gap: 1rem;
}
button {
padding: 0.5rem 1rem;
background-color: #333;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button:hover {
background-color: #555;
}
/* Color variations for sections */
.herosection {
background-color: #f0f8ff;
}
.contentsection {
background-color: #fff0f5;
}
.aboutmesection {
background-color: #f0fff0;
}
.gallerysection {
background-color: #fff8dc;
}
.blogsection {
background-color: #f5f5dc;
}
.contactsection {
background-color: #f0ffff;
}
</style>
</head>
<body>
<section class="herosection active">
<div class="container">
<div class="matrix-group">
<h1 class="matrixtext1">Matrix Text Animation</h1>
<p class="matrixtext2">Watch as text reveals itself character by character with a smooth animation.</p>
<p class="matrixtext3 stall">This text will wait a bit before animating.</p>
</div>
</div>
</section>
<section class="contentsection">
<div class="container">
<div class="matrix-group">
<h2 class="matrixtext1">Features</h2>
<p class="matrixtext2">Character-by-character reveal with customizable speeds</p>
<p class="matrixtext2 stall1">Configurable delay times (this has a 1-second delay)</p>
<p class="matrixtext3 stall2">Even longer delays are possible (this has a 2-second delay)</p>
</div>
</div>
</section>
<section class="aboutmesection">
<div class="container">
<div class="matrix-group">
<h2 class="matrixtext1">About</h2>
<p class="matrixtext2">This animation system works with intersection observers</p>
<p class="matrixtext3">Elements animate when they come into view</p>
</div>
</div>
</section>
<section class="gallerysection">
<div class="container">
<div class="matrix-group">
<h2 class="matrixtext1">Gallery</h2>
<p class="matrixtext2">Different sections can have different animation speeds</p>
<p class="matrixtext3">Try scrolling between sections to see animations trigger</p>
</div>
</div>
</section>
<section class="blogsection">
<div class="container">
<div class="matrix-group">
<h2 class="matrixtext1">Blog</h2>
<p class="matrixtext2">Animations only trigger when sections become active</p>
<p class="matrixtext3">This helps control when animations happen</p>
</div>
</div>
</section>
<section class="contactsection">
<div class="container">
<div class="matrix-group">
<h2 class="matrixtext1">Contact</h2>
<p class="matrixtext2">Text animations can create engaging user experiences</p>
<p class="matrixtext3">Perfect for revealing content dramatically</p>
</div>
</div>
</section>
<div class="nav-buttons">
<button id="prev-section">Previous Section</button>
<button id="next-section">Next Section</button>
</div>
<script>
// MatrixText class implementation
class MatrixText {
constructor(group) {
this.instanceId = Math.random().toString(36).substring(2, 9);
this.group = group;
this.elements = [...group.querySelectorAll('[class^="matrixtext"]')].sort(
(a, b) => {
const aNumMatch = a.className.match(/matrixtext(\d+)/);
const bNumMatch = b.className.match(/matrixtext(\d+)/);
const aNum = aNumMatch ? parseInt(aNumMatch[1]) : 0;
const bNum = bNumMatch ? parseInt(bNumMatch[1]) : 0;
return aNum - bNum;
}
);
// Store original content for each element
this.originalContents = new Map();
this.setupElements();
this.currentIndex = 0;
this.hasStarted = false;
this.isAnimating = false;
this.isComplete = false;
}
setupElements() {
this.elements.forEach((el) => {
const textContent = el.textContent;
// Store original content and tag name
this.originalContents.set(el, {
text: textContent,
className: el.className
});
el.innerHTML = "";
[...textContent].forEach((char, i) => {
const span = document.createElement("span");
span.textContent = char;
span.style.setProperty("--char-index", i);
el.appendChild(span);
});
// Ensure elements are hidden BEFORE first IntersectionObserver check
el.style.opacity = "0";
el.style.visibility = "hidden";
});
}
revertToOriginal(element) {
if (!this.originalContents.has(element)) return;
const original = this.originalContents.get(element);
const spans = element.querySelectorAll("span");
// Ensure all spans are fully visible first
spans.forEach(span => {
span.style.opacity = "1";
});
// Small delay to ensure all spans are visible before reverting
setTimeout(() => {
// Replace with original content in one operation to avoid flicker
element.innerHTML = original.text;
// Make sure we maintain the same class and visibility
element.className = original.className;
element.classList.add('animate'); // Keep the animate class
element.style.opacity = "1";
element.style.visibility = "visible";
}, 50);
}
async animateNext() {
if (this.isComplete) {
return;
}
if (this.currentIndex >= this.elements.length || this.isAnimating) {
if(this.currentIndex >= this.elements.length && !this.isComplete) {
this.isComplete = true;
}
return;
}
this.isAnimating = true;
const element = this.elements[this.currentIndex];
// Handle different stall durations
if (element.classList.contains("stall2")) {
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 seconds
} else if (element.classList.contains("stall1")) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 second
} else if (element.classList.contains("stall")) {
await new Promise((resolve) => setTimeout(resolve, 500)); // 0.5 seconds
}
element.style.setProperty("opacity", "1");
element.style.setProperty("visibility", "visible");
element.classList.add("animate");
const spans = element.querySelectorAll("span");
if (spans.length === 0) {
this.currentIndex++;
this.isAnimating = false;
if (this.currentIndex >= this.elements.length) {
this.isComplete = true;
} else {
this.animateNext();
}
return;
}
// Calculate proper animation duration based on the element class
let animationDelayFactor = 0.008; // Default for matrixtext1
if (element.classList.contains('matrixtext2')) animationDelayFactor = 0.02;
if (element.classList.contains('matrixtext3')) animationDelayFactor = 0.03;
// Calculate total animation time including the delay for the last character
const lastIndex = spans.length - 1;
const baseAnimationTime = 300; // 0.3s duration from CSS
const lastCharDelay = lastIndex * animationDelayFactor * 1000;
const totalAnimationTime = baseAnimationTime + lastCharDelay + 100; // Add small buffer
// Set up proper timeout that matches the CSS animation timing
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, totalAnimationTime));
// Animation end detection can still be included as a race
const animationEndPromise = new Promise((resolve) => {
const lastSpan = spans[lastIndex];
lastSpan.addEventListener("animationend", resolve, { once: true });
});
await Promise.race([animationEndPromise, timeoutPromise]);
// Revert to original element after animation completes
this.revertToOriginal(element);
this.currentIndex++;
this.isAnimating = false;
if (this.currentIndex >= this.elements.length) {
this.isComplete = true;
} else {
this.animateNext();
}
}
}
function isInActiveSection(element) {
const section = element.closest('.herosection, .contentsection, .aboutmesection, .gallerysection, .blogsection, .contactsection');
if (!section) {
return true;
}
return section.classList.contains('active');
}
let isInitialized = false;
const activeAnimations = new WeakMap();
function initializeMatrixAnimations() {
if (isInitialized) {
return;
}
isInitialized = true;
const intersectionObserverOptions = {
threshold: 0.1,
rootMargin: "0px 0px -5% 0px",
};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const group = entry.target;
let matrix = activeAnimations.get(group);
if (!matrix) {
matrix = new MatrixText(group);
activeAnimations.set(group, matrix);
}
if (entry.isIntersecting) {
if (!isInActiveSection(group)) {
return;
}
if (!matrix.hasStarted && !matrix.isAnimating && !matrix.isComplete) {
matrix.hasStarted = true;
matrix.animateNext();
}
}
});
},
intersectionObserverOptions
);
const matrixGroups = document.querySelectorAll(".matrix-group");
matrixGroups.forEach((group) => {
observer.observe(group);
});
const sectionObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const section = mutation.target;
if (section.classList.contains('active')) {
const groupsInActivatedSection = section.querySelectorAll('.matrix-group');
groupsInActivatedSection.forEach((group) => {
let matrix = activeAnimations.get(group);
if (!matrix) {
matrix = new MatrixText(group);
activeAnimations.set(group, matrix);
observer.observe(group);
} else if (!matrix.isComplete) {
observer.unobserve(group);
observer.observe(group);
}
});
}
}
});
});
const sectionsToWatch = document.querySelectorAll('.herosection, .contentsection, .aboutmesection, .gallerysection, .blogsection, .contactsection');
sectionsToWatch.forEach((section) => {
sectionObserver.observe(section, {
attributes: true,
attributeFilter: ['class'],
childList: false,
subtree: false
});
});
}
// Ensure DOM is fully ready before initializing
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeMatrixAnimations);
} else {
initializeMatrixAnimations();
}
// Navigation functionality for the demo
document.addEventListener('DOMContentLoaded', function() {
const sections = document.querySelectorAll('section');
const prevButton = document.getElementById('prev-section');
const nextButton = document.getElementById('next-section');
let currentSectionIndex = 0;
// Initialize first section as active
sections[0].classList.add('active');
function updateActiveSection() {
sections.forEach(section => section.classList.remove('active'));
sections[currentSectionIndex].classList.add('active');
sections[currentSectionIndex].scrollIntoView({ behavior: 'smooth' });
}
prevButton.addEventListener('click', function() {
if (currentSectionIndex > 0) {
currentSectionIndex--;
updateActiveSection();
}
});
nextButton.addEventListener('click', function() {
if (currentSectionIndex < sections.length - 1) {
currentSectionIndex++;
updateActiveSection();
}
});
// Handle scroll events to update active section
window.addEventListener('scroll', function() {
const scrollPosition = window.scrollY + window.innerHeight / 2;
sections.forEach((section, index) => {
const sectionTop = section.offsetTop;
const sectionBottom = sectionTop + section.offsetHeight;
if (scrollPosition >= sectionTop && scrollPosition < sectionBottom) {
if (currentSectionIndex !== index) {
currentSectionIndex = index;
sections.forEach(s => s.classList.remove('active'));
section.classList.add('active');
}
}
});
});
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment