Home > Software engineering >  Adding animation class to each <span>letter</span> after creation from an array of words
Adding animation class to each <span>letter</span> after creation from an array of words

Time:07-21

The goal is to animate each string value from the given array 'sampleArray', one letter at a time.

With the help of imvain2. I was able to create a timeout that would display each word at regular intervals. I've just managed to wrap each individual letter with spans of the class 'hidden'.

The next step is to add the class 'visible' to each letter (span) at a consistent interval. I'm very confused as to how these two functions would interact.

The two functions are.

  1. The creation of the div and spans with the letters.
  2. The timed adding of classes to each span

I have tried making the iife async and then creating an await call (outside the for loop) to another async function which adds the visible class to each span. This created an infinite loop. The cleanest code I have so far is the following.

Thanks for your help.

const wrapper = document.querySelector(".text-wrapper");
const buttonAnimate = document.querySelector(".animation");
buttonAnimate.addEventListener("click", animation);

let i = 0;
let j = 0;

let sampleArray = ["Just", "another", "cool", "heading"];

/*
sampleArray[i] === word
samleArray[i][j] === letter
*/

async function animation() {
  // debugger;
  console.log(`entry point: i is at ${i}`);
  if (i < sampleArray.length) {
    let word = document.createElement("div");
    // Add class to p element
    word.classList.add(`words`, `word${i}`);

    // Use immediately invoked function expression
    ((j, word) => {
      // Could I add a for loop here to update word.innerHTML?
      for (let j = 0; j < sampleArray[i].length; j  ) {
        // wrap each letter with span and give class 'hidden'
        {
          word.innerHTML  = `<span >${sampleArray[i][j]}</span>`;
        }
        // on timer give each span a new class of 'visible'. This method is preferable as I can use other animation effects
        // setTimeout(() => {word.innerHTML  = `<span >${sampleArray[i][j]}</span>`, 500 * j})
        console.log(word.innerHTML);
      }
    })(j, word);

    // Add text to screen
    wrapper.appendChild(word);
    console.log(word);
    i  ;
    console.log(`i is at ${i}`);
    setTimeout(animation, 1000);
  }
}
html {
  box-sizing: border-box;
}

*,*::before, *::after {
  box-sizing: inherit;
}

body {
  margin: 0;
}


.wrapper {
  width: 100%;
  background-color: #333;
  color: #FFA;
  text-align: center;
  height: 100vh;
  display: flex;
  flex-direction: row;
  gap: 10rem;
  align-items: center;
  justify-content: center;
  font-size: 4rem;
  position: relative;
}

.text-wrapper {
  position: absolute;
  top: 0;
  width: 100%;
  display: flex;
  gap: 3rem;
  flex-direction: column;
  justify-content: center;
  justify-content: space-around;
}

.button {
  font-size: 3rem;
  border-radius: 6px;
  background-color: #47cefa;
}

.button:hover {
  cursor: pointer;
  background-color: #BCEF4D;
}

/* Opacity will be set to zero when I am able to update the dom to include spans of class 'letter' */
.words {
  opacity: 1;
}

.hidden {
  opacity: 1;
}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="style.css">
  <title>Create Element</title>
</head>
<body>
  <section >
    <button >Start Animation</button>
    <div ></div>
  </section>
  <script type="module" src="./main.js"></script>
</body>
</html>

CodePudding user response:

In creating this code, one of the biggest concepts I took away from it is how to structure a script so that it can make two setTimeouts synchronous.. Here is the stackoverflow that helped a lot. Javascript - making 2 setInterval() be synchronous

I also learnt that iifes could be quite helpful within for loops and that in for loops you want a timeout not an interval usually..

const wrapper = document.querySelector(".text-wrapper");
const buttonAnimate = document.querySelector(".animation");
buttonAnimate.addEventListener("click", animation);

let i = 0;
let j = 0;

let sampleArray = ["Ab", "CB", "CD", "eD"];

async function animation() {
  // debugger;
  console.log(`entry point: i is at ${i}`);

  if (i < sampleArray.length) {
    // Create wordWrapper
    let wordWrapper = document.createElement("div");

    // Add classes to word wrapper
    wordWrapper.classList.add(`words`, `word${i}`);

    // append wordwrapper to text-wrapper (in dom)
    wrapper.appendChild(wordWrapper);

    // iife async function
    (async (j, wordWrapper) => {
      // loop through sampleArray
      for (let j = 0; j < sampleArray[i].length; j  ) {
        // for each cycle update wordWrapper html
        wordWrapper.innerHTML  = `<span >${sampleArray[i][j]}</span>`;
      }
    })(j, wordWrapper);

    // Call timer function one time after 1 second
    let timerId = setTimeout(timer, 1000, wordWrapper, i, j);

    if (j === sampleArray[i].length) {
      return () => {
        clearTimeout(timerId);
      };
    }

    i  ;

    console.log(`exit point - i is at ${i}`);
  }

  async function timer(wordWrapper, i, j) {
    console.log("timer variable i equals", i);
    // debugger;
    let spanArr = Array.from(wordWrapper.children);


    // house setDelay func in each loop
    for (let k = 0; k < spanArr.length; k  ) {
      setDelay(k);
    }


    // setDelay contains setTimeout()

    function setDelay(index) {
      const letterTimeout = setTimeout(() => {
        spanArr[index].classList.add("visible");
        console.log(spanArr[index]);
      }, index * 1000);


      // clear id
      if (index == spanArr.length) {
        () => {
          clearTimeout(letterTimeout);
        };
      }
    }



    // After letterTimeout is cleared wait one second and repeat animation function
    let animationRegulator = setTimeout(animation, 1000);

    
    // Escape animation
    if (i === sampleArray.length) {
      stopAnimation(animationRegulator);
    }
    function stopAnimation() {
      console.log(`i = ${i} and sampleArray = ${sampleArray.length}`);
      clearTimeout(animationRegulator);
    }
  }
}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="style.css">
  <title>Create Element</title>
</head>
<body>
  <section >
    <button >Start Animation</button>
    <div ></div>
  </section>
  <script type="module" src="./main.js"></script>
</body>
</html>

CodePudding user response:

Typewriter effect

In a typical typewriter effect, a text appears letter by letter. There are many ways to accomplish this, both in CSS-only and JS. CSS-only example:

@keyframes typewriter-effect {
  from {width: 0}
}
.typewriter {
  display: inline-block;
  white-space: nowrap;
  overflow: hidden;
  font-family: monospace;
}

/* #example's text is 43 characters long */
#example.typewriter {
  width: 43ch;
  animation-name: typewriter-effect;
  animation-duration: 3s;
  animation-timing-function: steps(43);
}
<span id="example" >This text will appear in typewriter effect.</span>

Simple CSS-only solutions (as the above) are less flexible than JS solutions, because:

  • You have to use a monospace font, because you cannot split a text with irregular wide characters into even steps.
  • You have to hard-code values (the text length) into your stylesheet, or use inline styling. Either way, this is technically redundant information.
  • CSS-only solutions do not (usually) work for multi-line texts.
  • You have to use a block element (display: block or similar), otherwise you cannot change the property width.

JS solutions do not have the above issues. Additionally, there (probably) exist various libraries to achieve the typewriter effect via a simple function call.

With your specification

As per your question, you want:

  • Your text to be block-level elements, one for each word.
  • Your word-elements to have inline-level elements, one for each letter.

This generation of elements can happen on the server-side, or on the client-side. On the client-side, we can generate them before the effect starts (this is your way), or once the page has loaded.

Preparing the typewriter

Generating the elements when the page has loaded allows us to let the "animation" function be exactly this: A function that animates; not a function that generates and animates.

const elWords = document.getElementById("words");

const words = ["Just", "another", "cool", "heading"];

prepareTypewriter(elWords, words);

