DEV Community

Cover image for Making a Text Scramble Animation with JavaScript
Cruip
Cruip

Posted on • Originally published at cruip.com

Making a Text Scramble Animation with JavaScript

Live Demo / Download

The text scramble effect is a cool animation that rapidly unveils text by randomly changing characters - just like those scenes in movies where hackers decode strings of text! Inspired by Evervault's blog, we'll make a navigation menu with that kind of effect when you click on links. Plus, we'll give you both light and dark versions of the menu, so you can integrate this example into any of our Tailwind templates.

Getting started with the HTML

To get started, we can define the structure of our navigation menu in HTML. We'll create a <nav> element that contains a list of links. And for styling, we'll make use of Tailwind CSS:

<nav class="text-sm flex flex-col space-y-2" role="navigation">
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">General</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">Work Preferences</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">Integrations</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">Billing</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">Subscription</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">Security</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5">Data Sources</a>
</nav>
Enter fullscreen mode Exit fullscreen mode

Creating a JS class for the animation

We'll create a JavaScript class, just as we use to do in our tutorials. This way, we can easily apply the animation to various elements within the page. So, create a file called text-scramble.js and include it in the HTML document, just before the closing </body> tag:

<script src="./text-scramble.js"></script>
Enter fullscreen mode Exit fullscreen mode

Now, in the JS file, we're going to create a class called TextScramble. Inside this class there will be an init method that initializes the animation:

class TextScramble {
  constructor(element) {
    this.element = element;
    this.links = Array.from(this.element.querySelectorAll('a'));
    this.init();
  }

  init = () => {
    this.links.forEach(link => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        // Run the scramble animation
      });
    });
  }
}

// Init TextScramble
const scrambleElements = document.querySelectorAll('[data-text-scramble]');
scrambleElements.forEach((element) => {
  new TextScramble(element);
});
Enter fullscreen mode Exit fullscreen mode

To use the TextScramble class, simply add the data-text-scramble attribute to the navigation menu:

<nav class="text-sm flex flex-col space-y-2" role="navigation" data-text-scramble>
Enter fullscreen mode Exit fullscreen mode

Next, the class selects all the links within <nav> and triggers the animation when each link is clicked.

Managing the active link

Now, in our HTML, mark the first link with the attribute aria-current="page". This attribute informs the browser which link is currently "active" and allows you to apply specific styles tto differentiate it from the other links.

<nav class="text-sm flex flex-col space-y-2" role="navigation">
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400" aria-current="page">General</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400">Work Preferences</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400">Integrations</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400">Billing</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400">Subscription</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400">Security</a>
    <a href="#0" class="relative font-medium text-slate-500 hover:text-slate-600 py-0.5 aria-[current]:text-indigo-500 before:absolute before:top-0 before:-left-5 before:h-full before:w-0.5 aria-[current]:before:bg-indigo-400">Data Sources</a>
</nav>
Enter fullscreen mode Exit fullscreen mode

To make the active link more prominent, we used the aria-[current]: prefix to give it a different color (aria-[current]:text-indigo-500). We also added a vertical line to the left of each link using the ::before pseudo-element. By default, this line is transparent, but it turns indigo when the link is active (aria-[current]:before:bg-indigo-400). Great! Now let's take care of the active link while navigating.

class TextScramble {
  constructor(element) {
    this.element = element;
    this.links = Array.from(this.element.querySelectorAll('a'));
    this.activeLink = this.links.find(link => link.getAttribute('aria-current') === 'page');
    this.init();
  }

  handleActiveLink = (link) => {
    if (this.activeLink) {
      this.activeLink.removeAttribute('aria-current');
    }
    this.activeLink = link;
    this.activeLink.setAttribute('aria-current', 'page');
  }  

  init = () => {
    this.links.forEach(link => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        if (link === this.activeLink) return;
        this.handleActiveLink(link);
      });
    });
  }
}

// Init TextScramble
const scrambleElements = document.querySelectorAll('[data-text-scramble]');
scrambleElements.forEach((element) => {
  new TextScramble(element);
});
Enter fullscreen mode Exit fullscreen mode

The key steps we took are as follows:

  • We added an activeLink property to the class, which finds the active link within the links array.
  • Then we created the handleActiveLink method, whose task is updating the active link whenever a menu link is clicked. It removes the aria-current attribute from the active link, sets the new active link, and assigns it the aria-current="page" attribute.
  • Lastly, we added a condition inside the init method to check if the clicked link is already active - if (link === this.activeLink). If it is, the init method's execution is halted with a return. Otherwise, we call the handleActiveLink method to update the active link.

Handling the scramble effect

Now comes the more intricate part. We'll introduce a couple of new properties and methods to handle the shuffle effect.

class TextScramble {
  constructor(element) {
    this.element = element;
    this.links = Array.from(this.element.querySelectorAll('a'));
    this.activeLink = this.links.find(link => link.getAttribute('aria-current') === 'page');
    this.isScrambling = false;
    this.intervalId = null;    
    this.init();
  }

  handleActiveLink = (link) => {
    if (this.activeLink) {
      this.activeLink.removeAttribute('aria-current');
    }
    this.activeLink = link;
    this.activeLink.setAttribute('aria-current', 'page');
  }

  startScramble = (link) => {
    if (this.isScrambling) {
      this.stopScramble(this.activeLink);
    }
    this.isScrambling = true;
    this.intervalId = setInterval(() => {
      // Do stuff...
      console.log('Scrambling...');
    }, 50);
  }

  stopScramble = (link) => {
    this.isScrambling = false;
    clearInterval(this.intervalId);
  }  

  init = () => {
    this.links.forEach(link => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        if (link === this.activeLink) return;
        this.startScramble(link);
        this.handleActiveLink(link);
      });
    });
  }
}

// Init TextScramble
const scrambleElements = document.querySelectorAll('[data-text-scramble]');
scrambleElements.forEach((element) => {
  new TextScramble(element);
});
Enter fullscreen mode Exit fullscreen mode

Let's begin with the newly added properties:

  • The boolean property isScrambling indicates whether the animation is currently in progress. By default, it is set to false and changes to true when the animation starts. This property is useful for stopping the ongoing animation when a different link is clicked.
  • The variable intervalId is used to store the ID of the interval used for the animation. By saving the interval ID, we can later clear the interval using clearInterval(this.intervalId) when the animation is completed or aborted.

Now, the new methods:

  • The method startScramble is responsible for initiating the animation. It is called when a menu link is clicked. This method first checks if the animation is already in progress and stops it if necessary. Then, it sets the isScrambling property to true and starts a time interval that executes a function every 50 milliseconds - currently, this function is empty, but we will fill it in shortly.
  • The method stopScramble is used to stop the animation. It is called when the animation is completed or aborted. This method sets the isScrambling property to false and stops the time interval by using clearInterval(this.intervalId).

Handling prefix and suffix

First, we'll create a property called this.input; it will store the text of the link that is going to be animated. Next, we'll add two more properties: this.prefix and this.suffix. These properties will be used to replace the link text during the animation. Let's take a practical example to understand how it works. Suppose you click on a link with the text "General" (i.e. this.input). The animation will go through the following iterations:

  1. ""(this.prefix) + "spfjert" (this.suffix)
  2. "G" (this.prefix) + "pfjfqr" (this.suffix)
  3. "Ge" (this.prefix) + "jdqll" (this.suffix)
  4. "Gen" (this.prefix) + "rmnb" (this.suffix)
  5. "Gene" (this.prefix) + "swt" (this.suffix)
  6. "Gener" (this.prefix) + "oe" (this.suffix)
  7. "Genera" (this.prefix) + "z" (this.suffix)
  8. "General" (this.prefix) + "" (this.suffix)

At the beginning, the prefix is an empty string, while the suffix consists of random characters. In each iteration, the prefix will be the same as the first letter of this.input, and the suffix will contain all the subsequent characters. This pattern continues until the prefix matches this.input, and the suffix becomes an empty string. Now, let's see how we can integrate this logic into our class.

class TextScramble {
  constructor(element) {
    this.element = element;
    this.links = Array.from(this.element.querySelectorAll('a'));
    this.activeLink = this.links.find(link => link.getAttribute('aria-current') === 'page');
    this.input = null;
    this.prefix = '';
    this.suffix = '';
    this.isScrambling = false;
    this.intervalId = null;    
    this.init();
  }

  handleActiveLink = (link) => {
    if (this.activeLink) {
      this.activeLink.removeAttribute('aria-current');
    }
    this.activeLink = link;
    this.activeLink.setAttribute('aria-current', 'page');
  }

  startScramble = (link) => {
    if (this.isScrambling) {
      this.stopScramble(this.activeLink);
    }
    this.isScrambling = true;
    this.input = link.textContent;
    this.prefix = '';
    this.suffix = 'xxx';    
    this.intervalId = setInterval(() => this.scrambleIteration(link), 50);
  }

