Add CSS-animation based typing animation
This commit is contained in:
		
							parent
							
								
									0801e85839
								
							
						
					
					
						commit
						31f3a69a1d
					
				
					 5 changed files with 142 additions and 134 deletions
				
			
		|  | @ -1,18 +1,7 @@ | ||||||
| <extends src="./lib/html/base.html"> | <extends src="./lib/html/base.html"> | ||||||
|   <block name="stylesheets"> |  | ||||||
|     <style> |  | ||||||
|       .no-js .head-line .typed { |  | ||||||
|         visibility: visible; |  | ||||||
|       } |  | ||||||
|       .head-line .typed { |  | ||||||
|         visibility: hidden; |  | ||||||
|       } |  | ||||||
|     </style> |  | ||||||
|   </block> |  | ||||||
| 
 |  | ||||||
|   <block name="content"> |   <block name="content"> | ||||||
|     <h1 class="title head-line has-text-weight-normal is-family-monospace"> |     <h1 class="title head-line has-text-weight-normal is-family-monospace"> | ||||||
|       $ <span class="typed">Welcome to tlater.net!</span> |       $ <span id="typed-welcome"></span> | ||||||
|     </h1> |     </h1> | ||||||
| 
 | 
 | ||||||
|     <hr /> |     <hr /> | ||||||
|  |  | ||||||
							
								
								
									
										119
									
								
								src/index.ts
									
										
									
									
									
								
							
							
						
						
									
										119
									
								
								src/index.ts
									
										
									
									
									
								
							|  | @ -1,119 +0,0 @@ | ||||||
| import jQuery from "jquery"; |  | ||||||
| 
 |  | ||||||
| // Helpers
 |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * "Types" out a DOM element, emulating the way a human might. |  | ||||||
