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 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
- const H = Math.round(sample?.getBoundingClientRect().height ||
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.round(parseFloat(cs.fontSize) * 1.3) || 18;
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
- sizer.textContent = glyph(toChar); // resize the cell to the new glyph
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
- const d = Math.round(duration * (1 + bounce * 0.45 * wobble(i, 1)));
137
- const base = Math.round(i * stagger * (1 + bounce * 0.25 * wobble(i, 2)));
138
- const tilt = (bounce * 9 * wobble(i, 3)).toFixed(2);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slot-text",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Dependency-free text roll animation for tiny, tactile UI labels.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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;