├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── demo.css ├── demo.js ├── index.html ├── lib ├── define.js ├── events.js ├── style.css ├── template.js ├── typewritten-text-mirror.js ├── typewritten-text.js └── typewritten-text.spec.js ├── package-lock.json ├── package.json └── typewritten-text.gif /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /lib/*.spec.js 2 | *.gif 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Timothy Foster 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <typewritten-text> Element 2 | 3 | Performs a **typewriter effect** on the selected piece of text! 4 | 5 | ![Text is automatically typed out one letter at a time](https://github.com/Auroratide/typewritten-text/blob/master/typewritten-text.gif) 6 | 7 | See the **[Live Demo](https://auroratide.github.io/typewritten-text/)** for some examples! 8 | 9 | ## Installation 10 | 11 | You can import through CDN: 12 | 13 | ```html 14 | 15 | 16 | ``` 17 | 18 | Or, you may install through [NPM](https://www.npmjs.com/package/@auroratide/typewritten-text) and include it as part of your build process: 19 | 20 | ``` 21 | $ npm i @auroratide/typewritten-text 22 | ``` 23 | 24 | ```js 25 | import '@auroratide/typewritten-text/lib/style.css' 26 | import '@auroratide/typewritten-text/lib/define.js' 27 | ``` 28 | Note: How you import into your project depends on its configuration. The style.css file should be imported with your root CSS, and the define.js file should imported with your root Javascript. 29 | 30 | ## Usage 31 | 32 | `` is an **inline markup element** that you can use in your HTML document. 33 | 34 | ```html 35 |

Some text to type out!

36 | ``` 37 | 38 | Since this is Just HTMLTM, you can use `typewritten-text` with other markup tags: 39 | 40 | ```html 41 |

Some strong and emphasized text.

42 | ``` 43 | 44 | **Note:** `typewritten-text` has text-level semantics, meaning it can contain anything that a `span` can contain. See [Phrasing Content](https://html.spec.whatwg.org/#phrasing-content-2). 45 | 46 | ### Repeat Indefinitely 47 | 48 | This types and backspaces the text on a loop. 49 | 50 | ```html 51 |

Some text to type out!

52 | ``` 53 | 54 | ### Adjust Timing 55 | 56 | The time provided is number of milliseconds between each letter. 57 | 58 | ```html 59 |

Some text to type out!

60 | ``` 61 | 62 | The `phrase-interval` is the time between when the text is typed out and when it starts to be removed during a repetition loop. 63 | 64 | ```html 65 |

Some text to type out!

66 | ``` 67 | 68 | ### Start Paused 69 | 70 | This will start paused until invoked by **javascript**. 71 | 72 | ```html 73 |

Some text to type out!

74 | ``` 75 | 76 | ### All Attributes 77 | 78 | | Attribute | Default | Description | 79 | | ------------- | --------- | ------------- | 80 | | `repeat` | - | Whether the text should type itself repeatedly on a loop | 81 | | `letter-interval` | 100 | Time between each letter in milliseconds | 82 | | `phrase-interval` | 1000 | Time between completion and restart during a repeat loop in milliseconds | 83 | | `paused` | - | Whether the animation should start paused | 84 | 85 | ## Style API 86 | 87 | Since `typewritten-text` is Just HTMLTM, you can style it the same way you style any HTML tag. 88 | 89 | ```css 90 | typewritten-text { 91 | color: red; 92 | } 93 | ``` 94 | 95 | **Note**: Depending on what you want to do, you may run into some [Implementation Gotchas](#implementation-gotchas). 96 | 97 | ### Cursor 98 | 99 | The blinking cursor can be customized with either CSS variables or directly via selectors. 100 | 101 | | Variable | Default | Description | 102 | | ------------- | --------- | ------------- | 103 | | `--typewritten-text_cursor-width` | 0.125em | How wide the cursor is | 104 | | `--typewritten-text_cursor-style` | solid | Whether the cursor is solid, dashed, dotted, etc; can be any border-style value | 105 | | `--typewritten-text_cursor-color` | currentColor | Color of the cursor | 106 | | `--typewriten-text_cursor-interval` | 700ms | The duration of the blink animation | 107 | 108 | The cursor can be arbitrarily customized with the following CSS selectors: 109 | 110 | ```css 111 | .typewritten-text_character::after, 112 | .typewritten-text_start::after { } 113 | ``` 114 | 115 | The `*_start` selector represents the start of the text and can be used to style the initial cursor differently than the cursor-in-motion. For example, to hide the cursor while the animation is paused and yet show it at the start, you can do: 116 | 117 | ```css 118 | typewritten-text[paused] .typewritten-text_character::after { 119 | visibility: hidden; 120 | } 121 | ``` 122 | 123 | ## Javascript API 124 | 125 | The element exposes some useful methods to enable custom animation. Once you have obtained a reference to a `TypewrittenText` element: 126 | 127 | ```js 128 | const elem = document.querySelector('typewritten-text') 129 | ``` 130 | 131 | You can use the following methods: 132 | 133 | | Method | Description | 134 | | ------------- | ------------- | 135 | | `start()` | Start the animation cycle if it is currently paused | 136 | | `pause()` | Pause the animation cycle if it is currently running | 137 | | `typeNext()` | Manually type the next character | 138 | | `backspace()` | Manually remove one character | 139 | | `tick()` | Run one frame of the animation; only works if not paused | 140 | | `forceTick()` | Run one frame of the animation regardless of paused state | 141 | | `reverse()` | Reverse the direction of the animation | 142 | | `reset()` | Completely resets the element and animation; may be useful if the content within the element is dynamic | 143 | 144 | ### Properties 145 | 146 | Each attribute can be accessed as a Javascript property. 147 | 148 | * `elem.repeat` 149 | * `elem.paused` 150 | * `elem.letterInterval` 151 | * `elem.phraseInterval` 152 | 153 | One additional property is provided: 154 | 155 | * `elem.length`: The total number of typeable characters 156 | 157 | ### Events 158 | 159 | The `typewritten-text` element dispatches the following events: 160 | 161 | | Name | When Triggered | 162 | | ------------- | ------------- | 163 | | `typewritten-text:nextchar` | Anytime a character is typed into view | 164 | | `typewritten-text:prevchar` | Anytime a character is removed from view | 165 | | `typewritten-text:phrasetyped` | When the full phrase becomes fully typed | 166 | | `typewritten-text:phraseremoved` | When the full phrase becomes untyped | 167 | | `typewritten-text:started` | When the animation is started | 168 | | `typewritten-text:paused` | When the animation is paused | 169 | 170 | ### Element Class 171 | 172 | The element interface can be accessed in javascript as well. 173 | 174 | ```js 175 | import { TypewrittenText } from '@auroratide/typewritten-text' 176 | ``` 177 | 178 | ## Accessibility 179 | 180 | This custom element is built with accessibility in mind! 181 | 182 | * The `typewritten-text` element always represents its textual content regardless of visibility state. Screenreaders should read the text in its entirety. 183 | * The textual content can be copied and pasted regardless of visibility state. 184 | * The blinking cursor animation is disabled for people who [prefer reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) 185 | 186 | ## Implementation Gotchas 187 | 188 | It is possible the non-trivial implementation of `typewritten-text` can lead to unexpected complications with advanced customization. 189 | 190 | Most notably, `typewritten-text` works by **cloning** its inner content into a separate custom element called `typewritten-text-mirror`, within which each letter is wrapped with a `span` denoted with the class `typewritten-text_character`. The following is an example before-and-after of what the resulting markup looks like once the element has finished rendering: 191 | 192 | ```html 193 | Hey 194 | 195 | 196 | 197 | Hey 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | ``` 206 | 207 | The only part that becomes visible to the viewer is the contents of `typewritten-text-mirror`. As a result, a selector like `typewritten-text > span` will have unexpected results. 208 | 209 | This architecture has the following explicit goals: 210 | 211 | * Preserve, as much as possible, the way the web developer has specified the usage of the element. This means not overriding the inner content of `typewritten-text`. 212 | * Allow the use of semantic markup within `typewritten-text` so it acts as much as possible like a native text-level element 213 | * Enable typing each individual character regardless of its formatting, allowing for size- and position-independence. 214 | -------------------------------------------------------------------------------- /demo.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font: 'Source Sans Pro', sans-serif; 3 | 4 | --color-bg: hsl(0, 0%, 93%); 5 | --color-fg: hsl(0, 0%, 100%); 6 | --color-primary: 211, 69%, 57%; 7 | 8 | --code-font: 'Source Code Pro', monospace; 9 | --code-base: hsl(0, 0%, 0%); 10 | --code-tagname: hsl(195, 100%, 33%); 11 | --code-keyword: hsl(217, 100%, 50%); 12 | --code-string: hsl(0, 77%, 36%); 13 | } 14 | 15 | *, *::before, *::after { 16 | box-sizing: border-box; 17 | margin-top: 0; 18 | } 19 | 20 | body { 21 | font-family: var(--font); 22 | padding: 0.5em; 23 | background: var(--color-bg); 24 | } 25 | 26 | main, footer { 27 | max-width: 50rem; 28 | margin: auto; 29 | } 30 | 31 | h1 { 32 | font-size: 3em; 33 | font-weight: bold; 34 | text-align: center; 35 | } 36 | 37 | footer { 38 | text-align: center; 39 | padding: 1.5em; 40 | } 41 | 42 | button, ::part(button) { 43 | font-family: var(--font); 44 | font-size: 0.75em; 45 | border-radius: 0.25em; 46 | border: none; 47 | padding: 0.625em 1.125em; 48 | color: var(--color-fg); 49 | text-transform: uppercase; 50 | letter-spacing: 0.0625em; 51 | box-shadow: 0 0.25em 0.25em -0.125em rgba(0, 0, 0, 0.25); 52 | cursor: pointer; 53 | background: hsl(var(--color-primary)); 54 | } 55 | 56 | button:hover, ::part(button):hover { 57 | box-shadow: 0 0.25em 0.25em -0.125em rgba(0, 0, 0, 0.25), 0 0 0 3em rgba(0, 0, 0, 0.2) inset; 58 | } 59 | 60 | button:active, ::part(button):active, button[disabled] { 61 | box-shadow: 0 0 0 3em rgba(0, 0, 0, 0.3) inset; 62 | } 63 | 64 | button[disabled] { 65 | pointer-events: none; 66 | opacity: 0.5; 67 | } 68 | 69 | figure { 70 | font-size: 1.5em; 71 | background-color: var(--color-fg); 72 | padding: 1em; 73 | border-radius: 0.25em; 74 | border: 0.0625em solid hsla(var(--color-primary), 1); 75 | box-shadow: 0.125em 0.125em 0.25em hsla(0, 0%, 0%, 0.15) inset; 76 | position: relative; 77 | margin: 0 0 1rem; 78 | } 79 | 80 | figure button { 81 | position: absolute; 82 | font-size: 0.667em; 83 | bottom: 0.25em; 84 | right: 0.25em; 85 | } 86 | 87 | pre { 88 | white-space: break-spaces; 89 | } 90 | 91 | pre code { 92 | display: block; 93 | font-size: 1.125em; 94 | padding: 0.75em; 95 | background-color: hsl(210, 11%, 4%); 96 | border-radius: 0.25em; 97 | color: hsl(60, 30%, 96%); 98 | line-height: 1.5; 99 | border: 0.125em solid hsl(0, 0%, 47%); 100 | } 101 | 102 | code mark { 103 | background: none; 104 | color: hsl(120, 47%, 65%); 105 | } 106 | 107 | code mark strong { 108 | color:hsl(23, 95%, 52%); 109 | } 110 | 111 | .demo { 112 | margin-bottom: 6rem; 113 | } 114 | 115 | .demo typewritten-text[paused] .typewritten-text_character::after { 116 | visibility: hidden; 117 | } 118 | 119 | .special { 120 | font-size: 1.25em; 121 | color:hsl(0, 77%, 36%); 122 | } -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | const setupRun = (name) => { 2 | document.querySelector(`#${name} .start`).onclick = () => { 3 | document.querySelectorAll(`#${name} typewritten-text`).forEach(elem => { 4 | elem.start() 5 | }) 6 | } 7 | } 8 | 9 | setupRun('main-demo') 10 | setupRun('markdown-demo') 11 | setupRun('interval-demo') 12 | 13 | document.querySelector('#repeat-demo .toggle').onclick = () => { 14 | document.querySelectorAll(`#repeat-demo typewritten-text`).forEach(elem => { 15 | elem.paused = !elem.paused 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | typewritten-text Element 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

typewritten-text Demo

15 |
16 |
17 |

This typewriter effect is achieved using custom elements!

18 | 19 |
20 |
This <typewritten-text paused>typewriter effect</typewritten-text> is achieved using <typewritten-text paused>custom elements!</typewritten-text>
21 |
22 |
23 |
24 |

This works with other markdown elements as well!

25 | 26 |
27 |
This works with <typewritten-text paused><strong>other</strong> <em>markdown</em> <span class="special">elements</span></typewritten-text> as well!
28 |
29 |
30 |
31 |

You can change the typing rate!

32 | 33 |
34 |
You can <typewritten-text paused letter-interval="400">change</typewritten-text> the <typewritten-text paused letter-interval="50">typing rate!</typewritten-text>
35 |
36 |
37 |
38 |

Text can be set to repeat indefinitely!

39 | 40 |
41 |
Text can be set to <typewritten-text repeat>repeat indefinitely!</typewritten-text>
42 |
43 |
44 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /lib/define.js: -------------------------------------------------------------------------------- 1 | import { TypewrittenText } from './typewritten-text.js' 2 | import { TypewrittenTextMirror } from './typewritten-text-mirror.js' 3 | 4 | window.customElements.define(TypewrittenTextMirror.elementName, TypewrittenTextMirror) 5 | window.customElements.define(TypewrittenText.elementName, TypewrittenText) 6 | -------------------------------------------------------------------------------- /lib/events.js: -------------------------------------------------------------------------------- 1 | export const NEXT_CHAR = 'typewritten-text:nextchar' 2 | export const nextCharEvent = (position) => new CustomEvent(NEXT_CHAR, { 3 | detail: { position } 4 | }) 5 | 6 | export const PREV_CHAR = 'typewritten-text:prevchar' 7 | export const prevCharEvent = (position) => new CustomEvent(PREV_CHAR, { 8 | detail: { position } 9 | }) 10 | 11 | export const PHRASE_TYPED = 'typewritten-text:phrasetyped' 12 | export const phraseTypedEvent = () => new CustomEvent(PHRASE_TYPED) 13 | 14 | export const PHRASE_REMOVED = 'typewritten-text:phraseremoved' 15 | export const phraseRemovedEvent = () => new CustomEvent(PHRASE_REMOVED) 16 | 17 | export const STARTED = 'typewritten-text:started' 18 | export const startedEvent = () => new CustomEvent(STARTED) 19 | 20 | export const PAUSED = 'typewritten-text:paused' 21 | export const pausedEvent = () => new CustomEvent(PAUSED) 22 | -------------------------------------------------------------------------------- /lib/style.css: -------------------------------------------------------------------------------- 1 | typewritten-text { 2 | /* Prevent double-copying */ 3 | -ms-user-select: none; 4 | -webkit-user-select: none; 5 | user-select: none; 6 | } 7 | 8 | typewritten-text-mirror { 9 | -ms-user-select: text; 10 | -webkit-user-select: text; 11 | user-select: text; 12 | } 13 | 14 | .typewritten-text_character { 15 | color: transparent; 16 | } 17 | 18 | .typewritten-text_word { 19 | white-space: nowrap; 20 | } 21 | 22 | .typewritten-text_character, 23 | .typewritten-text_start { 24 | position: relative; 25 | } 26 | 27 | .typewritten-text_character.typewritten-text_revealed { 28 | color: inherit; 29 | } 30 | 31 | .typewritten-text_character::after, 32 | .typewritten-text_start::after { 33 | box-sizing: border-box; 34 | content: ''; 35 | position: absolute; 36 | top: 0; bottom: 0; 37 | right: -0.5ch; 38 | border-right: var(--typewritten-text_cursor-width, 0.125em) var(--typewritten-text_cursor-style, solid) var(--typewritten-text_cursor-color, currentColor); 39 | animation: typewritten-text_blink var(--typewriten-text_cursor-interval, 700ms) infinite steps(1); 40 | visibility: hidden; 41 | } 42 | 43 | .typewritten-text_start::after { 44 | right: 0; 45 | } 46 | 47 | .typewritten-text_current.typewritten-text_character::after, 48 | .typewritten-text_current.typewritten-text_start::after { 49 | visibility: visible; 50 | } 51 | 52 | @keyframes typewritten-text_blink { 53 | 0% { opacity: 0; } 54 | 50% { opacity: 1; } 55 | } 56 | 57 | @media (prefers-reduced-motion) { 58 | .typewritten-text_character::after, 59 | .typewritten-text_start::after { 60 | animation: none; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/template.js: -------------------------------------------------------------------------------- 1 | export const template = document.createElement('template') 2 | template.innerHTML = ` 3 | 4 | 5 | ` 6 | -------------------------------------------------------------------------------- /lib/typewritten-text-mirror.js: -------------------------------------------------------------------------------- 1 | import { NEXT_CHAR, PREV_CHAR } from './events.js' 2 | 3 | export class TypewrittenTextMirror extends HTMLElement { 4 | static elementName = 'typewritten-text-mirror' 5 | 6 | constructor(mirrorOf) { 7 | super() 8 | 9 | this.mirrorOf = mirrorOf 10 | this.characters = [] 11 | } 12 | 13 | connectedCallback() { 14 | this.setAttribute('aria-label', this.textContent) 15 | this.characters = this.querySelectorAll('.typewritten-text_character') 16 | 17 | this.mirrorOf.addEventListener(NEXT_CHAR, this.next) 18 | this.mirrorOf.addEventListener(PREV_CHAR, this.prev) 19 | } 20 | 21 | disconnectedCallback() { 22 | this.mirrorOf.removeEventListener(NEXT_CHAR, this.next) 23 | this.mirrorOf.removeEventListener(PREV_CHAR, this.prev) 24 | } 25 | 26 | type = (prevAction, curAction, e) => { 27 | const i = e.detail.position 28 | 29 | if (i <= 0) { 30 | this.querySelector('.typewritten-text_start').classList[prevAction]('typewritten-text_current') 31 | } else { 32 | this.characters[i - 1].classList[prevAction]('typewritten-text_current') 33 | } 34 | 35 | this.characters[i].classList[curAction]('typewritten-text_current', 'typewritten-text_revealed') 36 | } 37 | 38 | next = this.type.bind(this, 'remove', 'add') 39 | prev = this.type.bind(this, 'add', 'remove') 40 | } 41 | -------------------------------------------------------------------------------- /lib/typewritten-text.js: -------------------------------------------------------------------------------- 1 | import { TypewrittenTextMirror } from './typewritten-text-mirror.js' 2 | import { 3 | nextCharEvent, 4 | prevCharEvent, 5 | phraseTypedEvent, 6 | phraseRemovedEvent, 7 | startedEvent, 8 | pausedEvent 9 | } from './events.js' 10 | import { template } from './template.js' 11 | 12 | const FORWARD = 'forward' 13 | const BACKWARD = 'backward' 14 | 15 | export class TypewrittenText extends HTMLElement { 16 | static elementName = 'typewritten-text' 17 | static defaultLetterInterval = 100 18 | static defaultPhraseInterval = 1000 19 | 20 | static get observedAttributes() { 21 | return ['paused'] 22 | } 23 | 24 | constructor() { 25 | super() 26 | 27 | this 28 | .attachShadow({ mode: 'open' }) 29 | .appendChild(template.content.cloneNode(true)) 30 | 31 | this.currentPosition = 0 32 | this.mirror = null 33 | this.direction = FORWARD 34 | } 35 | 36 | connectedCallback() { 37 | if (!this.mirror) this.createMirror() 38 | 39 | this.insertMirror() 40 | this.tick() 41 | 42 | const mainSlot = this.shadowRoot.querySelector('slot') 43 | const mirrorSlot = this.shadowRoot.querySelector('slot[name="mirror"]') 44 | 45 | mainSlot.addEventListener('slotchange', this.reset) 46 | mirrorSlot.addEventListener('slotchange', () => { 47 | if (mirrorSlot.assignedNodes().length === 0) { 48 | this.reset() 49 | } 50 | }) 51 | } 52 | 53 | attributeChangedCallback(name, oldValue, newValue) { 54 | if (name === 'paused') { 55 | if (newValue === null || newValue === undefined) { 56 | this.dispatchEvent(startedEvent()) 57 | this.tick() 58 | } else { 59 | this.dispatchEvent(pausedEvent()) 60 | } 61 | } 62 | } 63 | 64 | get letterInterval() { 65 | return parseInt(this.getAttribute('letter-interval')) || TypewrittenText.defaultLetterInterval 66 | } 67 | set letterInterval(value) { 68 | if (value === null) { 69 | this.removeAttribute('letter-interval') 70 | } else { 71 | this.setAttribute('letter-interval', value.toString()) 72 | } 73 | } 74 | 75 | get phraseInterval() { 76 | return parseInt(this.getAttribute('phrase-interval')) || TypewrittenText.defaultPhraseInterval 77 | } 78 | set phraseInterval(value) { 79 | if (value === null) { 80 | this.removeAttribute('phrase-interval') 81 | } else { 82 | this.setAttribute('phrase-interval', value.toString()) 83 | } 84 | } 85 | 86 | get paused() { return this.hasAttribute('paused') } 87 | set paused(value) { 88 | if (value) { 89 | this.setAttribute('paused', '') 90 | } else { 91 | this.removeAttribute('paused') 92 | } 93 | } 94 | 95 | get repeat() { return this.hasAttribute('repeat') } 96 | set repeat(value) { 97 | if (value) { 98 | this.setAttribute('repeat', '') 99 | } else { 100 | this.removeAttribute('repeat') 101 | } 102 | } 103 | 104 | get length() { 105 | return this.mirror.querySelectorAll('.typewritten-text_character').length 106 | } 107 | 108 | typeNext = () => { 109 | if (this.currentPosition < this.length) { 110 | this.dispatchEvent(nextCharEvent(this.currentPosition)) 111 | this.currentPosition += 1 112 | 113 | if (this.currentPosition === this.length) 114 | this.dispatchEvent(phraseTypedEvent()) 115 | } 116 | } 117 | 118 | backspace = () => { 119 | if (this.currentPosition > 0) { 120 | this.currentPosition -= 1 121 | this.dispatchEvent(prevCharEvent(this.currentPosition)) 122 | 123 | if (this.currentPosition === 0) 124 | this.dispatchEvent(phraseRemovedEvent()) 125 | } 126 | } 127 | 128 | start = () => this.paused = false 129 | pause = () => this.paused = true 130 | 131 | tick = () => { 132 | if (this.paused) 133 | return 134 | 135 | const reversed = this.forceTick() 136 | 137 | if (!reversed || this.repeat) { 138 | setTimeout(this.tick, reversed ? this.phraseInterval : this.letterInterval) 139 | } else { 140 | this.pause() 141 | } 142 | } 143 | 144 | reverse = () => { 145 | this.direction = this.direction === FORWARD ? BACKWARD : FORWARD 146 | } 147 | 148 | reset = () => { 149 | this.currentPosition = 0 150 | this.direction = FORWARD 151 | this.mirror.remove() 152 | this.createMirror() 153 | this.insertMirror() 154 | } 155 | 156 | forceTick = () => { 157 | if (this.direction === FORWARD) { 158 | this.typeNext() 159 | } else { 160 | this.backspace() 161 | } 162 | 163 | const reversed = this.currentPosition <= 0 || this.currentPosition >= this.length 164 | 165 | if (reversed) this.reverse() 166 | return reversed 167 | } 168 | 169 | divideIntoCharacters = (node = this) => { 170 | const isAlphanumeric = ch => /[a-zA-Z0-9_]/.test(ch) 171 | return [...node.childNodes].map(n => { 172 | if (n.nodeType === Node.TEXT_NODE) { 173 | const characters = [...n.textContent] 174 | let wordStarted = false 175 | const result = characters.reduce((acc, ch) => { 176 | let wordSpan = '' 177 | 178 | if (!wordStarted && isAlphanumeric(ch)) { 179 | wordStarted = true 180 | wordSpan = '' 181 | } else if (wordStarted && !isAlphanumeric(ch)) { 182 | wordStarted = false 183 | wordSpan = '' 184 | } 185 | 186 | return `${acc}${wordSpan}` 187 | }, '') 188 | 189 | if (wordStarted) { 190 | return `${result}` 191 | } else { 192 | return result 193 | } 194 | } else { 195 | const nn = n.cloneNode(false) 196 | nn.innerHTML = this.divideIntoCharacters(n) 197 | return nn.outerHTML 198 | } 199 | }).join('') 200 | } 201 | 202 | createMirror = () => { 203 | this.mirror = new TypewrittenTextMirror(this) 204 | this.mirror.slot = 'mirror' 205 | this.mirror.innerHTML = `` + this.divideIntoCharacters() 206 | } 207 | 208 | insertMirror = () => { 209 | this.appendChild(this.mirror) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/typewritten-text.spec.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from '@open-wc/testing' 2 | import { NEXT_CHAR, PAUSED, PHRASE_REMOVED, PHRASE_TYPED, PREV_CHAR, STARTED } from './events.js' 3 | import './define.js' 4 | 5 | describe('typewritten-text', () => { 6 | const typewriter = (container) => container.querySelector('typewritten-text') 7 | 8 | const visibleText = (container) => 9 | [...container.querySelectorAll('.typewritten-text_revealed')] 10 | .map(e => e.textContent) 11 | .join('') 12 | 13 | const cursor = (container) => { 14 | const cursors = container.querySelectorAll('.typewritten-text_current') 15 | if (cursors.length > 1) { 16 | throw new Error('Cannot have multiple cursors') 17 | } else { 18 | return cursors[0].textContent 19 | } 20 | } 21 | 22 | const render = (code) => { 23 | return fixture(`
${code}
`) 24 | .then(container => new Promise(resolve => setTimeout(() => resolve(container)))) 25 | } 26 | 27 | const milliseconds = (n) => new Promise(resolve => setTimeout(resolve, n)) 28 | const rerender = () => milliseconds(0) 29 | 30 | const words = (container) => [...container.querySelectorAll('.typewritten-text_word')] 31 | .map(elem => elem.textContent) 32 | 33 | describe('manual typing', () => { 34 | it('basic text', async () => { 35 | const container = await render(` 36 | text 37 | `) 38 | 39 | expect(visibleText(container)).to.equal('') 40 | 41 | typewriter(container).typeNext() 42 | expect(visibleText(container)).to.equal('t') 43 | 44 | typewriter(container).typeNext() 45 | typewriter(container).typeNext() 46 | typewriter(container).typeNext() 47 | expect(visibleText(container)).to.equal('text') 48 | 49 | // attempting to type past the length of the text 50 | typewriter(container).typeNext() 51 | expect(visibleText(container)).to.equal('text') 52 | }) 53 | 54 | it('backspacing', async () => { 55 | const container = await render(` 56 | text 57 | `) 58 | 59 | expect(visibleText(container)).to.equal('') 60 | 61 | typewriter(container).typeNext() 62 | typewriter(container).typeNext() 63 | expect(visibleText(container)).to.equal('te') 64 | 65 | typewriter(container).backspace() 66 | expect(visibleText(container)).to.equal('t') 67 | 68 | typewriter(container).backspace() 69 | expect(visibleText(container)).to.equal('') 70 | 71 | // attempting to backspace past the beginning 72 | typewriter(container).backspace() 73 | expect(visibleText(container)).to.equal('') 74 | }) 75 | 76 | it('containing emoji', async () => { 77 | // cannot use s.split('') since it splits emojis in half 78 | const container = await render(` 79 | 🍎🍏 80 | `) 81 | 82 | expect(visibleText(container)).to.equal('') 83 | 84 | typewriter(container).typeNext() 85 | expect(visibleText(container)).to.equal('🍎') 86 | 87 | typewriter(container).typeNext() 88 | expect(visibleText(container)).to.equal('🍎🍏') 89 | }) 90 | }) 91 | 92 | describe('auto-typing', () => { 93 | it('basic text', async () => { 94 | const container = await render(` 95 | text 96 | `) 97 | 98 | await milliseconds(50) 99 | 100 | expect(visibleText(container)).to.equal('text') 101 | }) 102 | 103 | it('resuming after it finishes', async () => { 104 | const container = await render(` 105 | text 106 | `) 107 | 108 | await milliseconds(50) 109 | expect(visibleText(container)).to.equal('text') 110 | 111 | typewriter(container).start() 112 | await milliseconds(50) 113 | expect(visibleText(container)).to.equal('') 114 | }) 115 | 116 | describe('unpausing', () => { 117 | it('with the start method', async () => { 118 | const container = await render(` 119 | hi 120 | `) 121 | 122 | typewriter(container).start() 123 | await milliseconds(30) 124 | 125 | expect(visibleText(container)).to.equal('hi') 126 | }) 127 | 128 | it('with the paused property', async () => { 129 | const container = await render(` 130 | hi 131 | `) 132 | 133 | typewriter(container).paused = false 134 | await milliseconds(30) 135 | 136 | expect(visibleText(container)).to.equal('hi') 137 | }) 138 | 139 | it('with the paused attribute', async () => { 140 | const container = await render(` 141 | hi 142 | `) 143 | 144 | typewriter(container).removeAttribute('paused') 145 | await milliseconds(30) 146 | 147 | expect(visibleText(container)).to.equal('hi') 148 | }) 149 | }) 150 | }) 151 | 152 | describe('length', () => { 153 | it('empty', async () => { 154 | const container = await render(` 155 | 156 | `) 157 | 158 | expect(typewriter(container).length).to.equal(0) 159 | }) 160 | 161 | it('basic text', async () => { 162 | const container = await render(` 163 | example 164 | `) 165 | 166 | expect(typewriter(container).length).to.equal(7) 167 | }) 168 | 169 | it('nested nodes', async () => { 170 | const container = await render(` 171 | hello world 172 | `) 173 | 174 | expect(typewriter(container).length).to.equal(11) 175 | }) 176 | }) 177 | 178 | /** 179 | * This suite of tests accomodates Chrome, which has wonky word-break 180 | * rules when a pseudo-element is on a span. See: 181 | * https://stackoverflow.com/questions/69121874/ 182 | */ 183 | describe('words', () => { 184 | it('empty', async () => { 185 | const container = await render(` 186 | 187 | `) 188 | 189 | expect(words(container).length).to.equal(0) 190 | }) 191 | 192 | it('single word', async () => { 193 | const container = await render(` 194 | one 195 | `) 196 | 197 | expect(words(container).length).to.equal(1) 198 | expect(words(container)[0]).to.equal('one') 199 | }) 200 | 201 | it('basic text', async () => { 202 | const container = await render(` 203 | one two three 204 | `) 205 | 206 | expect(words(container).length).to.equal(3) 207 | expect(words(container)[1]).to.equal('two') 208 | }) 209 | 210 | it('nested nodes', async () => { 211 | const container = await render(` 212 | hello world 213 | `) 214 | 215 | expect(words(container).length).to.equal(2) 216 | expect(words(container)[0]).to.equal('hello') 217 | expect(words(container)[1]).to.equal('world') 218 | }) 219 | }) 220 | 221 | describe('cursor', () => { 222 | it('travels with the typing', async () => { 223 | const container = await render(` 224 | text 225 | `) 226 | 227 | typewriter(container).typeNext() 228 | expect(cursor(container)).to.equal('t') 229 | typewriter(container).typeNext() 230 | expect(cursor(container)).to.equal('e') 231 | }) 232 | 233 | it('is at beginning and end', async () => { 234 | const container = await render(` 235 | hi 236 | `) 237 | 238 | expect(cursor(container)).to.equal('') 239 | typewriter(container).typeNext() 240 | typewriter(container).typeNext() 241 | expect(cursor(container)).to.equal('i') 242 | }) 243 | }) 244 | 245 | describe('repeat', () => { 246 | it('repeats', async () => { 247 | const container = await render(` 248 | hi 249 | `) 250 | 251 | typewriter(container).forceTick() 252 | typewriter(container).forceTick() 253 | expect(visibleText(container)).to.equal('hi') 254 | 255 | typewriter(container).forceTick() 256 | expect(visibleText(container)).to.equal('h') 257 | 258 | typewriter(container).forceTick() 259 | expect(visibleText(container)).to.equal('') 260 | 261 | typewriter(container).forceTick() 262 | expect(visibleText(container)).to.equal('h') 263 | }) 264 | }) 265 | 266 | describe('dom manipulation', () => { 267 | it('removing and re-adding the node', async () => { 268 | const container = await render(` 269 | text 270 | `) 271 | 272 | const node = typewriter(container) 273 | 274 | node.typeNext() 275 | node.typeNext() 276 | node.remove() 277 | 278 | expect(visibleText(container)).to.equal('') 279 | 280 | // it remembers where it was 281 | container.appendChild(node) 282 | await rerender() 283 | expect(visibleText(container)).to.equal('te') 284 | }) 285 | 286 | it('altering the children', async () => { 287 | const container = await render(` 288 | hi 289 | `) 290 | 291 | typewriter(container).typeNext() 292 | typewriter(container).typeNext() 293 | 294 | typewriter(container).innerHTML = 'text' 295 | 296 | // it starts over 297 | await rerender() 298 | expect(visibleText(container)).to.equal('') 299 | 300 | typewriter(container).typeNext() 301 | expect(visibleText(container)).to.equal('t') 302 | 303 | // typing past the original text's length 304 | typewriter(container).typeNext() 305 | typewriter(container).typeNext() 306 | expect(visibleText(container)).to.equal('tex') 307 | }) 308 | 309 | it('mirror node removed', async () => { 310 | const container = await render(` 311 | text 312 | `) 313 | 314 | typewriter(container).typeNext() 315 | typewriter(container).typeNext() 316 | 317 | container.querySelector('typewritten-text-mirror').remove() 318 | 319 | // it starts over 320 | await rerender() 321 | expect(visibleText(container)).to.equal('') 322 | 323 | typewriter(container).typeNext() 324 | expect(visibleText(container)).to.equal('t') 325 | }) 326 | }) 327 | 328 | describe('events', () => { 329 | it('next char', async () => { 330 | const container = await render(` 331 | text 332 | `) 333 | 334 | let caught = false 335 | typewriter(container).addEventListener(NEXT_CHAR, () => { 336 | caught = true 337 | }) 338 | 339 | typewriter(container).typeNext() 340 | 341 | expect(caught).to.be.true 342 | }) 343 | 344 | it('prev char', async () => { 345 | const container = await render(` 346 | text 347 | `) 348 | 349 | let caught = false 350 | typewriter(container).addEventListener(PREV_CHAR, () => { 351 | caught = true 352 | }) 353 | 354 | typewriter(container).typeNext() 355 | typewriter(container).backspace() 356 | 357 | expect(caught).to.be.true 358 | }) 359 | 360 | it('phrase typed', async () => { 361 | const container = await render(` 362 | hi 363 | `) 364 | 365 | let caught = false 366 | typewriter(container).addEventListener(PHRASE_TYPED, () => { 367 | caught = true 368 | }) 369 | 370 | typewriter(container).typeNext() 371 | expect(caught).to.be.false 372 | 373 | typewriter(container).typeNext() 374 | expect(caught).to.be.true 375 | }) 376 | 377 | it('phrase removed', async () => { 378 | const container = await render(` 379 | hi 380 | `) 381 | 382 | let caught = false 383 | typewriter(container).addEventListener(PHRASE_REMOVED, () => { 384 | caught = true 385 | }) 386 | 387 | typewriter(container).typeNext() 388 | typewriter(container).typeNext() 389 | typewriter(container).backspace() 390 | expect(caught).to.be.false 391 | 392 | typewriter(container).backspace() 393 | expect(caught).to.be.true 394 | }) 395 | 396 | it('started', async () => { 397 | const container = await render(` 398 | hi 399 | `) 400 | 401 | let caught = false 402 | typewriter(container).addEventListener(STARTED, () => { 403 | caught = true 404 | }) 405 | 406 | typewriter(container).start() 407 | expect(caught).to.be.true 408 | }) 409 | 410 | it('paused', async () => { 411 | const container = await render(` 412 | hi 413 | `) 414 | 415 | let caught = false 416 | typewriter(container).addEventListener(PAUSED, () => { 417 | caught = true 418 | }) 419 | 420 | typewriter(container).start() 421 | await rerender() 422 | typewriter(container).pause() 423 | expect(caught).to.be.true 424 | }) 425 | }) 426 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@auroratide/typewritten-text", 3 | "version": "0.1.4", 4 | "description": "The text types itself out!", 5 | "keywords": [ 6 | "text", 7 | "element", 8 | "typing", 9 | "web-component", 10 | "typewriter" 11 | ], 12 | "type": "module", 13 | "main": "lib/typewritten-text.js", 14 | "module": "lib/typewritten-text.js", 15 | "scripts": { 16 | "start": "wds --node-resolve -p 3000 --watch", 17 | "test": "wtr --node-resolve ./**/*.spec.js", 18 | "prepublishOnly": "npm run test", 19 | "gh": "gh-pages -d ." 20 | }, 21 | "author": { 22 | "name": "Timothy Foster", 23 | "url": "https://auroratide.com" 24 | }, 25 | "license": "ISC", 26 | "devDependencies": { 27 | "@open-wc/testing": "^2.5.33", 28 | "@web/dev-server": "^0.1.22", 29 | "@web/test-runner": "^0.13.17", 30 | "gh-pages": "^3.2.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /typewritten-text.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Auroratide/typewritten-text/437c9198f0ac6d4e72c948a14761fa58a50f9a64/typewritten-text.gif --------------------------------------------------------------------------------