Last active
February 26, 2026 19:30
-
-
Save krhoyt/3c982cb4d09cfcbce93395324a1fbfc3 to your computer and use it in GitHub Desktop.
Typing animation for multiple strings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Typing</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" rel="stylesheet"> | |
| </head> | |
| <body> | |
| <p>A long time ago, in a galaxy, <kh-typing>far, far away</kh-typing>...</p> | |
| <script src="./typing.js" type="module"></script> | |
| <script> | |
| const typing = document.querySelector( 'kh-typing' ); | |
| typing.messages = [ | |
| 'far, far, away', | |
| 'just down the street', | |
| 'next door' | |
| ] | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export default class KHTyping extends HTMLElement { | |
| constructor() { | |
| super(); | |
| const template = document.createElement( 'template' ); | |
| template.innerHTML = /* template */ ` | |
| <style> | |
| :host { | |
| box-sizing: border-box; | |
| display: inline; | |
| position: relative; | |
| } | |
| :host( [hidden] ) { display: none; } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0; } | |
| } | |
| span[part=cursor] { | |
| animation: blink 1.05s step-end infinite; | |
| font-weight: 300; | |
| margin-left: 1px; | |
| opacity: 1; | |
| } | |
| span { display: inline; } | |
| </style> | |
| <span part="typing"></span> | |
| <span part="cursor">|</span> | |
| `; | |
| // Timings (ms) | |
| this.TYPING_MS = 35; | |
| this.DELETING_MS = 75; | |
| this.PAUSE_FULL = 2000; | |
| this.PAUSE_EMPTY = 350; | |
| // State | |
| this._messages = []; | |
| this._index = 0; | |
| this._character = 0; | |
| // Phase machine: TYPING | PAUSE_FULL | DELETING | PAUSE_EMPTY | |
| this._phase = 'TYPING'; | |
| // rAF control | |
| this._rafId = null; | |
| this._running = false; | |
| this._nextAt = 0; | |
| // Root | |
| this.attachShadow( { mode: 'open' } ); | |
| this.shadowRoot.appendChild(template.content.cloneNode(true)); | |
| // Elements | |
| this.$cursor = this.shadowRoot.querySelector( 'span[part=cursor]' ); | |
| this.$span = this.shadowRoot.querySelector( 'span[part=typing]' ); | |
| // Bind tick once (no bind-per-frame allocations) | |
| this._tick = this._tick.bind( this ); | |
| } | |
| // Setup | |
| connectedCallback() { | |
| this._upgrade( 'data' ); | |
| this._upgrade( 'hidden' ); | |
| this._upgrade( 'messages' ); | |
| this._render(); | |
| // If messages were set before connect, start now. | |
| if( this._messages.length ) this._start(); | |
| } | |
| // Set down | |
| disconnectedCallback() { | |
| this._stop(); | |
| } | |
| static get observedAttributes() { | |
| return [ | |
| 'hidden' | |
| ]; | |
| } | |
| attributeChangedCallback() { | |
| // Pause animation when hidden | |
| if( this.hidden ) this._stop(); | |
| else if (this._messages.length) this._start(); | |
| this._render(); | |
| } | |
| // ---- Animation control | |
| _start() { | |
| if( this._running ) return; | |
| this._running = true; | |
| this._nextAt = performance.now(); // run first step immediately | |
| this._rafId = requestAnimationFrame( this._tick ); | |
| } | |
| _stop() { | |
| this._running = false; | |
| if( this._rafId !== null ) cancelAnimationFrame( this._rafId ); | |
| this._rafId = null; | |
| } | |
| _resetLoop() { | |
| this._index = 0; | |
| this._character = 0; | |
| this._phase = 'TYPING'; | |
| this.$span.textContent = ''; | |
| this._nextAt = performance.now(); | |
| } | |
| _tick( now ) { | |
| if( !this._running ) return; | |
| // If hidden, don't keep spinning | |
| if( this.hidden ) { | |
| this._stop(); | |
| return; | |
| } | |
| if( now >= this._nextAt ) { | |
| this._step( now ); | |
| } | |
| this._rafId = requestAnimationFrame( this._tick ); | |
| } | |
| _step( now ) { | |
| if( !this._messages.length ) return; | |
| const word = String( this._messages[this._index] ?? '' ); | |
| switch( this._phase ) { | |
| case 'TYPING': { | |
| if( this._character < word.length ) { | |
| this._character += 1; | |
| this.$span.textContent = word.slice( 0, this._character ); | |
| this._nextAt = now + this.TYPING_MS; | |
| } else { | |
| this._phase = 'PAUSE_FULL'; | |
| this._nextAt = now + this.PAUSE_FULL; | |
| } | |
| break; | |
| } | |
| case 'PAUSE_FULL': { | |
| this._phase = 'DELETING'; | |
| this._nextAt = now + this.DELETING_MS; | |
| break; | |
| } | |
| case 'DELETING': { | |
| if( this._character > 0 ) { | |
| this._character -= 1; | |
| this.$span.textContent = word.slice( 0, this._character ); | |
| this._nextAt = now + this.DELETING_MS; | |
| } else { | |
| this._phase = 'PAUSE_EMPTY'; | |
| this._nextAt = now + this.PAUSE_EMPTY; | |
| } | |
| break; | |
| } | |
| case 'PAUSE_EMPTY': { | |
| this._index = ( this._index + 1 ) % this._messages.length; | |
| this._phase = 'TYPING'; | |
| this._nextAt = now + this.TYPING_MS; | |
| break; | |
| } | |
| } | |
| } | |
| // ---- Rendering (keep yours; no-op is fine) | |
| _render() { /* optional */ } | |
| _upgrade( property ) { | |
| if( this.hasOwnProperty( property ) ) { | |
| const value = this[property]; | |
| delete this[property]; | |
| this[property] = value; | |
| } | |
| } | |
| // ---- Properties | |
| get data() { | |
| return this._data; | |
| } | |
| set data( value ) { | |
| this._data = value === null ? null : structuredClone( value ); | |
| } | |
| get messages() { | |
| return this._messages.length === 0 ? null : this._messages; | |
| } | |
| set messages( value ) { | |
| this._messages = value === null ? [] : [...value]; | |
| // Reset animation state for new message set | |
| this._resetLoop(); | |
| // Only start if connected & not hidden | |
| if( this.isConnected && !this.hidden && this._messages.length ) { | |
| this._start(); | |
| } else { | |
| this._stop(); | |
| } | |
| } | |
| // ---- Reflected attribute | |
| get hidden() { | |
| return this.hasAttribute( 'hidden' ); | |
| } | |
| set hidden( value ) { | |
| if( value !== null ) { | |
| if( typeof value === 'boolean' ) value = value.toString(); | |
| if( value === 'false' ) this.removeAttribute( 'hidden' ); | |
| else this.setAttribute( 'hidden', '' ); | |
| } else { | |
| this.removeAttribute( 'hidden' ); | |
| } | |
| } | |
| } | |
| window.customElements.define( 'kh-typing', KHTyping ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script> | |
| import { onMount } from 'svelte'; | |
| let { ready = [], width = null } = $props(); | |
| let displayed = $state( '' ); | |
| const TYPING_MS = 75; | |
| const DELETING_MS = 35; | |
| const PAUSE_FULL = 2200; | |
| const PAUSE_EMPTY = 350; | |
| onMount( () => { | |
| if ( !ready.length ) return; | |
| let index = 0; | |
| let charIndex = 0; | |
| let deleting = false; | |
| let timer; | |
| function tick() { | |
| const word = ready[ index ]; | |
| if ( deleting ) { | |
| charIndex--; | |
| displayed = word.slice( 0, charIndex ); | |
| if ( charIndex === 0 ) { | |
| deleting = false; | |
| index = ( index + 1 ) % ready.length; | |
| timer = setTimeout( tick, PAUSE_EMPTY ); | |
| } else { | |
| timer = setTimeout( tick, DELETING_MS ); | |
| } | |
| } else { | |
| charIndex++; | |
| displayed = word.slice( 0, charIndex ); | |
| if ( charIndex === word.length ) { | |
| deleting = true; | |
| timer = setTimeout( tick, PAUSE_FULL ); | |
| } else { | |
| timer = setTimeout( tick, TYPING_MS ); | |
| } | |
| } | |
| } | |
| timer = setTimeout( tick, PAUSE_EMPTY ); | |
| return () => clearTimeout( timer ); | |
| } ); | |
| </script> | |
| <span class="typing" style={width ? `display: inline-block; width: ${width}; text-align: left;` : null}>{displayed}<span class="cursor">|</span></span> | |
| <style> | |
| .typing { | |
| display: inline; | |
| } | |
| .cursor { | |
| animation: blink 1.05s step-end infinite; | |
| font-weight: 300; | |
| margin-left: 1px; | |
| opacity: 1; | |
| } | |
| @keyframes blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0; } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment