animate-text-weight 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # animateTextWeight
2
+
3
+ Animate a headline, button or link between two weights of a variable font.
4
+ The text can span one line or several, but it works best on short text.
5
+ Body text animates fine in Chrome, but jitters in Firefox.
6
+
7
+ - The element keeps its default `from-weight` width, so no line moves and no line break changes.
8
+ - The `from` state keeps your letter-spacing. The `to` state gets
9
+ spacing matched per line, so every line ends where it started.
10
+
11
+ Everything you write lives in CSS, in your components. There is no
12
+ class needed on the elements you want to animate, because the script finds the
13
+ animated elements on its own. That also makes it work with any CMS: nothing is
14
+ pre-rendered, the browser measures at runtime.
15
+
16
+ ## Sandbox
17
+
18
+ Run `npm run demo` and open `http://localhost:3000/demo/`. Type any text,
19
+ set width, size and the two weights, hover the preview.
20
+
21
+ ## Use
22
+
23
+ Import the stylesheet and start the script once per page:
24
+
25
+ ```js
26
+ import "animate-text-weight/style.css";
27
+ import { init } from "animate-text-weight";
28
+ init();
29
+ ```
30
+
31
+ Set the `to` weight on the element, then switch it on wherever you like.
32
+ The element's own `font-weight` is the `from` weight:
33
+
34
+ ```css
35
+ h1 {
36
+ font-weight: 200;
37
+ --animate-weight-to: 700;
38
+ }
39
+ section:hover h1,
40
+ section.active h1 {
41
+ --animate-weight: true;
42
+ }
43
+ ```
44
+
45
+ `--animate-weight` inherits, so you can set it on the section instead of
46
+ each heading. `--animate-weight-from` overrides the `from` weight when you
47
+ need it. Duration and easing: `--animate-weight-duration` and
48
+ `--animate-weight-easing` on `:root`.
49
+
50
+ ## Animate other properties with the weight animation
51
+
52
+ The span that animates carries class `animate-text-weight-unit` and tweens
53
+ `--animate-weight-progress` on itself from 0 to 1. Read that value to tie any
54
+ other property to the same curve — here the text fades in as it gets bolder:
55
+
56
+ ```css
57
+ .animate-text-weight-unit {
58
+ opacity: clamp(0, var(--animate-weight-progress), 1);
59
+ }
60
+ ```
61
+
62
+ This works for character animations too, with nothing added. In character mode
63
+ the unit is the character, so each one carries its own progress and its own
64
+ delay, and the fade arrives as a wave:
65
+
66
+ ```css
67
+ h1 {
68
+ font-weight: 200;
69
+ --animate-weight-to: 700;
70
+ --animate-weight-by: character;
71
+ --animate-weight-delay: 40ms;
72
+ }
73
+ ```
74
+
75
+ Clamp anything you read, the way the library does: an easing may overshoot 0
76
+ or 1.
@@ -0,0 +1,58 @@
1
+ /** Class of one rendered line, styled by animateTextWeight.css. */
2
+ export declare const LINE = "animate-text-weight-line";
3
+ /** Class of whatever animates: the line, or one character inside it. */
4
+ export declare const UNIT = "animate-text-weight-unit";
5
+ /** Class of one character, which gives it a box of its own. */
6
+ export declare const CHAR = "animate-text-weight-char";
7
+ /** What one unit is: a whole line, or a single character. */
8
+ export type Mode = "line" | "character";
9
+ export interface Unit {
10
+ text: string;
11
+ /** Its kerned advance at the from weight, px. */
12
+ target: number;
13
+ /** The box it is given at each stop, px. These add up to the line's target. */
14
+ boxes: number[];
15
+ /** How many delays this unit waits before it starts. */
16
+ delayCount: number;
17
+ }
18
+ export interface Line {
19
+ text: string;
20
+ /** The width the line is held at, px. */
21
+ target: number;
22
+ /** What the line would measure at each stop, left alone, px. */
23
+ widths: number[];
24
+ /** Line mode: its letter-spacing at each stop, px. Empty in character mode. */
25
+ spacing: number[];
26
+ /** How many delays it waits. Zero in character mode: its characters wait. */
27
+ delayCount: number;
28
+ /** One per character in character mode, empty in line mode. */
29
+ units: Unit[];
30
+ }
31
+ export interface Report {
32
+ from: number;
33
+ to: number;
34
+ /** What was actually split, which can be less than asked for. */
35
+ by: Mode;
36
+ /** The weight at each stop, from first to last. */
37
+ weights: number[];
38
+ lines: Line[];
39
+ }
40
+ /**
41
+ * The weight range set in CSS, or null when the element does not animate.
42
+ * --animate-weight-from is optional: the element's font-weight is the from weight.
43
+ */
44
+ export declare function range(el: Element): {
45
+ from: number;
46
+ to: number;
47
+ by: Mode;
48
+ } | null;
49
+ /** Elements under root that carry a weight range and hold text directly. */
50
+ export declare function findAll(root?: ParentNode): HTMLElement[];
51
+ /** Words of the live element grouped by rendered line. */
52
+ export declare function readLines(el: HTMLElement): string[];
53
+ /** Split one element into its lines, then its units, and write their values. */
54
+ export declare function calibrate(el: HTMLElement): Report | null;
55
+ /** Calibrate every animated element under root, now and after each resize. */
56
+ export declare function calibrateAll(root?: ParentNode): HTMLElement[];
57
+ /** Wait for the fonts, then calibrate the page. */
58
+ export declare function init(root?: ParentNode): Promise<HTMLElement[]>;
package/dist/index.js ADDED
@@ -0,0 +1,330 @@
1
+ /*
2
+ * animateTextWeight: measure each rendered line of an animated element and
3
+ * write the numbers that keep it at its width at every weight.
4
+ *
5
+ * A string's width at a weight depends on its letters, so no table per font
6
+ * can hold every text to the same width, and one spacing per element can
7
+ * hold one line only. So the element is split into its rendered lines, one
8
+ * span per line. A line that animates whole gets eleven --atw-ls-<stop>
9
+ * letter-spacings; a line that animates by character is split again and each
10
+ * character gets eleven --atw-w-<stop> box widths, which add up to the line's
11
+ * width at every stop. animateTextWeight.css reads them.
12
+ *
13
+ * The element says what it wants in CSS: --animate-weight-from and
14
+ * --animate-weight-to. The script finds every element that carries both.
15
+ *
16
+ * Line breaks are read from the live element with a Range. The spans are
17
+ * nowrap and joined by <br>, so the lines are frozen: they can get wider or
18
+ * narrower, never reflow. Runs again on resize, from the original content,
19
+ * which is kept aside.
20
+ */
21
+ /** Class of one rendered line, styled by animateTextWeight.css. */
22
+ export const LINE = "animate-text-weight-line";
23
+ /** Class of whatever animates: the line, or one character inside it. */
24
+ export const UNIT = "animate-text-weight-unit";
25
+ /** Class of one character, which gives it a box of its own. */
26
+ export const CHAR = "animate-text-weight-char";
27
+ /**
28
+ * The CSS map has STOPS values, --atw-ls-0 to --atw-ls-(STOPS - 1).
29
+ *
30
+ * An odd number, so the middle weight is one of them. A variable font is drawn
31
+ * at a few weights and interpolated between them, and the middle of the range
32
+ * is very often one of those drawings: measure it and the line holds all the
33
+ * way through, miss it and the line dips by about two pixels halfway.
34
+ */
35
+ const STOPS = 11;
36
+ /** Original child nodes of each split element, so it can be split again. */
37
+ const sources = new WeakMap();
38
+ /**
39
+ * The weight range set in CSS, or null when the element does not animate.
40
+ * --animate-weight-from is optional: the element's font-weight is the from weight.
41
+ */
42
+ export function range(el) {
43
+ const cs = getComputedStyle(el);
44
+ const from = Number(cs.getPropertyValue("--animate-weight-from")) || Number(cs.fontWeight);
45
+ const to = Number(cs.getPropertyValue("--animate-weight-to"));
46
+ if (!from || !to || from === to)
47
+ return null;
48
+ const by = cs.getPropertyValue("--animate-weight-by").trim() === "character" ? "character" : "line";
49
+ return { from, to, by };
50
+ }
51
+ /** Elements under root that carry a weight range and hold text directly. */
52
+ export function findAll(root = document) {
53
+ const out = [];
54
+ for (const el of root.querySelectorAll("*")) {
55
+ if (el.classList.contains(LINE))
56
+ continue;
57
+ const hasText = [...el.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && /\S/.test(n.textContent ?? ""));
58
+ if (hasText && range(el))
59
+ out.push(el);
60
+ }
61
+ return out;
62
+ }
63
+ /** Words of the live element grouped by rendered line. */
64
+ export function readLines(el) {
65
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
66
+ const lines = [];
67
+ const rangeOf = document.createRange();
68
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
69
+ const text = node.textContent ?? "";
70
+ const re = /\S+/g;
71
+ for (let m = re.exec(text); m; m = re.exec(text)) {
72
+ rangeOf.setStart(node, m.index);
73
+ rangeOf.setEnd(node, m.index + m[0].length);
74
+ const rect = rangeOf.getBoundingClientRect();
75
+ const line = lines.find((l) => Math.abs(l.top - rect.top) < 1);
76
+ if (line)
77
+ line.words.push(m[0]);
78
+ else
79
+ lines.push({ top: rect.top, words: [m[0]] });
80
+ }
81
+ }
82
+ return lines.sort((a, b) => a.top - b.top).map((l) => l.words.join(" "));
83
+ }
84
+ /**
85
+ * Put the original content back, so lines can be read afresh. Content that
86
+ * is not split any more was replaced from outside: that is the new source.
87
+ */
88
+ function restore(el) {
89
+ const source = sources.get(el);
90
+ if (source && el.querySelector(`.${LINE}`))
91
+ el.replaceChildren(...source.map((n) => n.cloneNode(true)));
92
+ else
93
+ sources.set(el, [...el.childNodes].map((n) => n.cloneNode(true)));
94
+ }
95
+ /**
96
+ * One value per stop, as --atw-<name>-<stop>.
97
+ *
98
+ * Three decimals. More makes Firefox shimmer, fewer leaves 0.04px gaps.
99
+ */
100
+ function values(span, name, list) {
101
+ list.forEach((v, k) => span.style.setProperty(`--atw-${name}-${k}`, `${v.toFixed(3)}px`));
102
+ }
103
+ /** Switch a span on: the weights it moves between, and its place in the wave. */
104
+ function unit(span, from, to, delayCount) {
105
+ span.style.setProperty("--atw-from", String(from));
106
+ span.style.setProperty("--atw-to", String(to));
107
+ span.style.setProperty("--atw-delay-count", String(delayCount));
108
+ span.classList.add(UNIT);
109
+ }
110
+ /** Graphemes of a text, so an accent or an emoji stays whole. */
111
+ function graphemes(text) {
112
+ if (typeof Intl.Segmenter !== "function")
113
+ return null;
114
+ const parts = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text);
115
+ return [...parts].map((p) => p.segment);
116
+ }
117
+ /**
118
+ * How far each part of a text node sits from the one before it, kerning and
119
+ * all. Read as growing prefixes: the widths then add up to the line's own
120
+ * width by construction, and right-to-left text works, where subtracting
121
+ * left edges would give nonsense.
122
+ */
123
+ function advances(node, parts) {
124
+ const span = document.createRange();
125
+ span.setStart(node, 0);
126
+ let end = 0;
127
+ let before = 0;
128
+ return parts.map((part) => {
129
+ end += part.length;
130
+ span.setEnd(node, end);
131
+ const upToHere = span.getBoundingClientRect().width;
132
+ const advance = upToHere - before;
133
+ before = upToHere;
134
+ return advance;
135
+ });
136
+ }
137
+ /**
138
+ * How many places a span takes its letter-spacing, counted by probing rather
139
+ * than by counting letters. A browser puts one after every character the font
140
+ * draws, and a ligature is one character however many letters went into it, so
141
+ * the text alone does not say. width is the span's width at rest spacing.
142
+ */
143
+ function slots(span, rest, width) {
144
+ const probe = 10;
145
+ span.style.letterSpacing = `${rest + probe}px`;
146
+ const wide = span.getBoundingClientRect().width;
147
+ span.style.letterSpacing = `${rest}px`;
148
+ return Math.max(Math.round((wide - width) / probe), 1);
149
+ }
150
+ /**
151
+ * How much room every unit asks for at every stop, and what the line would
152
+ * measure at each stop if nothing held it.
153
+ *
154
+ * A unit asks for its own kerned advance at that stop, less a reserve. The
155
+ * reserve is one number for the whole line, so a unit's request depends on
156
+ * nothing but itself and its own weight — which is what lets the characters
157
+ * run out of step without upsetting the line.
158
+ *
159
+ * Asking for too little is the point. Whatever a line has left over, flexbox
160
+ * hands out in equal parts, so each unit ends up with its own advance less an
161
+ * even share of what the line is over by right now. At rest that share is
162
+ * nothing, at the end it is what line mode's letter-spacing takes off, and in
163
+ * between it is whatever it has to be for the line to stay the same width.
164
+ *
165
+ * The reserve itself cancels out of that, so its size does not matter as long
166
+ * as there is always something left to hand out. It is taken from the widest
167
+ * every unit ever gets, one by one, because they do not all get widest at the
168
+ * same weight: a word space is at its widest at the lightest.
169
+ */
170
+ function fit(advances, target) {
171
+ // At least one unit: splitChars returns early on a line that has none.
172
+ const count = advances.length;
173
+ const widths = (advances[0] ?? []).map((_, k) => advances.reduce((sum, a) => sum + (a[k] ?? 0), 0));
174
+ const widest = advances.reduce((sum, own) => sum + Math.max(...own), 0);
175
+ const reserve = Math.max(widest - target, 0) / count;
176
+ return { boxes: advances.map((own) => own.map((advance) => advance - reserve)), widths };
177
+ }
178
+ /**
179
+ * Split one line into character units and write their stops, or null when the
180
+ * text cannot be split safely and the line must stay whole.
181
+ *
182
+ * Kerning is measured before the split and handed back after it, as the box
183
+ * each character is given: the box is the advance the character had while the
184
+ * line was still one piece, at that weight, less its share of the line's
185
+ * growth. See fit().
186
+ */
187
+ function splitChars(line, text, weights, from, to, base, target) {
188
+ const parts = graphemes(text);
189
+ const node = line.firstChild;
190
+ if (!parts || !(node instanceof Text))
191
+ return null;
192
+ // Every grapheme's kerned advance at every stop, while the line is still
193
+ // one piece. One layout per stop, and the prefix sums are exact by
194
+ // construction.
195
+ //
196
+ // Ligatures off, because each character will be drawn in a box of its own
197
+ // and cannot ligate there. Measured with them on, an fi would give two boxes
198
+ // of half a ligature each, to hold a whole f and a whole i.
199
+ line.style.fontFeatureSettings = '"liga" 0, "clig" 0';
200
+ const perStop = weights.map((weight) => {
201
+ line.style.fontWeight = String(weight);
202
+ return advances(node, parts);
203
+ });
204
+ line.style.removeProperty("font-weight");
205
+ line.style.removeProperty("font-feature-settings");
206
+ // A grapheme that takes no advance of its own belongs to the one before it
207
+ // and shares its box. It still carries the element's own spacing, so what
208
+ // counts as no advance is measured against that. The grouping is decided at
209
+ // the from state and holds for every stop.
210
+ const units = [];
211
+ (perStop[0] ?? []).forEach((advance, i) => {
212
+ const open = units.at(-1);
213
+ if (open && advance - base < 0.01) {
214
+ open.text += parts[i] ?? "";
215
+ open.parts += 1;
216
+ }
217
+ else
218
+ units.push({ text: parts[i] ?? "", start: i, parts: 1 });
219
+ });
220
+ if (!units.length)
221
+ return null;
222
+ // A unit's advance is its graphemes', added up.
223
+ const kerned = units.map((unit) => perStop.map((row) => row.slice(unit.start, unit.start + unit.parts).reduce((sum, a) => sum + a, 0)));
224
+ const { boxes, widths } = fit(kerned, target);
225
+ let count = -1;
226
+ const made = units.map((u, i) => {
227
+ const span = document.createElement("span");
228
+ span.className = CHAR;
229
+ span.textContent = u.text;
230
+ // A space keeps the count of the letter before it, so the wave holds its
231
+ // beat across a word gap instead of stalling on something invisible.
232
+ const delayCount = /\S/.test(u.text) ? ++count : Math.max(count, 0);
233
+ values(span, "w", boxes[i] ?? []);
234
+ unit(span, from, to, delayCount);
235
+ return { span, out: { text: u.text, target: kerned[i]?.[0] ?? 0, boxes: boxes[i] ?? [], delayCount } };
236
+ });
237
+ // The width the line is held at, and the only thing that decides it.
238
+ line.style.setProperty("--atw-width", `${target.toFixed(3)}px`);
239
+ line.replaceChildren(...made.map((m) => m.span));
240
+ return { units: made.map((m) => m.out), widths };
241
+ }
242
+ /** Split one element into its lines, then its units, and write their values. */
243
+ export function calibrate(el) {
244
+ const r = range(el);
245
+ if (!r)
246
+ return null;
247
+ const { from, to } = r;
248
+ const weights = Array.from({ length: STOPS }, (_, k) => from + ((to - from) * k) / (STOPS - 1));
249
+ // The element's own spacing is the from state; every stop adds to it.
250
+ const base = parseFloat(getComputedStyle(el).letterSpacing) || 0;
251
+ // Lines break at the from weight, so the element must sit there.
252
+ if (Number(getComputedStyle(el).fontWeight) !== from)
253
+ el.style.fontWeight = String(from);
254
+ restore(el);
255
+ const texts = readLines(el);
256
+ const spans = texts.map((text) => {
257
+ const span = document.createElement("span");
258
+ span.className = LINE;
259
+ span.textContent = text;
260
+ return span;
261
+ });
262
+ el.replaceChildren(...spans.flatMap((s, i) => (i ? [document.createElement("br"), s] : [s])));
263
+ // Falls back to whole lines if a line cannot be split into characters.
264
+ let by = r.by;
265
+ // Measure in place, before the next paint, with the tween switched off.
266
+ const lines = spans.map((span, i) => {
267
+ const text = texts[i] ?? "";
268
+ span.style.transition = "none";
269
+ span.style.letterSpacing = `${base}px`;
270
+ const widthAt = (w) => {
271
+ span.style.fontWeight = String(w);
272
+ return span.getBoundingClientRect().width;
273
+ };
274
+ const target = widthAt(from);
275
+ span.style.removeProperty("font-weight");
276
+ const split = by === "character" ? splitChars(span, text, weights, from, to, base, target) : null;
277
+ if (by === "character" && !split)
278
+ by = "line";
279
+ const done = () => {
280
+ span.style.removeProperty("letter-spacing");
281
+ span.style.removeProperty("transition");
282
+ };
283
+ if (split) {
284
+ done();
285
+ // The line only holds the break now; its characters carry the tween, and
286
+ // their boxes carry the width.
287
+ return { text, target, widths: split.widths, spacing: [], delayCount: 0, units: split.units };
288
+ }
289
+ // The slot count is measured at every stop, not counted from the text: a
290
+ // ligature takes the spacing once, and whether the font makes one can
291
+ // depend on the weight.
292
+ const widths = [];
293
+ const counts = [];
294
+ for (const w of weights) {
295
+ const width = widthAt(w);
296
+ widths.push(width);
297
+ counts.push(slots(span, base, width));
298
+ }
299
+ const spacing = widths.map((w, k) => base + (target - w) / (counts[k] ?? 1));
300
+ values(span, "ls", spacing);
301
+ // One line, one delay: the block arrives line by line.
302
+ unit(span, from, to, i);
303
+ span.style.removeProperty("font-weight");
304
+ done();
305
+ return { text, target, widths, spacing, delayCount: i, units: [] };
306
+ });
307
+ return { from, to, by, weights, lines };
308
+ }
309
+ /** Calibrate every animated element under root, now and after each resize. */
310
+ export function calibrateAll(root = document) {
311
+ const els = findAll(root);
312
+ els.forEach((el) => calibrate(el));
313
+ let width = innerWidth;
314
+ let pending = 0;
315
+ // A line is laid out once per stop, and character mode reads every character
316
+ // out of each of those, so a dragged window edge must not recalibrate on
317
+ // every event it fires.
318
+ addEventListener("resize", () => {
319
+ if (innerWidth === width)
320
+ return;
321
+ width = innerWidth;
322
+ clearTimeout(pending);
323
+ pending = setTimeout(() => els.forEach((el) => calibrate(el)), 150);
324
+ });
325
+ return els;
326
+ }
327
+ /** Wait for the fonts, then calibrate the page. */
328
+ export function init(root = document) {
329
+ return document.fonts.ready.then(() => calibrateAll(root));
330
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "animate-text-weight",
3
+ "version": "0.1.0",
4
+ "description": "Animate short text between two weights of a variable font without moving a single line.",
5
+ "license": "MIT",
6
+ "author": "Tim Schoch",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/timschoch/animateTextWeight.git"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./style.css": "./src/animateTextWeight.css"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "prepare": "husky && tsc",
26
+ "demo": "tsc && npx -y serve -l 3000 ."
27
+ },
28
+ "devDependencies": {
29
+ "husky": "^9.1.7",
30
+ "lint-staged": "^17.5.0",
31
+ "prettier": "^3.9.6",
32
+ "typescript": "^6.0.3"
33
+ }
34
+ }
@@ -0,0 +1,218 @@
1
+ /*
2
+ * animateTextWeight: tween a variable font between two weights and keep
3
+ * every line at its width.
4
+ *
5
+ * You write three things, all in CSS, on the element:
6
+ * --animate-weight-from: 200; the weight it animates from
7
+ * --animate-weight-to: 700; the weight it animates to
8
+ * --animate-weight: true; on any state or ancestor: switch it on
9
+ *
10
+ * And two more if you want the text to arrive in a wave:
11
+ * --animate-weight-by: character; one unit per character, not per line
12
+ * --animate-weight-delay: 40ms; one unit waits this long after the one before it
13
+ *
14
+ * The script (index.ts) splits the element into one span per rendered
15
+ * line, class .animate-text-weight-line, and in character mode splits each
16
+ * of those again into one span per character. Whichever span animates gets
17
+ * class .animate-text-weight-unit and carries the two weights, its delay
18
+ * count, and eleven values, one per stop between the two weights. A unit
19
+ * tweens --animate-weight-progress from 0 to 1, and its weight follows.
20
+ *
21
+ * How the width is held differs by mode.
22
+ *
23
+ * A line holds its own, with eleven letter-spacing values, --atw-ls-0 to
24
+ * --atw-ls-10, that take back what the line gains as it gets bolder.
25
+ *
26
+ * A character asks for room instead, eleven widths, --atw-w-0 to --atw-w-10,
27
+ * used as its flex basis. Its line is a flex line of a fixed width,
28
+ * --atw-width, and every character asks for slightly less than it needs, so
29
+ * there is always room left over for flexbox to hand out in equal parts. The
30
+ * line's width is a number, so nothing the characters do can move it, and the
31
+ * equal parts are what a letter-spacing on a whole line does.
32
+ */
33
+ @property --animate-weight-from {
34
+ syntax: "<number>";
35
+ inherits: false;
36
+ initial-value: 0;
37
+ }
38
+ @property --animate-weight-to {
39
+ syntax: "<number>";
40
+ inherits: false;
41
+ initial-value: 0;
42
+ }
43
+ @property --animate-weight {
44
+ syntax: "true | false";
45
+ inherits: true;
46
+ initial-value: false;
47
+ }
48
+ @property --animate-weight-progress {
49
+ syntax: "<number>";
50
+ inherits: false;
51
+ initial-value: 0;
52
+ }
53
+ /* These two are set on the element but read on a unit inside it. */
54
+ @property --animate-weight-by {
55
+ syntax: "line | character";
56
+ inherits: true;
57
+ initial-value: line;
58
+ }
59
+ @property --animate-weight-delay {
60
+ syntax: "<time>";
61
+ inherits: true;
62
+ initial-value: 0s;
63
+ }
64
+ /* How many delays this unit waits: the count of the one before it, plus one. */
65
+ @property --atw-delay-count {
66
+ syntax: "<number>";
67
+ inherits: false;
68
+ initial-value: 0;
69
+ }
70
+ /* The width a line is held at. */
71
+ @property --atw-width {
72
+ syntax: "<length>";
73
+ inherits: false;
74
+ initial-value: 0px;
75
+ }
76
+
77
+ :root {
78
+ --animate-weight-duration: 300ms;
79
+ --animate-weight-easing: ease;
80
+ }
81
+
82
+ .animate-text-weight-line {
83
+ /* A line can get wider or narrower, never reflow. */
84
+ white-space: nowrap;
85
+ /* Fractional glyph advances, so Firefox does not snap letters mid-tween. */
86
+ text-rendering: geometricPrecision;
87
+ /*
88
+ * A browser drops common ligatures the moment a letter-spacing is anything
89
+ * but zero. The spacing here is zero at the from weight and not zero after
90
+ * it, so an fi would come apart on the first stop and the line would change
91
+ * shape as it moves — and lose the width the lock is holding. Asking for the
92
+ * features by name is what holds them: font-variant-ligatures does not
93
+ * (Chrome 143). Inherited, so a merged unit keeps its ligature too.
94
+ */
95
+ font-feature-settings:
96
+ "liga" 1,
97
+ "clig" 1;
98
+ }
99
+
100
+ /*
101
+ * A line whose characters carry the tween is a flex line of a fixed width.
102
+ *
103
+ * That is what holds it. Every character asks for a little less room than it
104
+ * needs (see the basis below), so the line always has some room left over, and
105
+ * flexbox hands that room out in equal parts. Equal parts is what one
106
+ * letter-spacing over a line does, so the letters fit the way line mode fits
107
+ * them — and because the line's width is a number, not a sum, nothing the
108
+ * characters do inside can move it.
109
+ */
110
+ .animate-text-weight-line:has(> .animate-text-weight-char) {
111
+ display: inline-flex;
112
+ align-items: baseline;
113
+ width: var(--atw-width);
114
+ }
115
+
116
+ /*
117
+ * One character, in a box of its own. The box stops neighbouring glyphs kerning
118
+ * into each other, so what was measured is what is drawn; the kerning they give
119
+ * up is already in the widths below.
120
+ */
121
+ .animate-text-weight-char {
122
+ display: inline-block;
123
+ /* A character can be a lone space, and a lone space must not collapse. */
124
+ white-space: pre;
125
+ /* Take an equal part of whatever room the line has left. */
126
+ flex: 1 1 auto;
127
+ min-width: 0;
128
+ /* The element's own spacing is inside the box widths already. */
129
+ letter-spacing: 0;
130
+ /*
131
+ * No ligatures. A character has its own box, so two of them can never join
132
+ * into one glyph anyway; saying so here is what makes the measurement agree,
133
+ * because the line is still one piece while it is measured and would ligate.
134
+ * A box sized for half of an fi, holding a whole f, would crowd its
135
+ * neighbour. This is the one thing character mode gives up.
136
+ */
137
+ font-feature-settings:
138
+ "liga" 0,
139
+ "clig" 0;
140
+ /*
141
+ * How much room the character asks for: its own kerned advance at its own
142
+ * weight, less a reserve. It is always a little short, which is what leaves
143
+ * the line room to hand out. See fit() in index.ts.
144
+ *
145
+ * Piecewise-linear over the eleven stops. Each clamp() is the progress inside
146
+ * one tenth, 0 to 1; the sum walks the stops up to the current progress and
147
+ * adds each one's change of width.
148
+ */
149
+ flex-basis: calc(
150
+ var(--atw-w-0) + (var(--atw-w-1) - var(--atw-w-0)) *
151
+ clamp(0, var(--animate-weight-progress) * 10 - 0, 1) +
152
+ (var(--atw-w-2) - var(--atw-w-1)) * clamp(0, var(--animate-weight-progress) * 10 - 1, 1) +
153
+ (var(--atw-w-3) - var(--atw-w-2)) * clamp(0, var(--animate-weight-progress) * 10 - 2, 1) +
154
+ (var(--atw-w-4) - var(--atw-w-3)) * clamp(0, var(--animate-weight-progress) * 10 - 3, 1) +
155
+ (var(--atw-w-5) - var(--atw-w-4)) * clamp(0, var(--animate-weight-progress) * 10 - 4, 1) +
156
+ (var(--atw-w-6) - var(--atw-w-5)) * clamp(0, var(--animate-weight-progress) * 10 - 5, 1) +
157
+ (var(--atw-w-7) - var(--atw-w-6)) * clamp(0, var(--animate-weight-progress) * 10 - 6, 1) +
158
+ (var(--atw-w-8) - var(--atw-w-7)) * clamp(0, var(--animate-weight-progress) * 10 - 7, 1) +
159
+ (var(--atw-w-9) - var(--atw-w-8)) * clamp(0, var(--animate-weight-progress) * 10 - 8, 1) +
160
+ (var(--atw-w-10) - var(--atw-w-9)) * clamp(0, var(--animate-weight-progress) * 10 - 9, 1)
161
+ );
162
+ }
163
+
164
+ /* Whatever animates: the line in line mode, one character in character mode. */
165
+ .animate-text-weight-unit {
166
+ /*
167
+ * Clamped, because an easing may overshoot: the spacing below stops at the
168
+ * last stop, so an unclamped weight would keep going and break the width.
169
+ */
170
+ font-weight: calc(
171
+ var(--atw-from) + (var(--atw-to) - var(--atw-from)) *
172
+ clamp(0, var(--animate-weight-progress), 1)
173
+ );
174
+ transition: --animate-weight-progress var(--animate-weight-duration) var(--animate-weight-easing);
175
+ /* After the shorthand, which would reset it. */
176
+ transition-delay: calc(var(--atw-delay-count) * var(--animate-weight-delay));
177
+ }
178
+
179
+ /*
180
+ * A line that animates as one piece holds its width the other way: one
181
+ * letter-spacing, taking back what the whole line gains. Piecewise-linear over
182
+ * the eleven stops, driven by the line's own progress — it is the only unit
183
+ * here, so there is nothing to keep in step with.
184
+ */
185
+ .animate-text-weight-line.animate-text-weight-unit {
186
+ letter-spacing: calc(
187
+ var(--atw-ls-0) + (var(--atw-ls-1) - var(--atw-ls-0)) *
188
+ clamp(0, var(--animate-weight-progress) * 10 - 0, 1) +
189
+ (var(--atw-ls-2) - var(--atw-ls-1)) * clamp(0, var(--animate-weight-progress) * 10 - 1, 1) +
190
+ (var(--atw-ls-3) - var(--atw-ls-2)) * clamp(0, var(--animate-weight-progress) * 10 - 2, 1) +
191
+ (var(--atw-ls-4) - var(--atw-ls-3)) * clamp(0, var(--animate-weight-progress) * 10 - 3, 1) +
192
+ (var(--atw-ls-5) - var(--atw-ls-4)) * clamp(0, var(--animate-weight-progress) * 10 - 4, 1) +
193
+ (var(--atw-ls-6) - var(--atw-ls-5)) * clamp(0, var(--animate-weight-progress) * 10 - 5, 1) +
194
+ (var(--atw-ls-7) - var(--atw-ls-6)) * clamp(0, var(--animate-weight-progress) * 10 - 6, 1) +
195
+ (var(--atw-ls-8) - var(--atw-ls-7)) * clamp(0, var(--animate-weight-progress) * 10 - 7, 1) +
196
+ (var(--atw-ls-9) - var(--atw-ls-8)) * clamp(0, var(--animate-weight-progress) * 10 - 8, 1) +
197
+ (var(--atw-ls-10) - var(--atw-ls-9)) * clamp(0, var(--animate-weight-progress) * 10 - 9, 1)
198
+ );
199
+ }
200
+
201
+ /* The switch. The element is the unit's style container. */
202
+ @container style(--animate-weight: true) {
203
+ .animate-text-weight-unit {
204
+ --animate-weight-progress: 1;
205
+ }
206
+ }
207
+
208
+ /*
209
+ * A delay multiplies the time the text spends moving by the number of units,
210
+ * so this matters more here than in most places.
211
+ */
212
+ @media (prefers-reduced-motion: reduce) {
213
+ /* On the unit, so an element that sets its own values loses to this. */
214
+ .animate-text-weight-unit {
215
+ --animate-weight-duration: 0s;
216
+ --animate-weight-delay: 0s;
217
+ }
218
+ }
package/src/index.ts ADDED
@@ -0,0 +1,388 @@
1
+ /*
2
+ * animateTextWeight: measure each rendered line of an animated element and
3
+ * write the numbers that keep it at its width at every weight.
4
+ *
5
+ * A string's width at a weight depends on its letters, so no table per font
6
+ * can hold every text to the same width, and one spacing per element can
7
+ * hold one line only. So the element is split into its rendered lines, one
8
+ * span per line. A line that animates whole gets eleven --atw-ls-<stop>
9
+ * letter-spacings; a line that animates by character is split again and each
10
+ * character gets eleven --atw-w-<stop> box widths, which add up to the line's
11
+ * width at every stop. animateTextWeight.css reads them.
12
+ *
13
+ * The element says what it wants in CSS: --animate-weight-from and
14
+ * --animate-weight-to. The script finds every element that carries both.
15
+ *
16
+ * Line breaks are read from the live element with a Range. The spans are
17
+ * nowrap and joined by <br>, so the lines are frozen: they can get wider or
18
+ * narrower, never reflow. Runs again on resize, from the original content,
19
+ * which is kept aside.
20
+ */
21
+
22
+ /** Class of one rendered line, styled by animateTextWeight.css. */
23
+ export const LINE = "animate-text-weight-line";
24
+ /** Class of whatever animates: the line, or one character inside it. */
25
+ export const UNIT = "animate-text-weight-unit";
26
+ /** Class of one character, which gives it a box of its own. */
27
+ export const CHAR = "animate-text-weight-char";
28
+ /**
29
+ * The CSS map has STOPS values, --atw-ls-0 to --atw-ls-(STOPS - 1).
30
+ *
31
+ * An odd number, so the middle weight is one of them. A variable font is drawn
32
+ * at a few weights and interpolated between them, and the middle of the range
33
+ * is very often one of those drawings: measure it and the line holds all the
34
+ * way through, miss it and the line dips by about two pixels halfway.
35
+ */
36
+ const STOPS = 11;
37
+
38
+ /** What one unit is: a whole line, or a single character. */
39
+ export type Mode = "line" | "character";
40
+
41
+ /** Original child nodes of each split element, so it can be split again. */
42
+ const sources = new WeakMap<HTMLElement, Node[]>();
43
+
44
+ export interface Unit {
45
+ text: string;
46
+ /** Its kerned advance at the from weight, px. */
47
+ target: number;
48
+ /** The box it is given at each stop, px. These add up to the line's target. */
49
+ boxes: number[];
50
+ /** How many delays this unit waits before it starts. */
51
+ delayCount: number;
52
+ }
53
+
54
+ export interface Line {
55
+ text: string;
56
+ /** The width the line is held at, px. */
57
+ target: number;
58
+ /** What the line would measure at each stop, left alone, px. */
59
+ widths: number[];
60
+ /** Line mode: its letter-spacing at each stop, px. Empty in character mode. */
61
+ spacing: number[];
62
+ /** How many delays it waits. Zero in character mode: its characters wait. */
63
+ delayCount: number;
64
+ /** One per character in character mode, empty in line mode. */
65
+ units: Unit[];
66
+ }
67
+
68
+ export interface Report {
69
+ from: number;
70
+ to: number;
71
+ /** What was actually split, which can be less than asked for. */
72
+ by: Mode;
73
+ /** The weight at each stop, from first to last. */
74
+ weights: number[];
75
+ lines: Line[];
76
+ }
77
+
78
+ /**
79
+ * The weight range set in CSS, or null when the element does not animate.
80
+ * --animate-weight-from is optional: the element's font-weight is the from weight.
81
+ */
82
+ export function range(el: Element): { from: number; to: number; by: Mode } | null {
83
+ const cs = getComputedStyle(el);
84
+ const from = Number(cs.getPropertyValue("--animate-weight-from")) || Number(cs.fontWeight);
85
+ const to = Number(cs.getPropertyValue("--animate-weight-to"));
86
+ if (!from || !to || from === to) return null;
87
+ const by = cs.getPropertyValue("--animate-weight-by").trim() === "character" ? "character" : "line";
88
+ return { from, to, by };
89
+ }
90
+
91
+ /** Elements under root that carry a weight range and hold text directly. */
92
+ export function findAll(root: ParentNode = document): HTMLElement[] {
93
+ const out: HTMLElement[] = [];
94
+ for (const el of root.querySelectorAll<HTMLElement>("*")) {
95
+ if (el.classList.contains(LINE)) continue;
96
+ const hasText = [...el.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && /\S/.test(n.textContent ?? ""));
97
+ if (hasText && range(el)) out.push(el);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /** Words of the live element grouped by rendered line. */
103
+ export function readLines(el: HTMLElement): string[] {
104
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
105
+ const lines: { top: number; words: string[] }[] = [];
106
+ const rangeOf = document.createRange();
107
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
108
+ const text = node.textContent ?? "";
109
+ const re = /\S+/g;
110
+ for (let m = re.exec(text); m; m = re.exec(text)) {
111
+ rangeOf.setStart(node, m.index);
112
+ rangeOf.setEnd(node, m.index + m[0].length);
113
+ const rect = rangeOf.getBoundingClientRect();
114
+ const line = lines.find((l) => Math.abs(l.top - rect.top) < 1);
115
+ if (line) line.words.push(m[0]);
116
+ else lines.push({ top: rect.top, words: [m[0]] });
117
+ }
118
+ }
119
+ return lines.sort((a, b) => a.top - b.top).map((l) => l.words.join(" "));
120
+ }
121
+
122
+ /**
123
+ * Put the original content back, so lines can be read afresh. Content that
124
+ * is not split any more was replaced from outside: that is the new source.
125
+ */
126
+ function restore(el: HTMLElement): void {
127
+ const source = sources.get(el);
128
+ if (source && el.querySelector(`.${LINE}`)) el.replaceChildren(...source.map((n) => n.cloneNode(true)));
129
+ else sources.set(el, [...el.childNodes].map((n) => n.cloneNode(true)));
130
+ }
131
+
132
+ /**
133
+ * One value per stop, as --atw-<name>-<stop>.
134
+ *
135
+ * Three decimals. More makes Firefox shimmer, fewer leaves 0.04px gaps.
136
+ */
137
+ function values(span: HTMLElement, name: string, list: number[]): void {
138
+ list.forEach((v, k) => span.style.setProperty(`--atw-${name}-${k}`, `${v.toFixed(3)}px`));
139
+ }
140
+
141
+ /** Switch a span on: the weights it moves between, and its place in the wave. */
142
+ function unit(span: HTMLElement, from: number, to: number, delayCount: number): void {
143
+ span.style.setProperty("--atw-from", String(from));
144
+ span.style.setProperty("--atw-to", String(to));
145
+ span.style.setProperty("--atw-delay-count", String(delayCount));
146
+ span.classList.add(UNIT);
147
+ }
148
+
149
+ /** Graphemes of a text, so an accent or an emoji stays whole. */
150
+ function graphemes(text: string): string[] | null {
151
+ if (typeof Intl.Segmenter !== "function") return null;
152
+ const parts = new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text);
153
+ return [...parts].map((p) => p.segment);
154
+ }
155
+
156
+ /**
157
+ * How far each part of a text node sits from the one before it, kerning and
158
+ * all. Read as growing prefixes: the widths then add up to the line's own
159
+ * width by construction, and right-to-left text works, where subtracting
160
+ * left edges would give nonsense.
161
+ */
162
+ function advances(node: Text, parts: string[]): number[] {
163
+ const span = document.createRange();
164
+ span.setStart(node, 0);
165
+ let end = 0;
166
+ let before = 0;
167
+ return parts.map((part) => {
168
+ end += part.length;
169
+ span.setEnd(node, end);
170
+ const upToHere = span.getBoundingClientRect().width;
171
+ const advance = upToHere - before;
172
+ before = upToHere;
173
+ return advance;
174
+ });
175
+ }
176
+
177
+ /**
178
+ * How many places a span takes its letter-spacing, counted by probing rather
179
+ * than by counting letters. A browser puts one after every character the font
180
+ * draws, and a ligature is one character however many letters went into it, so
181
+ * the text alone does not say. width is the span's width at rest spacing.
182
+ */
183
+ function slots(span: HTMLElement, rest: number, width: number): number {
184
+ const probe = 10;
185
+ span.style.letterSpacing = `${rest + probe}px`;
186
+ const wide = span.getBoundingClientRect().width;
187
+ span.style.letterSpacing = `${rest}px`;
188
+ return Math.max(Math.round((wide - width) / probe), 1);
189
+ }
190
+
191
+ /**
192
+ * How much room every unit asks for at every stop, and what the line would
193
+ * measure at each stop if nothing held it.
194
+ *
195
+ * A unit asks for its own kerned advance at that stop, less a reserve. The
196
+ * reserve is one number for the whole line, so a unit's request depends on
197
+ * nothing but itself and its own weight — which is what lets the characters
198
+ * run out of step without upsetting the line.
199
+ *
200
+ * Asking for too little is the point. Whatever a line has left over, flexbox
201
+ * hands out in equal parts, so each unit ends up with its own advance less an
202
+ * even share of what the line is over by right now. At rest that share is
203
+ * nothing, at the end it is what line mode's letter-spacing takes off, and in
204
+ * between it is whatever it has to be for the line to stay the same width.
205
+ *
206
+ * The reserve itself cancels out of that, so its size does not matter as long
207
+ * as there is always something left to hand out. It is taken from the widest
208
+ * every unit ever gets, one by one, because they do not all get widest at the
209
+ * same weight: a word space is at its widest at the lightest.
210
+ */
211
+ function fit(advances: number[][], target: number): { boxes: number[][]; widths: number[] } {
212
+ // At least one unit: splitChars returns early on a line that has none.
213
+ const count = advances.length;
214
+ const widths = (advances[0] ?? []).map((_, k) => advances.reduce((sum, a) => sum + (a[k] ?? 0), 0));
215
+ const widest = advances.reduce((sum, own) => sum + Math.max(...own), 0);
216
+ const reserve = Math.max(widest - target, 0) / count;
217
+ return { boxes: advances.map((own) => own.map((advance) => advance - reserve)), widths };
218
+ }
219
+
220
+ /**
221
+ * Split one line into character units and write their stops, or null when the
222
+ * text cannot be split safely and the line must stay whole.
223
+ *
224
+ * Kerning is measured before the split and handed back after it, as the box
225
+ * each character is given: the box is the advance the character had while the
226
+ * line was still one piece, at that weight, less its share of the line's
227
+ * growth. See fit().
228
+ */
229
+ function splitChars(
230
+ line: HTMLElement,
231
+ text: string,
232
+ weights: number[],
233
+ from: number,
234
+ to: number,
235
+ base: number,
236
+ target: number,
237
+ ): { units: Unit[]; widths: number[] } | null {
238
+ const parts = graphemes(text);
239
+ const node = line.firstChild;
240
+ if (!parts || !(node instanceof Text)) return null;
241
+
242
+ // Every grapheme's kerned advance at every stop, while the line is still
243
+ // one piece. One layout per stop, and the prefix sums are exact by
244
+ // construction.
245
+ //
246
+ // Ligatures off, because each character will be drawn in a box of its own
247
+ // and cannot ligate there. Measured with them on, an fi would give two boxes
248
+ // of half a ligature each, to hold a whole f and a whole i.
249
+ line.style.fontFeatureSettings = '"liga" 0, "clig" 0';
250
+ const perStop = weights.map((weight) => {
251
+ line.style.fontWeight = String(weight);
252
+ return advances(node, parts);
253
+ });
254
+ line.style.removeProperty("font-weight");
255
+ line.style.removeProperty("font-feature-settings");
256
+
257
+ // A grapheme that takes no advance of its own belongs to the one before it
258
+ // and shares its box. It still carries the element's own spacing, so what
259
+ // counts as no advance is measured against that. The grouping is decided at
260
+ // the from state and holds for every stop.
261
+ const units: { text: string; start: number; parts: number }[] = [];
262
+ (perStop[0] ?? []).forEach((advance, i) => {
263
+ const open = units.at(-1);
264
+ if (open && advance - base < 0.01) {
265
+ open.text += parts[i] ?? "";
266
+ open.parts += 1;
267
+ } else units.push({ text: parts[i] ?? "", start: i, parts: 1 });
268
+ });
269
+ if (!units.length) return null;
270
+
271
+ // A unit's advance is its graphemes', added up.
272
+ const kerned = units.map((unit) =>
273
+ perStop.map((row) => row.slice(unit.start, unit.start + unit.parts).reduce((sum, a) => sum + a, 0)),
274
+ );
275
+
276
+ const { boxes, widths } = fit(kerned, target);
277
+
278
+ let count = -1;
279
+ const made = units.map((u, i) => {
280
+ const span = document.createElement("span");
281
+ span.className = CHAR;
282
+ span.textContent = u.text;
283
+ // A space keeps the count of the letter before it, so the wave holds its
284
+ // beat across a word gap instead of stalling on something invisible.
285
+ const delayCount = /\S/.test(u.text) ? ++count : Math.max(count, 0);
286
+ values(span, "w", boxes[i] ?? []);
287
+ unit(span, from, to, delayCount);
288
+ return { span, out: { text: u.text, target: kerned[i]?.[0] ?? 0, boxes: boxes[i] ?? [], delayCount } };
289
+ });
290
+
291
+ // The width the line is held at, and the only thing that decides it.
292
+ line.style.setProperty("--atw-width", `${target.toFixed(3)}px`);
293
+ line.replaceChildren(...made.map((m) => m.span));
294
+ return { units: made.map((m) => m.out), widths };
295
+ }
296
+
297
+ /** Split one element into its lines, then its units, and write their values. */
298
+ export function calibrate(el: HTMLElement): Report | null {
299
+ const r = range(el);
300
+ if (!r) return null;
301
+ const { from, to } = r;
302
+ const weights = Array.from({ length: STOPS }, (_, k) => from + ((to - from) * k) / (STOPS - 1));
303
+ // The element's own spacing is the from state; every stop adds to it.
304
+ const base = parseFloat(getComputedStyle(el).letterSpacing) || 0;
305
+ // Lines break at the from weight, so the element must sit there.
306
+ if (Number(getComputedStyle(el).fontWeight) !== from) el.style.fontWeight = String(from);
307
+
308
+ restore(el);
309
+ const texts = readLines(el);
310
+ const spans = texts.map((text) => {
311
+ const span = document.createElement("span");
312
+ span.className = LINE;
313
+ span.textContent = text;
314
+ return span;
315
+ });
316
+ el.replaceChildren(...spans.flatMap((s, i) => (i ? [document.createElement("br"), s] : [s])));
317
+
318
+ // Falls back to whole lines if a line cannot be split into characters.
319
+ let by: Mode = r.by;
320
+ // Measure in place, before the next paint, with the tween switched off.
321
+ const lines = spans.map((span, i): Line => {
322
+ const text = texts[i] ?? "";
323
+ span.style.transition = "none";
324
+ span.style.letterSpacing = `${base}px`;
325
+ const widthAt = (w: number) => {
326
+ span.style.fontWeight = String(w);
327
+ return span.getBoundingClientRect().width;
328
+ };
329
+ const target = widthAt(from);
330
+ span.style.removeProperty("font-weight");
331
+
332
+ const split = by === "character" ? splitChars(span, text, weights, from, to, base, target) : null;
333
+ if (by === "character" && !split) by = "line";
334
+ const done = () => {
335
+ span.style.removeProperty("letter-spacing");
336
+ span.style.removeProperty("transition");
337
+ };
338
+
339
+ if (split) {
340
+ done();
341
+ // The line only holds the break now; its characters carry the tween, and
342
+ // their boxes carry the width.
343
+ return { text, target, widths: split.widths, spacing: [], delayCount: 0, units: split.units };
344
+ }
345
+
346
+ // The slot count is measured at every stop, not counted from the text: a
347
+ // ligature takes the spacing once, and whether the font makes one can
348
+ // depend on the weight.
349
+ const widths: number[] = [];
350
+ const counts: number[] = [];
351
+ for (const w of weights) {
352
+ const width = widthAt(w);
353
+ widths.push(width);
354
+ counts.push(slots(span, base, width));
355
+ }
356
+ const spacing = widths.map((w, k) => base + (target - w) / (counts[k] ?? 1));
357
+ values(span, "ls", spacing);
358
+ // One line, one delay: the block arrives line by line.
359
+ unit(span, from, to, i);
360
+ span.style.removeProperty("font-weight");
361
+ done();
362
+ return { text, target, widths, spacing, delayCount: i, units: [] };
363
+ });
364
+ return { from, to, by, weights, lines };
365
+ }
366
+
367
+ /** Calibrate every animated element under root, now and after each resize. */
368
+ export function calibrateAll(root: ParentNode = document): HTMLElement[] {
369
+ const els = findAll(root);
370
+ els.forEach((el) => calibrate(el));
371
+ let width = innerWidth;
372
+ let pending = 0;
373
+ // A line is laid out once per stop, and character mode reads every character
374
+ // out of each of those, so a dragged window edge must not recalibrate on
375
+ // every event it fires.
376
+ addEventListener("resize", () => {
377
+ if (innerWidth === width) return;
378
+ width = innerWidth;
379
+ clearTimeout(pending);
380
+ pending = setTimeout(() => els.forEach((el) => calibrate(el)), 150);
381
+ });
382
+ return els;
383
+ }
384
+
385
+ /** Wait for the fonts, then calibrate the page. */
386
+ export function init(root: ParentNode = document): Promise<HTMLElement[]> {
387
+ return document.fonts.ready.then(() => calibrateAll(root));
388
+ }