DEV Community

Rachel
Rachel

Posted on • Updated on

Typewriter Component for Vue.js

"A typewriter forces you to keep going, to march forward." - James McBride

I'm taking a break this week from writing about writing. Instead, I will demonstrate how to create a Typewriter Component in Vue.js.

Here's a preview:

Template

The template is quite simple. To create the typewriter effect, you need an element for static text and an element for changing text. This component contains two span tags encapsulated in a div. I also tried a variant of a p tag encapsulating the span tag of the changing text.

  <div class="pl-10">
    <span class="text-4xl text-black">
      {{ title }}
    </span>
    <span class="text-4xl text-bold text-red-500">
        {{ displayText.join("") }}
    </span>
  </div>
Enter fullscreen mode Exit fullscreen mode

Styles

For simplicity, I've used Tailwind CSS for styling.

Script

Props & Computed Values

This component takes in 4 props: title, speed, deleteSpeed, and words. The title prop is the static text. The speed prop is the typing speed, and the deleteSpeed prop is the delete speed. The words prop is an array of changing words. While computed values are not needed in this simple example, I pondered if there might be a use case where certain conditions may require internally changing the values (such as having a super slow delete speed for words that match a certain value).

Data

There's only 3 data values - a displayText array, which keeps track of which values to display, the currentWord being "typed", and the index of the current word from the words array.

Methods

Start

This begins the typing sequence, setting the currentWord and using a setTimeout interval for a delay before calling the type function to continue the typing sequence.

Type

This method contains all the logic to determine which word is being typed, whether to type or delete, or to change to the next word. Take a look below.

      // if typing...
      if (this.currentWord.length > 0) {
        this.displayText.push(this.currentWord.shift());
        // if done typing, then delete
      } else if (this.currentWord.length === 0 && 
             this.displayText.length > 0) {
        this.timeoutSpeed = this.DELETE_SPEED;
        this.displayText.pop();
        // if done typing & deleting
      } else if (
        this.currentWord.length === 0 &&
        this.displayText.length === 0
      ) {
        // change words
        if (this.wordIdx < this.words.length) {
          this.currentWord = this.words[this.wordIdx].split("");
          this.wordIdx++;
          this.timeoutSpeed = this.TYPE_SPEED;
          this.displayText.push(this.currentWord.shift());
        } else {
          // reset
          this.wordIdx = 0;
          this.currentWord = this.words[this.wordIdx].split("");
          this.displayText.push(this.currentWord.shift());
        }
      }
      setTimeout(this.type, this.timeoutSpeed);
Enter fullscreen mode Exit fullscreen mode

Mounted Lifecycle

When the component is mounted, it calls the start() method to begin the typing sequence.

Here's the final Codepen code:

And a Github Gist for the Single Page Component:

Code reviews welcome. Let me know if I can do something better.

Update [16 Oct 2020]: Take a look at Theo's Comment for ways to improve this component!

Just some fixes and two features:

  1. Blink cursor.
  2. Add interval between words/phrases cycle.
<template>
  <span>
    {{ displayText.join('') }}
    <span class="cursor">|</span>
  </span>
</template>

<script>
export default {
  props: {
    speed: {
      type: Number,
      default: 100,
    },
    deleteSpeed: {
      type: Number,
      default: 30,
    },
    nextWordInterval: {
      type: Number,
      default: 1200
    },
    words: {
      type: Array,
      default: [],
    },
  },
  data() {
    return {
      displayText: [],
      currentWord: '',
      wordIdx: 0,
      timeoutSpeed: null,
      isWaitingNextWord: false,
    }
  },
  mounted() {
    this.start()
  },
  methods: {
    start() {
      if (this.words && this.words.length > 0) {
        this.currentWord = this.words[this.wordIdx].split('')
        this.timeoutSpeed = this.speed
        this.animate = setTimeout(this.type, this.timeoutSpeed)
      }
    },
    type() {
      // if typing...
      if (this.currentWord.length > 0) {
        this.displayText.push(this.currentWord.shift())
        // if done typing, wait for a while
      } else if (!this.isWaitingNextWord && this.currentWord.length === 0 && this.displayText.length === this.words[this.wordIdx].length) {
        this.timeoutSpeed = this.nextWordInterval
        this.isWaitingNextWord = true
        // if done typing, then delete
      } else if (this.currentWord.length === 0 && this.displayText.length > 0) {
        this.timeoutSpeed = this.deleteSpeed
        this.displayText.pop()
        // if done typing & deleting
      } else if (this.currentWord.length === 0 && this.displayText.length === 0) {
        // change words
        if (this.wordIdx < (this.words.length - 1)) {
          this.wordIdx++
        } else {
          // reset
          this.wordIdx = 0
        }

        this.timeoutSpeed = this.speed
        this.isWaitingNextWord = false
        this.currentWord = this.words[this.wordIdx].split('')
        this.displayText.push(this.currentWord.shift())
      }

      setTimeout(this.type, this.timeoutSpeed)
    },
  },
}
</script>

<style lang="scss" scoped>
@keyframes blink-animation {
  to {
    visibility: hidden;
  }
}

.cursor {
  display: inline-block;
  margin-left: -3px;
  animation: blink-animation 1s steps(2, start) infinite;
}
</style>
Enter fullscreen mode Exit fullscreen mode
Enter fullscreen mode Exit fullscreen mode
Enter fullscreen mode Exit fullscreen mode

Top comments (3)

Collapse
 
theobittencourt profile image
Theo B

Just some fixes and two features:

  1. Blink cursor.
  2. Add interval between words/phrases cycle.
