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.
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 class="hidden">${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 class="hidden visible">${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 class="wrapper">
<button class="button animation">Start Animation</button>
<div class="text-wrapper"></div>
</section>
<script type="module" src="./main.js"></script>
</body>
</html>
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" class="typewriter">This text will appear in typewriter effect.</span>
Simple CSS-only solutions (as the above) are less flexible than JS solutions, because:
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.
As per your question, you want:
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.
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>
Simply put, we want to show each letter with a delay. To do this, we would have to:
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:
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!
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.
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:
Further, since the typewriter effect is visual:
On interest, I suggest these:
In this implementation I considered:
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" class="typewriter">Some sample text.</div>
<div id="text-2" class="typewriter">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.