PageSourceSearch

https://aymuos15.github.io/js/typer.js

js aymuos15.github.io collected 2026-10-03 08:40:51 UTC 6,499 bytes, 246 lines download raw bytes

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.