<template>
  <span>
    {{ displayText.join('') }}
    <span class="cursor">|</span>
  </span>
</template>

<script>
export default {
  props: {
    speed: {
      type: Number,
      default: 100,
    },
    deleteSpeed: {
      type: Number,
      default: 30,
    },
    nextWordInterval: {
      type: Number,
      default: 1200
    },
    words: {
      type: Array,
      default: [],
    },
  },
  data() {
    return {
      displayText: [],
      currentWord: '',
      wordIdx: 0,
      timeoutSpeed: null,
      isWaitingNextWord: false,
    }
  },
  mounted() {
    this.start()
  },
  methods: {
    start() {
      if (this.words && this.words.length > 0) {
        this.currentWord = this.words[this.wordIdx].split('')
        this.timeoutSpeed = this.speed
        this.animate = setTimeout(this.type, this.timeoutSpeed)
      }
    },
    type() {
      // if typing...
      if (this.currentWord.length > 0) {
        this.displayText.push(this.currentWord.shift())
        // if done typing, wait for a while
      } else if (!this.isWaitingNextWord && this.currentWord.length === 0 && this.displayText.length === this.words[this.wordIdx].length) {
        this.timeoutSpeed = this.nextWordInterval
        this.isWaitingNextWord = true
        // if done typing, then delete
      } else if (this.currentWord.length === 0 && this.displayText.length > 0) {
        this.timeoutSpeed = this.deleteSpeed
        this.displayText.pop()
        // if done typing & deleting
      } else if (this.currentWord.length === 0 && this.displayText.length === 0) {
        // change words
        if (this.wordIdx < (this.words.length - 1)) {
          this.wordIdx++
        } else {
          // reset
          this.wordIdx = 0
        }

        this.timeoutSpeed = this.speed
        this.isWaitingNextWord = false
        this.currentWord = this.words[this.wordIdx].split('')
        this.displayText.push(this.currentWord.shift())
      }

      setTimeout(this.type, this.timeoutSpeed)
    },
  },
}
</script>

<style lang="scss" scoped>
@keyframes blink-animation {
  to {
    visibility: hidden;
  }
}

.cursor {
  display: inline-block;
  margin-left: -3px;
  animation: blink-animation 1s steps(2, start) infinite;
}
</style>
Enter fullscreen mode Exit fullscreen mode
Collapse
 
imseaworld profile image
SeaWorld • Edited

This was a great addition, I'd also like to contribute some changes I've made.
I put all the main functionality of the loop into individual functions for better state management. I personally loved the addition of the cursor since it was exactly what I was looking for, but I had an issue with the look of the cursor. I wanted it to be exact to what's seen when you actually type. Ex: Typing, cursor is static, stop typing, cursor blinks.

<template>
    <span>
        {{ displayText.join('') }}
        <span :class="{cursor: true, 'cursor--blink': blinkCursor}">|</span>
    </span>
</template>

<script>
export default {
    name: 'TypeWriter',
    props: {
        speed: {
            type: Number,
            default: 100,
        },
        deleteSpeed: {
            type: Number,
            default: 30,
        },
        nextWordInterval: {
            type: Number,
            default: 1200,
        },
        words: {
            type: Array,
            default: [],
        },
    },
    data() {
        return {
            displayText: [],
            currentWord: '',
            wordIdx: 0,
            timeoutSpeed: null,
            isWaitingNextWord: false,
            blinkCursor: false,
        };
    },
    mounted() {
        this.start();
    },
    methods: {
        start() {
            if (this.words && this.words.length > 0) {
                this.currentWord = this.words[this.wordIdx].split('');
                this.timeoutSpeed = this.speed;
                this.animate = setTimeout(this.type, this.timeoutSpeed);
            }
        },
        type() {
            if (this.currentWord.length > 0) {
                this.displayText.push(this.currentWord.shift());
                this.timeoutSpeed = this.speed;
                this.animate = setTimeout(this.type, this.timeoutSpeed);
            } else {
                this.blinkCursor = true;
                this.isWaitingNextWord = true;
                this.timeoutSpeed = this.nextWordInterval;
                this.animate = setTimeout(this.delete, this.timeoutSpeed);
            }
        },
        nextWord() {
            this.isWaitingNextWord = false;
            this.displayText = [];
            if (++this.wordIdx >= this.words.length) {
                this.wordIdx = 0;
            }
            this.blinkCursor = false;
            this.currentWord = this.words[this.wordIdx].split('');
            this.timeoutSpeed = this.speed;
            this.animate = setTimeout(this.type, this.timeoutSpeed);
        },
        delete() {
            if (this.displayText.length > 0) {
                this.blinkCursor = false;
                this.displayText.pop();
                this.timeoutSpeed = this.deleteSpeed;
                this.animate = setTimeout(this.delete, this.timeoutSpeed);
            } else {
                this.blinkCursor = true;
                this.isWaitingNextWord = false;
                this.timeoutSpeed = this.nextWordInterval;
                this.animate = setTimeout(this.nextWord, this.timeoutSpeed);
            }
        },
    },
};
</script>

<style lang="scss" scoped>
@keyframes blink-animation {
    to {
        visibility: hidden;
    }
}

.cursor {
    display: inline-block;
    margin-left: -5px;

    &--blink {
        animation: blink-animation 1s steps(2, start) infinite;
    }
}
</style>
Enter fullscreen mode Exit fullscreen mode
Collapse
 
rachel_cheuk profile image
Rachel

Thanks Theo! I've included your reply above as part of an update!