|  */ |  | ||||||
| class Typer { |  | ||||||
|     private element: JQuery; |  | ||||||
|     private text: string; |  | ||||||
|     private cursor: boolean; |  | ||||||
|     private typed: number; |  | ||||||
|     private min: number; |  | ||||||
|     private max: number; |  | ||||||
|     private blink_tick: number; |  | ||||||
|     private blink_timeout: number; |  | ||||||
|     private end?: number; |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Create the typer. |  | ||||||
|      * @param {HTMLElement} element - The element to type. |  | ||||||
|      * @param {number} blink - The time between cursor blinks. |  | ||||||
|      * @param {number} blink_timeout - How long the cursor should keep |  | ||||||
|      *                                 blinking for after the text |  | ||||||
|      *                                 finishes typing. |  | ||||||
|      */ |  | ||||||
|     constructor(element: JQuery<HTMLElement>, blink: number, blink_timeout: number) { |  | ||||||
|         // Retrieve the current content and wipe it. We also make the
 |  | ||||||
|         // element visible if it was hidden.
 |  | ||||||
|         this.element = element; |  | ||||||
|         this.text = this.element.html(); |  | ||||||
|         this.element.html(""); |  | ||||||
|         this.element.css("visibility", "visible"); |  | ||||||
| 
 |  | ||||||
|         this.cursor = false; |  | ||||||
|         this.typed = 0; |  | ||||||
| 
 |  | ||||||
|         this.min = 20; |  | ||||||
|         this.max = 70; |  | ||||||
|         this.blink_tick = blink; |  | ||||||
|         this.blink_timeout = blink_timeout; |  | ||||||
| 
 |  | ||||||
|         this.end = null; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Start typing. |  | ||||||
|      */ |  | ||||||
|     type() { |  | ||||||
|         this._type(); |  | ||||||
|         this._blink(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Draw the current text line, i.e., anything that has been typed |  | ||||||
|      * so far, and a cursor if it is currently supposed to be on. |  | ||||||
|      * @private |  | ||||||
|      */ |  | ||||||
|     _draw() { |  | ||||||
|         let text = this.text.slice(0, this.typed); |  | ||||||
| 
 |  | ||||||
|         if (this.cursor) { |  | ||||||
|             text += "\u2588"; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         window.requestAnimationFrame(() => this.element.html(text)); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Type the next character, and prepare to draw the next one. If |  | ||||||
|      * no new characters are to be drawn, set the end timestamp. |  | ||||||
|      * @private |  | ||||||
|      */ |  | ||||||
|     _type() { |  | ||||||
|         this.typed += 1; |  | ||||||
|         this._draw(); |  | ||||||
| 
 |  | ||||||
|         if (this.typed != this.text.length) |  | ||||||
|             setTimeout(this._type.bind(this), this._type_tick()); |  | ||||||
|         else { |  | ||||||
|             this.end = Date.now(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Make the cursor change blink status, and prepare for the next |  | ||||||
|      * blink. |  | ||||||
|      * @private |  | ||||||
|      */ |  | ||||||
|     _blink() { |  | ||||||
|         this.cursor = !this.cursor; |  | ||||||
|         this._draw(); |  | ||||||
| 
 |  | ||||||
|         // As long as we are typing, keep blinking
 |  | ||||||
|         if (this.typed != this.text.length) |  | ||||||
|             setTimeout(this._blink.bind(this), this.blink_tick); |  | ||||||
|         // Once typing ends, keep going for a little bit
 |  | ||||||
|         else if (Date.now() - this.end < this.blink_timeout) |  | ||||||
|             setTimeout(this._blink.bind(this), this.blink_tick); |  | ||||||
|         // Make sure we get rid of the cursor in the end
 |  | ||||||
|         else { |  | ||||||
|             this.cursor = true; |  | ||||||
|             setTimeout(this._blink.bind(this), this.blink_tick); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     /** |  | ||||||
|      * Calculate a "human" time for the next character to type. |  | ||||||
|      * @private |  | ||||||
|      */ |  | ||||||
|     _type_tick() { |  | ||||||
|         return Math.round(Math.random() * this.max) + this.min; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| jQuery(($) => { |  | ||||||
|     const typer = new Typer($(".head-line .typed").first(), 500, 3000); |  | ||||||
|     typer.type(); |  | ||||||
| }); |  | ||||||
|  | @ -1,3 +0,0 @@ | ||||||
| import jQuery from "jquery"; |  | ||||||
| 
 |  | ||||||
| jQuery(($) => $("html").removeClass("no-js")); |  | ||||||
							
								
								
									
										130
									
								
								src/lib/scss/_typed.scss
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/lib/scss/_typed.scss
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,130 @@ | ||||||
|  | @use "sass:math"; | ||||||
|  | @use "sass:list"; | ||||||
|  | 
 | ||||||
|  | /// Animate a blinking cursor. | ||||||
|  | @mixin cursor($duration) { | ||||||
|  |   $name: cursor-09d03260130069771b6ddc1cb415f39fdd27ddfab7b01ba91273398c2d245ae4; | ||||||
|  |   // The number of times we need to blink is = the number of full | ||||||
|  |   // seconds (500ms * 2) that fit in the total duration, rounded up, | ||||||
|  |   // and doubled. | ||||||
|  |   $iterations: math.ceil(math.div($duration, 1s)) * 2; | ||||||
|  | 
 | ||||||
|  |   animation: $name ease-in-out 500ms $iterations alternate; | ||||||
|  |   content: " "; | ||||||
|  | 
 | ||||||
|  |   @keyframes #{$name} { | ||||||
|  |     from { | ||||||
|  |       content: " "; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     to { | ||||||
|  |       content: "█"; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Animate a piece of text as if it was being typed by a human. | ||||||
|  | @mixin typed($text, $duration) { | ||||||
|  |   // We don't want a linearly typed set of text, which makes this | ||||||
|  |   // singificantly more complex. | ||||||
|  |   // | ||||||
|  |   // CSS animations normally do not permit per-frame changes in | ||||||
|  |   // duration (since the total animation time is fixed). This means we | ||||||
|  |   // need to create multiple animations, and delay them so that they | ||||||
|  |   // happen in the time sequence we want. | ||||||
|  |   // | ||||||
|  |   // We generate the raw values with _generate-animations, and then | ||||||
|  |   // split up the result into the animation API. | ||||||
|  |   $frames: str-length($text); | ||||||
|  |   $animations: _generate-animations($frames, 1.2s); | ||||||
|  | 
 | ||||||
|  |   animation-name: _unzip($animations, 1); | ||||||
|  |   animation-delay: _unzip($animations, 3); | ||||||
|  |   animation-fill-mode: forwards; | ||||||
|  |   content: ""; | ||||||
|  | 
 | ||||||
|  |   // We need to type each character in separate animations, see above | ||||||
|  |   // comment. | ||||||
|  |   @each $name, $character in $animations { | ||||||
|  |     @keyframes #{$name} { | ||||||
|  |       from { | ||||||
|  |         content: str-slice($text, 0, $character); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       to { | ||||||
|  |         content: str-slice($text, 0, $character + 1); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Unzip a nested set of lists, taking the nth value of each sublist. | ||||||
|  | @function _unzip($lists, $i) { | ||||||
|  |   $out: (); | ||||||
|  |   $sep: comma; | ||||||
|  |   @each $sublist in $lists { | ||||||
|  |     $out: list.append($out, list.nth($sublist, $i), $sep); | ||||||
|  |   } | ||||||
|  |   @return $out; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Compute the sum of all numbers in a list. | ||||||
|  | @function _sum($list) { | ||||||
|  |   $out: 0; | ||||||
|  |   @each $val in $list { | ||||||
|  |     $out: $out + $val; | ||||||
|  |   } | ||||||
|  |   @return $out; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Produce a list from a shorter list by repeating it up until size | ||||||
|  | /// $length. | ||||||
|  | @function _round-robin($base, $length) { | ||||||
|  |   $out: (); | ||||||
|  |   $sep: list.separator($out); | ||||||
|  |   @for $i from 0 through $length { | ||||||
|  |     $out: list.append($out, list.nth($base, $i % list.length($base) + 1)); | ||||||
|  |   } | ||||||
|  |   @return $out; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Generate the actual animation values. | ||||||
|  | /// | ||||||
|  | /// This generates a nested list as: | ||||||
|  | /// | ||||||
|  | ///   (keyframe-name, index, start time) | ||||||
|  | /// | ||||||
|  | /// The duration of each frame is taken from the internal $delays in a | ||||||
|  | /// round robin fashion, to give some amount of human-like variance to | ||||||
|  | /// the duration of each frame. | ||||||
|  | /// | ||||||
|  | /// Start time is set to the time at which the frame should start to | ||||||
|  | /// achieve the desired frame-by-frame duration. | ||||||
|  | @function _generate-animations($number, $total_duration) { | ||||||
|  |   $id: d66fa0449c0b4d4ca287f8c96428af928b2987b4d88b72b7d60152d9a55d9f29; | ||||||
|  |   $out: (); | ||||||
|  |   $sep: list.separator($out); | ||||||
|  | 
 | ||||||
|  |   // A set of "human-like" delays for each typed character. In | ||||||
|  |   // practice, my typing seems to be about 20-70ms, but it looks a bit | ||||||
|  |   // nicer to increase all typing by 20ms to make the effect more | ||||||
|  |   // noticeable. | ||||||
|  |   // | ||||||
|  |   // Numbers generated once with a random number generator, rather | ||||||
|  |   // than using `math.random()`, since they end up in CSS verbatim, | ||||||
|  |   // and the build would be non-reproducible if we didn't do it this | ||||||
|  |   // way. Using `math.random() wouldn't change this dynamically each | ||||||
|  |   // time the page loads anyway, so we don't really lose anything by | ||||||
|  |   // pre-generating these numbers. | ||||||
|  |   $delays: 69ms, 83ms, 49ms, 48ms, 52ms, 59ms, 40ms, 71ms, 80ms, 67ms; | ||||||
|  | 
 | ||||||
|  |   @for $animation from 0 through $number { | ||||||
|  |     $out: list.append($out, ( | ||||||
|  |       type-#{$id}-#{$animation}, | ||||||
|  |       $animation, | ||||||
|  |       _sum(_round_robin($delays, $animation)) | ||||||
|  |     ), $sep); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @return $out; | ||||||
|  | } | ||||||
|  | @ -1,6 +1,17 @@ | ||||||
| @import "./_custom-bulma"; | @import "./_custom-bulma"; | ||||||
|  | @import "./_typed"; | ||||||
| 
 | 
 | ||||||
| html, body { | html, body { | ||||||
|   height: 100%; |   height: 100%; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | #typed-welcome { | ||||||
|  |   &::before { | ||||||
|  |     @include typed("Welcome to tlater.net!", 1.2s); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &::after { | ||||||
|  |     @include cursor(6s); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
		Reference in a new issue