In the previous article, I added some simple CSS animations to show how the separation of my assets works. Now that that is en place, let's dive into JavaScript a bit more and create an even better typewriter animation based on your input !


The HTML (inside Grav markdown)

To make our CSS and JavaScript interact with this blog page, we need to set up some HTML elements, as I use the Grav content editor I need to type in some particular elements.

<span class="typewriter-input-title">Add some input !</span>
<label for="typewriter-input-data">Input</label>
<textarea id='typewriter-input-data'>Default input data</textarea>

<label for="delay-input">Delay <small>(ms)</small></label>
<input type="number" id="delay-input" name="speed" value="50"/>

<br>
<button class="type-btn" id="type-btn">Type !</button>
<button class="type-btn" id="cancel-btn" disabled>Cancel</button>
<div id="typewriter-errors" class="banner"></div>
<div id="typewriter-output" class="typewriter-output"></div>

I now have :

  • A textarea for the input
  • A number input for the delay
  • A type button to trigger the JavaScript
  • A cancel button to stop the typing
  • An output div

TypeScript

First, I'll get all the elements I need :

export default class Typewriter {
    private typeWriterInput: HTMLTextAreaElement
    private typeWriterOutput: HTMLElement
    private typeButton: HTMLButtonElement;
    private delayInput: HTMLInputElement;
    private errorElement: HTMLUListElement;
    private cancelButton: HTMLButtonElement;

    constructor() {
        this.typeWriterInput = document.getElementById('typewriter-input-data') as HTMLTextAreaElement
        this.typeWriterOutput = document.getElementById('typewriter-output') as HTMLElement
        this.typeButton = document.getElementById('type-btn') as HTMLButtonElement
        this.cancelButton = document.getElementById('cancel-btn') as HTMLButtonElement
        this.delayInput = document.getElementById('delay-input') as HTMLInputElement
        this.errorElement = document.getElementById('typewriter-errors') as HTMLUListElement
    }
}

Next we need to get something happening as soon as the Type ! button is clicked, in the constructor I added the event listener that will call another function of my class, typeEvent :

export default class Typewriter {
    constructor() {
//...
        this.typeButton.addEventListener('click', () => {
            this.typeEvent()
        })
//...
    }

    typeEvent() {
        this.resetTypewriter()
        let delay = <any>this.delayInput.value as number;
        let errors: string[] = [];
        if (delay < 10) {
            errors.push("Delay has to be 10 or more")
        }

        const text = this.typeWriterInput.value
        if(text.length < 4) {
            errors.push("Add more than 3 characters to the text input")
        }

       if (errors.length !== 0) {
            this.errorElement.classList.add('error')
            this.errorElement.innerHTML = '';
            let errorList = document.createElement('ul')
            this.errorElement.appendChild(errorList);
            errors.forEach((error: string) => {
                let errorSpan = document.createElement('li')
                errorSpan.innerHTML = error;
                errorList.appendChild(errorSpan);
            })
            return;
        }

        this.typeWriterOutput.innerHTML = '';
        this.disableTypewriter();
        this.JSTypewriter(text, delay);
    }

    //...

    enableTypewriter() 
    {
        this.typeButton.disabled = false
        this.typeWriterInput.disabled = false
        this.cancelButton.disabled = true
    }

    disableTypewriter() {
        this.typeButton.disabled = true
        this.cancelButton.disabled = false
        this.typeWriterInput.disabled = true
    }

    resetTypewriter() {
        this.errorElement.classList.remove('error')
        this.typeWriterOutput.innerHTML = ''
        this.typeWriterOutput.classList.remove('typewriter-output-blinker')
    }
}

It will use the resetTypewriter function to make sure everything is reset on each new click. It will get the values from the textArea, and the number input, and check if the values are correct. In case at least one of them is wrong an error will be added, the errors will be displayed and none of the typing will be done.

If everything is correct, the previous typed text will get removed and the inputs as well as the type button disabled whilst the cancel button will become available.

Next the JSTypewriter will be triggered, this is what is responsible for typing out one character at a time.

setTimeout

In order to get this delay between the added characters, I used the setTimeout function. It took a bit of getting used to in this situation. When looking at some W3schools documentation they add the next char every time the function gets called after the setTimeout and increase a counter variable (i).

Here in my class, I did not want to add a property just for this. This is why my JSTypewriter has everything it needs in the parameters.

However, when I tried to call the class whilst passing parameters inside the setTimeout function, it crashed. It turns out to be able to pass on the parameters you need to create a clone of the function and bind the parameters to it.

This now worked, and I added the if statement to re-enable the typewriter once the text was finished.