  scrambleIteration = (link) => {
    let nextChar = this.input.charAt(this.prefix.length);
    if (nextChar === '') {
      this.stopScramble(link);
    } else {
      this.prefix += nextChar;
      this.suffix = 'xxx';
      link.textContent = this.prefix;
      link.setAttribute('data-scramble-suffix', this.suffix);
    }
  }  

  stopScramble = (link) => {
    link.textContent = this.input;
    link.setAttribute('data-scramble-suffix', '');
    this.isScrambling = false;
    clearInterval(this.intervalId);
  }

  init = () => {
    this.links.forEach(link => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        if (link === this.activeLink) return;
        this.startScramble(link);
        this.handleActiveLink(link);
      });
    });
  }
}

// Init TextScramble
const scrambleElements = document.querySelectorAll('[data-text-scramble]');
scrambleElements.forEach((element) => {
  new TextScramble(element);
});
Enter fullscreen mode Exit fullscreen mode

In addition to adding this.input, this.prefix, and this.suffix, we also integrated the startScramble and stopScramble methods to update the values of this.prefix and this.suffix with each iteration. Moreover, while scrambling, the scrambleIteration method appends a data-scramble-suffix attribute to the link, which contains the value of the this.suffix property. This attribute will be used to display the suffix using the link's ::after pseudo-element - with Tailwind, all you have to do is add the class after:content-[attr(data-scramble-suffix)] to all menu links.

Generating random characters

The last step is to generate the appropriate length of random characters for the suffix. To do this, we'll create a method called randomChars returning a string of random letters:

randomChars = (length) => {
  let result = '';
  for (let i = 0; i < length; i++) {
    const randomIndex = Math.floor(Math.random() * this.charsetLength);
    result += `${this.charset[randomIndex]}`;
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Now, we just need to use this method in the startScramble and scrambleIteration methods. So, the final code for the class will look like this:

class TextScramble {
  constructor(element) {
    this.element = element;
    this.links = Array.from(this.element.querySelectorAll('a'));
    this.activeLink = this.links.find(link => link.getAttribute('aria-current') === 'page');
    this.input = null;
    this.prefix = '';
    this.suffix = '';
    this.isScrambling = false;
    this.intervalId = null;
    this.charset = 'abcdefghijklmnopqrstuvwxyz';
    this.charsetLength = this.charset.length;
    this.scrambleIteration = this.scrambleIteration.bind(this);
    this.init();
  }

  handleActiveLink = (link) => {
    if (this.activeLink) {
      this.activeLink.removeAttribute('aria-current');
    }
    this.activeLink = link;
    this.activeLink.setAttribute('aria-current', 'page');
  }

  startScramble = (link) => {
    if (this.isScrambling) {
      this.stopScramble(this.activeLink);
    }
    this.isScrambling = true;
    this.input = link.textContent;
    this.prefix = '';
    this.suffix = this.randomChars(this.input.length);
    this.intervalId = setInterval(() => this.scrambleIteration(link), 50);
  }

  scrambleIteration = (link) => {
    let nextChar = this.input.charAt(this.prefix.length);
    if (nextChar === '') {
      this.stopScramble(link);
    } else {
      this.prefix += nextChar;
      this.suffix = this.randomChars(this.input.length - this.prefix.length);
      link.textContent = this.prefix;
      link.setAttribute('data-scramble-suffix', this.suffix);
    }
  }

  stopScramble = (link) => {
    link.textContent = this.input;
    link.setAttribute('data-scramble-suffix', '');
    this.isScrambling = false;
    clearInterval(this.intervalId);
  }

  randomChars = (length) => {
    let result = '';
    for (let i = 0; i < length; i++) {
      const randomIndex = Math.floor(Math.random() * this.charsetLength);
      result += `${this.charset[randomIndex]}`;
    }
    return result;
  }

  init = () => {
    this.links.forEach(link => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        if (link === this.activeLink) return;
        this.startScramble(link);
        this.handleActiveLink(link);
      });
    });
  }
}

// Init TextScramble
const scrambleElements = document.querySelectorAll('[data-text-scramble]');
scrambleElements.forEach((element) => {
  new TextScramble(element);
});
Enter fullscreen mode Exit fullscreen mode

Conclusions

Creating appealing effects and animations that don't compromise a website's usability can be challenging. In this tutorial, we tried to add a bit of fun to the menu interaction without hurting the overall experience. Did we achieve our goal? Reach out and let us know what you think!

Top comments (0)