function prepareTypewriter(elWrapper, words) {
  elWrapper.replaceChildren(); // Clear the wrapper of all elements

  for (let wordIndex = 0; wordIndex < words.length;   wordIndex) {
    const elWord = document.createElement("div");
    elWord.classList.add("word"); // No need for `word${i}`; use :nth-child() selector
    elWrapper.append(elWord, " ");

    const word = words[wordIndex];
    for (let letterIndex = 0; letterIndex < word.length;   letterIndex) {
      const elLetter = document.createElement("span");
      elLetter.setAttribute("hidden", "");
      elLetter.classList.add("letter"); // No need for `letter${j}`; use :nth-child() selector
      elLetter.textContent = word[letterIndex];
      elWord.append(elLetter);
    }
  }
}
<div id="words"></div>

Side note: Insert text with .textContent (or .innerText); inserting HTML-like tokens with .innerHTML may cause unintended issues:

const wrapper = document.getElementById("wrapper");
const lines = [
  "Do not",
  "use HTML-like tokens",
  "like <p in your texts",
  "when using .innerHTML."
];
for (const line of lines) {
  wrapper.innerHTML  = `<span>${line}</span> `;
}
<div id="wrapper"></div>
<p>The browser will try to fix the invalid markup.</p>

The effect

Simply put, we want to show each letter with a delay. To do this, we would have to:

  • Keep track of the current and next letter.
  • Keep track of the current and next word.
  • Show the next letter after the current letter has been shown with some delay, repeatedly.

To halt execution for a specific amount of time, use either the setTimeout or the setInterval function. Here is an implementation with setTimeout:

const elWords = document.getElementById("words");
document.querySelector("button")
  .addEventListener("click", () => startTypewriter(elWords));

const words = ["Just", "another", "cool", "heading"];
prepareTypewriter(elWords, words);

function startTypewriter(elWrapper) { // Added for readability
  nextTyping(elWrapper, 0, 0);
}
function nextTyping(elWrapper, wordIndex, letterIndex) {
  const elWord = elWrapper.children[wordIndex];
  const elLetter = elWord.children[letterIndex];
  
  elLetter.removeAttribute("hidden"); // Show specified letter
  
  // Find next letter to show
  if (elLetter.nextElementSibling) {
    setTimeout(nextTyping, 200, elWrapper, wordIndex, letterIndex   1);
  } else if (elWord.nextElementSibling) {
    setTimeout(nextTyping, 300, elWrapper, wordIndex   1, 0);
  }
}
function prepareTypewriter(elWrapper, words) {
  elWrapper.replaceChildren();
  
  for (let wordIndex = 0; wordIndex < words.length;   wordIndex) {
    const elWord = document.createElement("div");
    elWord.classList.add("word");
    elWrapper.append(elWord, " ");

    const word = words[wordIndex];
    for (let letterIndex = 0; letterIndex < word.length;   letterIndex) {
      const elLetter = document.createElement("span");
      elLetter.setAttribute("hidden", "");
      elLetter.classList.add("letter");
      elLetter.textContent = word[letterIndex];
      elWord.append(elLetter);
    }
  }
}
<button>Start effect</button>
<div id="words"></div>

The function nextTyping is called for each animation step. The parameters wordIndex and letterIndex keep track of the current word and letter.

After each animation step, nextTyping finds the next letter to show, and calls setTimeout with the appropriate arguments. The next letter is either:

  • The current word's next letter.
  • The next word's first letter.

Side note: This implementation will try to show each letter on button click, as if the letters were still hidden. Your implementation simply returns because it knows that it has already revealed them.
Try adding such a check (also called "guard clause") as an exercise!

With async/await

Promises were first introduced in ES6, along with the keywords async and await as syntactic sugar.

Promises allow (a)synchronous code to be handled in a functional way, which is arguably more readable than "callback hell".
async/await allow promises to be handled in an imperative way, which many people are arguably more used to.

Declaring a function as async allows the use of the keyword await, which pauses execution of the async function until the awaited expression is settled.

The above implementation (without promises) is more similar to recursion, but with async/await we can provide a more iterative implementation:

const elWords = document.getElementById("words");
document.querySelector("button")
  .addEventListener("click", () => startTypewriter(elWords));