There was one more small problem, the line breaks would not be added. This is because adding to the innerHTML of an element can only add HTML elements, so single characters or elements. This is why I added a char = char.replace(/\r?\n/g, '<br />'); to make sure a line break would be changed into an HTML <br /> element.

I wanted to be able to stop the typing while it was happening, so to do that I added a NodeJS.Timeout property to my class, here is the code :

export default class Typewriter {
    //...
    private typewriterTimer?: NodeJS.Timeout;

    JSTypewriter(text: string, delay: number, count: number = 0) {
        if (text.length > count) {
            this.typeWriterOutput.classList.add('typewriter-output-blinker')
            let char = text.charAt(count);
            char = char.replace(/\r?\n/g, '<br />');
            this.typeWriterOutput.innerHTML += char
            count++
            this.typewriterTimer = setTimeout(this.JSTypewriter.bind(this, text, delay, count), delay)
        } else {
            this.enableTypewriter()
        }
    }
}

Now to add the ability to stop the typing, I added a listener to the Cancel button and used the previously set NodeJS.Timeout parameter to stop it.

export default class Typewriter {
    constructor() {
        //...
        this.cancelButton.addEventListener('click', (event) => {
            if(this.typewriterTimer) {
                clearTimeout(this.typewriterTimer)
            }
            this.resetTypewriter()
            this.enableTypewriter()
        })
    }
}

This clearTimeout function will stop the setTimeout and then we will reset the typewriter.

A bit of style, SCSS

Now to make thing look a bit nicer some SCSS had to be added :

@keyframes blink-char {
  from, to { color: transparent }
  50% { color: inherit}
}

.retype-btn, .type-btn {
  border-radius: 5px;
  padding: 0.6rem 1.2rem;
  cursor: pointer;
  background-color: $darkmode-bg-color;
  color: $primary-color;
  margin-bottom: 2rem;
  transition: color 200ms ease-in-out,
  background-color 200ms ease-out;
;
  &:hover {
    color: $darkmode-bg-color;
    background-color: $primary-color;
  }
}
.animations-post {
  textarea, input {
    margin-top: 0.5rem;
    &:focus, &:focus-visible {
      box-shadow: 0 0 5px $primary-color-dark;
      border: 1px $bg-color-extra-dark solid;
    }
  }

  label {
    margin: 1rem;
  }
  button {
    &:disabled {
      background-color: $bg-color-dark;
      color: $primary-color-extra-dark;
    }
  }
}

.typewriter-output-blinker {
  &::after {
    color: rgba(0,0,0,0.8);
    content: '_';
    font-weight: 1000;
    margin-left: 0.2rem;
    animation: blink-char 0.75s step-end infinite;
  }
}

.typewriter-output {
  border-bottom: 2px solid $bg-color-extra-dark;
}

Most of this is pretty simple and uses my _variables.scss file to get the correct colors. The most important part is the blinking character et the end of the output text as it is being typed. This where the .typewriter-output-blinker::after comes in play. The content is set to empty but, it has a border-left and an animation for this border property.

As I did not want this to blink whilst nothing is being typed, the TypeScript file adds and removes this class when necessary.

Finally I wanted to make it look like some old paper so I used the CSS I found in this CodePen and, added a nice font-family I found on dafont. I used cloudConvert to get a woff2 font type from the original TFF.

To add this font I added a SCSS Mixin

//_fonts.scss
@mixin font($font-family, $font-file) {
    @font-face {
        font-family: $font-family;
        src: url('../fonts/' + $font-file + '.woff2') format('woff2'),
        url('../fonts/' + $font-file + '.ttf') format('truetype');
        font-weight: normal;
        font-style: normal;
    }
}

In the SCSS file for my typewriter I added the font :

@use 'fonts' as *;

@include font('ATWriter', 'atwriter/atwriter');

//...

.typewriter-output {
  font-family: ATWriter, sans-serif;
}

The last thing that needed to be done was to make sure that the SCSS files would be able to access the fonts. To do so I copied my downloaded fonts in {{themeName}}/fonts/atwriter. This directory now contains:

  • atwriter.tff
  • atwriter.woff2

To copy them into {{themeName}}/build/fonts/atwriter I added another webpack configuration:

const CopyFiles = Encore
        .setOutputPath(path.join(BUILD_PATH_BASE))
        .setPublicPath(path.join('/', BUILD_PATH_BASE))
        .disableSingleRuntimeChunk()
        .copyFiles({
                from: path.join(PATH, 'fonts'),
                to: 'fonts/[path][name].[ext]',
        })
        .getWebpackConfig()

CopyFiles.name = 'CopyFiles'

Encore.reset();

//...
module.exports = [CopyFiles, baseEncoreConfiguration, animationsConfiguration];