slot-text 0.1.0 → 0.1.1
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 +29 -0
- package/dist/slotText.js +62 -7
- package/package.json +1 -1
- package/style.css +12 -0
package/README.md
CHANGED
|
@@ -147,6 +147,35 @@ Defaults are tuned for a soft, springy roll:
|
|
|
147
147
|
</script>
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
## Font support
|
|
151
|
+
|
|
152
|
+
Each character animates inside its own measured cell, sized with the exact
|
|
153
|
+
font you give the element — so widths are always correct.
|
|
154
|
+
|
|
155
|
+
Works great with:
|
|
156
|
+
|
|
157
|
+
- **Monospace fonts** — perfect fit, every cell is identical.
|
|
158
|
+
- **Proportional Latin / Cyrillic / Greek fonts** (Geist, Inter, SF, …) —
|
|
159
|
+
including italics and glyphs with overhang.
|
|
160
|
+
|
|
161
|
+
Known tradeoffs (inherent to any per-character slot animation):
|
|
162
|
+
|
|
163
|
+
- **Kerning is lost.** Each glyph is its own box, so pairs like `AV` or `To`
|
|
164
|
+
sit slightly looser than in plain text. Invisible at UI label sizes,
|
|
165
|
+
noticeable at large display sizes with kerning-heavy fonts.
|
|
166
|
+
- **Ligatures won't form** — `fi`, `fl`, or coding ligatures stay separate.
|
|
167
|
+
- **Joined scripts are unsupported.** Arabic, Devanagari and other shaping
|
|
168
|
+
scripts need contextual letterforms across the string and will render as
|
|
169
|
+
isolated forms.
|
|
170
|
+
- **Complex emoji split.** Single emoji are fine (surrogate pairs are
|
|
171
|
+
handled), but ZWJ sequences (👨👩👧) and combining marks break into
|
|
172
|
+
multiple cells.
|
|
173
|
+
- **Very tall display/script fonts** may clip at the vertical roll mask,
|
|
174
|
+
which is sized to `line-height: 1.3`.
|
|
175
|
+
|
|
176
|
+
In short: ideal for short Latin labels, numbers, statuses and commands — in
|
|
177
|
+
essentially any font you'd use for those.
|
|
178
|
+
|
|
150
179
|
## Notes
|
|
151
180
|
|
|
152
181
|
- Browser-only DOM utility.
|
package/dist/slotText.js
CHANGED
|
@@ -92,11 +92,13 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
92
92
|
// so the text still rolls instead of swapping in place.
|
|
93
93
|
const sample = slots.find((s) => (s.dataset.char ?? "") !== "") ?? slots[0];
|
|
94
94
|
const cs = getComputedStyle(container);
|
|
95
|
-
|
|
95
|
+
// Ceil, not round: if the slide distance is even half a pixel short of the
|
|
96
|
+
// cell height, a sliver of the outgoing glyph stays visible at the clip edge.
|
|
97
|
+
const H = Math.ceil(sample?.getBoundingClientRect().height ||
|
|
96
98
|
sample?.offsetHeight ||
|
|
97
99
|
container.getBoundingClientRect().height ||
|
|
98
100
|
parseFloat(cs.lineHeight) ||
|
|
99
|
-
0) || Math.
|
|
101
|
+
0) || Math.ceil(parseFloat(cs.fontSize) * 1.3) || 18;
|
|
100
102
|
// Resting color to settle the chromatic flash back to.
|
|
101
103
|
const restColor = color ? cs.color : "";
|
|
102
104
|
// Pre-create any extra cells up front so the row never reflows mid-roll.
|
|
@@ -129,13 +131,34 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
129
131
|
const slot = slots[i];
|
|
130
132
|
const sizer = slot.querySelector(".char-sizer");
|
|
131
133
|
const oldFace = slot.querySelector(".char-face");
|
|
132
|
-
|
|
134
|
+
// Resize the cell to the new glyph — but ease the width instead of
|
|
135
|
+
// snapping it, so a wide outgoing glyph (W → i) is never cropped by a
|
|
136
|
+
// suddenly-narrow cell and neighbouring letters glide rather than jump.
|
|
137
|
+
const oldW = slot.getBoundingClientRect().width;
|
|
138
|
+
sizer.textContent = glyph(toChar);
|
|
139
|
+
const newW = sizer.getBoundingClientRect().width;
|
|
140
|
+
const widthChanges = Math.abs(newW - oldW) > 0.5;
|
|
141
|
+
if (widthChanges)
|
|
142
|
+
slot.style.width = `${oldW}px`;
|
|
143
|
+
// A cell growing from or collapsing to empty changes width drastically —
|
|
144
|
+
// clip it horizontally while it resizes so its glyph wipes in/out with the
|
|
145
|
+
// cell instead of spilling over and stacking onto the neighbours.
|
|
146
|
+
if (fromChar === "" || toChar === "")
|
|
147
|
+
slot.classList.add("is-resizing");
|
|
133
148
|
const tint = typeof color === "function" ? color(i, maxLen) : color;
|
|
134
149
|
// Per-letter personality: vary the speed, the stagger and a starting tilt
|
|
135
|
-
// that springs back to upright as the glyph settles.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
150
|
+
// that springs back to upright as the glyph settles. Tilt is kept small so
|
|
151
|
+
// rotated corners never swing into the neighbouring cells.
|
|
152
|
+
// Tail cells (rolling out to nothing) join the same wave instead of
|
|
153
|
+
// queuing behind it: they start mid-wave, roll a little faster, and are
|
|
154
|
+
// gone before the new word finishes landing — so nothing trails.
|
|
155
|
+
const isTail = toChar === "";
|
|
156
|
+
const d = Math.round(duration * (isTail ? 0.75 : 1) * (1 + bounce * 0.45 * wobble(i, 1)));
|
|
157
|
+
const staggerIndex = isTail
|
|
158
|
+
? toText.length * 0.5 + (i - toText.length) * 0.25
|
|
159
|
+
: i;
|
|
160
|
+
const base = Math.round(staggerIndex * stagger * (1 + bounce * 0.25 * wobble(i, 2)));
|
|
161
|
+
const tilt = (bounce * 5 * wobble(i, 3)).toFixed(2);
|
|
139
162
|
const rollTrans = `transform ${d}ms ${easing}`;
|
|
140
163
|
const trans = color ? `${rollTrans}, color ${colorFade}ms linear ${d}ms` : rollTrans;
|
|
141
164
|
const newFace = makeFace(toChar);
|
|
@@ -145,6 +168,34 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
145
168
|
newFace.style.color = tint;
|
|
146
169
|
slot.appendChild(newFace);
|
|
147
170
|
void slot.offsetWidth; // commit start transforms
|
|
171
|
+
// Glide the cell to its new width with a clean ease-out (no overshoot) so
|
|
172
|
+
// it never pinches narrower than either glyph. Timing depends on the kind
|
|
173
|
+
// of change:
|
|
174
|
+
// - glyph → glyph: resize alongside the roll.
|
|
175
|
+
// - glyph → empty: let the glyph roll out vertically at full width FIRST,
|
|
176
|
+
// then snap the empty cell closed quickly — so the exit reads as a roll,
|
|
177
|
+
// not a horizontal crush.
|
|
178
|
+
// - empty → glyph: open the cell quickly BEFORE the glyph rolls in, so it
|
|
179
|
+
// arrives into a full-width cell.
|
|
180
|
+
if (widthChanges) {
|
|
181
|
+
let wDelay = base;
|
|
182
|
+
let wDur = d;
|
|
183
|
+
if (isTail) {
|
|
184
|
+
// Keep full width while the glyph is visibly rolling (first ~55%),
|
|
185
|
+
// then close just behind it — the exit reads as a roll, and the line
|
|
186
|
+
// has fully contracted by the time the new word lands.
|
|
187
|
+
wDelay = base + Math.round(d * 0.55);
|
|
188
|
+
wDur = Math.max(140, Math.round(d * 0.6));
|
|
189
|
+
}
|
|
190
|
+
else if (fromChar === "") {
|
|
191
|
+
wDur = Math.max(140, Math.round(d * 0.45));
|
|
192
|
+
}
|
|
193
|
+
timers.push(window.setTimeout(() => {
|
|
194
|
+
slot.style.transition = `width ${wDur}ms cubic-bezier(0.2, 0, 0, 1)`;
|
|
195
|
+
slot.style.width = `${newW}px`;
|
|
196
|
+
}, wDelay));
|
|
197
|
+
maxEnd = Math.max(maxEnd, wDelay + wDur);
|
|
198
|
+
}
|
|
148
199
|
maxEnd = Math.max(maxEnd, base + exitOffset + d + (color ? colorFade : 0));
|
|
149
200
|
// Outgoing glyph slides away first (with its own little counter-tilt).
|
|
150
201
|
if (oldFace) {
|
|
@@ -164,6 +215,10 @@ export function animateSlotText(container, toText, options = {}) {
|
|
|
164
215
|
return; // ignore the colour fade
|
|
165
216
|
newFace.removeEventListener("transitionend", done);
|
|
166
217
|
slot.dataset.char = toChar;
|
|
218
|
+
// Hand sizing back to the sizer (same px, so nothing visibly moves).
|
|
219
|
+
slot.style.removeProperty("transition");
|
|
220
|
+
slot.style.removeProperty("width");
|
|
221
|
+
slot.classList.remove("is-resizing");
|
|
167
222
|
slot.querySelectorAll(".char-face").forEach((f) => {
|
|
168
223
|
if (f !== newFace)
|
|
169
224
|
f.remove();
|
package/package.json
CHANGED
package/style.css
CHANGED
|
@@ -7,11 +7,23 @@
|
|
|
7
7
|
position: relative;
|
|
8
8
|
display: inline-flex;
|
|
9
9
|
justify-content: center;
|
|
10
|
+
/* Clip only vertically: the roll needs a top/bottom mask, but glyph side
|
|
11
|
+
bearings, kerning overhang and the settle tilt must stay visible so
|
|
12
|
+
letters never look cropped. (overflow: hidden is the legacy fallback.) */
|
|
10
13
|
overflow: hidden;
|
|
14
|
+
overflow-x: visible;
|
|
15
|
+
overflow-y: clip;
|
|
11
16
|
line-height: 1.3;
|
|
12
17
|
vertical-align: bottom;
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
/* Cells appearing from or collapsing to empty change width drastically, so
|
|
21
|
+
clip them on both axes while they resize — otherwise their glyphs spill
|
|
22
|
+
out of the shrinking cell and stack up over the neighbours. */
|
|
23
|
+
.char-slot.is-resizing {
|
|
24
|
+
overflow-x: clip;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
.char-sizer {
|
|
16
28
|
visibility: hidden;
|
|
17
29
|
white-space: pre;
|