const words = ["Just", "another", "cool", "heading"];
prepareTypewriter(elWords, words);

async function startTypewriter(elWrapper) {
  for (const elWord of elWrapper.children) {
    for (const elLetter of elWord.children) {
      elLetter.removeAttribute("hidden");
      
      if (elLetter.nextElementSibling) {
        await sleep(200);
      }
    }
    
    // If-statement prevents unnecessary sleep after last word
    if (elWord.nextElementSibling) {
      await sleep(300);
    }
  }
}
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
function prepareTypewriter(elWrapper, words) {
  elWrapper.replaceChildren();
  
  for (let wordIndex = 0; wordIndex < words.length;   wordIndex) {
    const elWord = document.createElement("div");
    elWord.classList.add("word");
    elWrapper.append(elWord, " ");

    const word = words[wordIndex];
    for (let letterIndex = 0; letterIndex < word.length;   letterIndex) {
      const elLetter = document.createElement("span");
      elLetter.setAttribute("hidden", "");
      elLetter.classList.add("letter");
      elLetter.textContent = word[letterIndex];
      elWord.append(elLetter);
    }
  }
}
<button>Start effect</button>
<div id="words"></div>

Both ways (with or without promises / async/await) are obviously correct, and which way to prefer is subjective.

Accessibility considerations

Having the typewriter text in JS as opposed to in the HTML is not progressively enhancing. This means that search engines and people with JS disabled will (potentially) be oblivious of the typewriter text. Whether to follow the practice of progressive enhancement is subjective.

Assistive technology may not be aware of updating content when the content is not in a live-region. You should consider:

  • Is the content notable enough to be placed in a live-region?
  • Is the content notable enough to update the Accessibility Object Model (AOM) for each letter's reveal?
  • (Regarding progressive enhancement:) Is the content notable enough to be hidden by default (where default means without executing the JS)?

Further, since the typewriter effect is visual:

  • Would you consider the effect as potentially distracting for people who prefer reduced motion?
  • How do you want non-sighted visitors to experience the typewriter effect?
  • Should the typewriter be focussed by default? (I personally discourage this.)

On interest, I suggest these:

With my specification

In this implementation I considered:

  • Markup: Only elements for the typewriter effect; no elements for individual words or letters.
  • Progressive enhancement: Show typewriter text by default; hide when JS is executed.
  • Accessibility:
    • If typewriter effect starts, reveal full text to AOM.
    • No changes on prefers-reduced-motion: For how it is used, I do not consider it too distracting.

// Initially hidden (to assistive technology as well)
for (const el of document.querySelectorAll(".typewriter")) {
  el.dataset.text = el.textContent;
  el.textContent = "";
}

document.querySelector("button").addEventListener("click", () => {
  startTypewriterOf("#text-1")
    .then(() => startTypewriterOf("#text-2"));
});

function startTypewriterOf(query) {
  const elTypewriter = document.querySelector(query);
  return startTypewriter(elTypewriter);
}
async function startTypewriter(elTypewriter) {
  const text = elTypewriter.dataset.text;
  delete elTypewriter.dataset.text;
  
  elTypewriter.setAttribute("aria-label", text); // Reveal immediately to assistive technology
  
  // Reveal slowly visually
  for (const word of text.split(/\s/)) {
    for (const letter of word) {
      elTypewriter.textContent  = letter;
      await sleep(80);
    }
    elTypewriter.textContent  = " ";
    await sleep(100);
  }
}
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
<button>Start animation</button>
<div id="text-1" >Some sample text.</div>
<div id="text-2" >Some more sample text.</div>

Upon page load, the texts of .typewriter elements are cached in a custom data attribute (data-text). This hides the text both visually and to assistive technology.

When the typewriter effect starts, the full text is immediately revealed to the AOM via aria-label. The visual text will be revealed letter-by-letter.

Side note: Using aria-label for replacing the actual content (of a non-interactive element) may be considered misuse of it, especially when it is redundant after the effect has finished.

  • Related