1// Typer â a character-by-character text reveal where each glyph ripples through a 2// pool of randomized visual states (filled pill, inverse, accent, outlined pillâ¦) 3// before settling into plain text. Adjacent same-state chars merge into one 4// rounded bar (that corner-merging is pure CSS, see components/typer.css). 5// Ported to plain JS (classic script) from the standalone TS source. 6 7const WHITESPACE_RUN = /(\s+)/; 8 9const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), hi); 10 11const roundToStep = (v, step) => Math.round(v / step) * step; 12 13const remap = (v, inLo, inHi, outLo, outHi) => 14 ((v - inLo) * (outHi - outLo)) / (inHi - inLo) + outLo; 15 16// solve a cubic bezier easing y for a given x; endpoints fixed at (0,0)(1,1). 17function bezierEase(x, x1, y1, x2, y2, eps = 1e-6) { 18 const bx = (u) => 19 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3; 20 const by = (u) => 21 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3; 22 const bxDeriv = (u) => 23 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2); 24 25 let t = x; 26 for (let i = 0; i < 8; i += 1) { 27 const dx = bx(t) - x; 28 if (Math.abs(dx) < eps) { 29 return by(t); 30 } 31 const d = bxDeriv(t); 32 if (Math.abs(d) < 1e-6) { 33 break; 34 } 35 t -= dx / d; 36 } 37 let lo = 0, 38 hi = 1; 39 t = x; 40 while (lo < hi) { 41 const cx = bx(t); 42 if (Math.abs(cx - x) < eps) { 43 return by(t); 44 } 45 if (cx < x) { 46 lo = t; 47 } else { 48 hi = t; 49 } 50 t = (lo + hi) / 2; 51 } 52 return by(t); 53} 54 55const ALL_VARIATIONS = [ 56 "charFill", 57 "charInverse", 58 "charAccent", 59 "charAccentInverse", 60 "charAccentFill", 61 "charBorder", 62]; 63 64class Typer { 65 constructor(element, opts = {}) { 66 this.element = element; 67 this.source = element.textContent || ""; 68 this.length = this.source.replace(/\s/g, "").length; 69 this.fps = opts.fps ?? 20; 70 this.cycles = opts.cycles ?? 3; 71 this.cycleLength = opts.cycleLength ?? 0.5; 72 this.frames = this.length ? this.fps * (1 + this.length * 0.01) : 0; 73 this.frame = 0; 74 this.loop = null; 75 this.delay = opts.delay ?? 0; 76 this.delayTimer = null; 77 this.charNodes = []; 78 this.type = "initial"; 79 this.divisor = this.length > 1 ? this.length - 1 : 1; 80 this.denominator = this.frames - this.frames * this.cycleLength || 1; 81 this.variations = (opts.variations ?? [...ALL_VARIATIONS]).slice(); 82 this.shuffle(); 83 this.initVisible = opts.initVisible ?? false; 84 85 if (this.length) { 86 this.build(); 87 if (this.initVisible) { 88 for (const n of this.charNodes) { 89 this.setClass(n, "char"); 90 } 91 this.type = "done"; 92 this.element.dataset.typerType = "done"; 93 } else { 94 this.applyFrame(); 95 this.element.dataset.typerType = "initial"; 96 } 97 } 98 } 99 100 build() { 101 this.element.innerHTML = ""; 102 this.charNodes = []; 103 const parts = this.source.split(WHITESPACE_RUN); 104 let i = 0; 105 for (const part of parts) { 106 if (part.trim() === "") { 107 this.element.append(document.createTextNode(part)); 108 continue; 109 } 110 const word = document.createElement("span"); 111 word.className = "word"; 112 for (const ch of part.split("")) { 113 const pos = i / this.divisor; 114 const cp = roundToStep(bezierEase(pos, 0, 0.75, 0.75, 0), 0.05); 115 const span = document.createElement("span"); 116 span.className = "char charInit"; 117 span.textContent = ch || " "; 118 // Give each glyph its own pastel from the site palette so the ripple 119 // reads as the semi-rainbow theme instead of a knockout white. 120 span.style.setProperty( 121 "--char-color", 122 window.pastelColors[ 123 Math.floor(Math.random() * window.pastelColors.length) 124 ] 125 ); 126 this.charNodes.push({ cp, currentClass: "char charInit", el: span }); 127 i += 1; 128 word.appendChild(span); 129 } 130 this.element.appendChild(word); 131 } 132 } 133 134 in() { 135 this.setType("in"); 136 } 137 138 setType(t) { 139 if (t === this.type) { 140 return; 141 } 142 this.type = t; 143 this.element.dataset.typerType = t; 144 this.stopLoop(); 145 this.frame = 0; 146 this.applyFrame(); 147 if (t !== "initial" && this.charNodes.length) { 148 this.startLoop(); 149 } 150 } 151 152 startLoop() { 153 if (this.loop || this.delayTimer || !this.charNodes.length) { 154 return; 155 }
156 if (this.type === "initial") { 157 return; 158 } 159 this.shuffle(); 160 const begin = () => { 161 this.delayTimer = null; 162 if (this.loop || this.type === "initial") { 163 return; 164 } 165 this.applyFrame(); 166 this.loop = window.setInterval(() => this.tick(), 1000 / this.fps); 167 }; 168 if (this.delay > 0) { 169 this.delayTimer = window.setTimeout(begin, this.delay * 1000); 170 } else { 171 begin(); 172 } 173 } 174 175 stopLoop() { 176 if (this.delayTimer) { 177 window.clearTimeout(this.delayTimer); 178 this.delayTimer = null; 179 } 180 if (this.loop) { 181 window.clearInterval(this.loop); 182 this.loop = null; 183 } 184 } 185 186 tick() { 187 this.frame += 1; 188 this.frame = clamp(this.frame, 0, this.frames); 189 this.applyFrame(); 190 if (this.frame >= this.frames) { 191 this.stopLoop(); 192 this.type = "done"; 193 this.element.dataset.typerType = "done"; 194 } 195 } 196 197 applyFrame() { 198 if (!(this.length && this.charNodes.length)) { 199 return; 200 } 201 if (this.type === "initial") { 202 for (const n of this.charNodes) { 203 this.setClass(n, "char charInit"); 204 } 205 return; 206 } 207 const progress = this.frame / this.denominator; 208 209 for (const node of this.charNodes) { 210 let p = progress - node.cp; 211 p = roundToStep(p, 0.1); 212 p = clamp(p, 0, 1); 213 214 let variation = "charInit"; 215 if (p > 0) { 216 const idx = Math.round(remap(p, 0, 1, 0, this.cycles)); 217 variation = this.variations[idx % this.variations.length]; 218 } 219 if (p >= 1) { 220 variation = ""; 221 } 222 let cls = variation ? `char ${variation}` : "char"; 223 if (p <= 0) { 224 cls = "char charInit"; 225 } else if (p >= 1) { 226 cls = "char"; 227 } 228 this.setClass(node, cls); 229 } 230 } 231 232 setClass(node, cls) { 233 if (cls === node.currentClass) { 234 return; 235 } 236 node.currentClass = cls; 237 node.el.className = cls; 238 } 239 240 shuffle() { 241 this.variations.sort(() => 0.5 - Math.random()); 242 } 243} 244 245// Expose so updates.js (the name-click handler) can drive it. 246window.Typer = Typer;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.