slot-text 0.3.2 โ†’ 0.3.3

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
@@ -55,6 +55,17 @@ label.flash("Copied", { enter: { color: chromatic() } });
55
55
 
56
56
  That's it โ€” import the CSS once, attach to an element, roll text.
57
57
 
58
+ ## ๐Ÿงช Try it locally
59
+
60
+ The included playground has one button that flashes `Copy` โ†’ `Copied` โ†’ `Copy`.
61
+
62
+ ```bash
63
+ npm run build
64
+ python3 -m http.server
65
+ ```
66
+
67
+ Open [http://localhost:8000/examples/basic/](http://localhost:8000/examples/basic/).
68
+
58
69
  ## ๐Ÿงฉ API
59
70
 
60
71
  | Method | What it does |
@@ -176,7 +187,8 @@ font, so widths are always correct.
176
187
  - Kerning is lost โ€” pairs like `AV` sit slightly looser (invisible at label sizes).
177
188
  - Ligatures won't form (`fi`, `fl`, coding ligatures).
178
189
  - Joined scripts (Arabic, Devanagari) render as isolated forms.
179
- - ZWJ emoji sequences (๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง) split into cells; single emoji are fine.
190
+ - Grapheme clusters such as combining marks and ZWJ emoji stay together when
191
+ `Intl.Segmenter` is available; older browsers fall back to Unicode code points.
180
192
  - Very tall display fonts may clip at the roll mask (`line-height: 1.3`).
181
193
 
182
194
  **In short:** ideal for short labels, numbers, statuses and commands โ€” in
@@ -188,4 +200,6 @@ essentially any font you'd use for those.
188
200
  - React, Vue, Solid, and Svelte are optional peer dependencies โ€” plain JS users
189
201
  don't need them.
190
202
  - Import the CSS once before using the animation.
203
+ - If the stylesheet loads late, labels stay as plain readable text and upgrade
204
+ to slot animation once the required layout styles become available.
191
205
  - Low-level helpers also exported: `buildSlotText`, `animateSlotText`, `chromatic`.
@@ -0,0 +1,56 @@
1
+ export declare const TUNING: {
2
+ readonly animation: {
3
+ readonly direction: "down";
4
+ readonly stagger: 45;
5
+ readonly duration: 300;
6
+ readonly exitOffset: 50;
7
+ readonly easing: "cubic-bezier(0.34, 1.56, 0.64, 1)";
8
+ readonly bounce: 0.6;
9
+ readonly colorFade: 280;
10
+ readonly skipUnchanged: true;
11
+ readonly interrupt: true;
12
+ };
13
+ readonly variation: {
14
+ readonly indexFrequency: 12.9898;
15
+ readonly channelFrequency: 78.233;
16
+ readonly seedScale: 43758.5453;
17
+ readonly durationChannel: 1;
18
+ readonly staggerChannel: 2;
19
+ readonly tiltChannel: 3;
20
+ };
21
+ readonly glyph: {
22
+ readonly tailDurationMultiplier: 0.75;
23
+ readonly durationVariationStrength: 0.45;
24
+ readonly tailStaggerStartMultiplier: 0.5;
25
+ readonly tailStaggerStepMultiplier: 0.25;
26
+ readonly staggerVariationStrength: 0.25;
27
+ readonly maximumTiltDegrees: 5;
28
+ readonly tiltDecimalPlaces: 2;
29
+ };
30
+ readonly width: {
31
+ readonly tailCollapseDelayMultiplier: 0.55;
32
+ readonly tailCollapseDurationMultiplier: 0.6;
33
+ readonly newSlotExpansionDurationMultiplier: 0.45;
34
+ readonly minimumTransitionMs: 140;
35
+ readonly resizeEasing: "cubic-bezier(0.2, 0, 0, 1)";
36
+ readonly minimumVisibleChangePx: 0.5;
37
+ };
38
+ readonly layout: {
39
+ readonly probeOffscreenPositionPx: -9999;
40
+ readonly fallbackLineHeightMultiplier: 1.3;
41
+ readonly fallbackCharacterHeightPx: 18;
42
+ readonly characterTransformOrigin: "50% 50%";
43
+ readonly characterRestTransform: "translateY(0) rotate(0deg)";
44
+ };
45
+ readonly chromatic: {
46
+ readonly hueStartDegrees: 0;
47
+ readonly hueSpreadDegrees: 320;
48
+ readonly saturationPercent: 92;
49
+ readonly lightnessPercent: 60;
50
+ readonly fullHueRotationDegrees: 360;
51
+ };
52
+ readonly lifecycle: {
53
+ readonly completionBufferMs: 80;
54
+ readonly flashRevertDelayMs: 1400;
55
+ };
56
+ };
@@ -0,0 +1,56 @@
1
+ export const TUNING = {
2
+ animation: {
3
+ direction: "down",
4
+ stagger: 45,
5
+ duration: 300,
6
+ exitOffset: 50,
7
+ easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
8
+ bounce: 0.6,
9
+ colorFade: 280,
10
+ skipUnchanged: true,
11
+ interrupt: true,
12
+ },
13
+ variation: {
14
+ indexFrequency: 12.9898,
15
+ channelFrequency: 78.233,
16
+ seedScale: 43758.5453,
17
+ durationChannel: 1,
18
+ staggerChannel: 2,
19
+ tiltChannel: 3,
20
+ },
21
+ glyph: {
22
+ tailDurationMultiplier: 0.75,
23
+ durationVariationStrength: 0.45,
24
+ tailStaggerStartMultiplier: 0.5,
25
+ tailStaggerStepMultiplier: 0.25,
26
+ staggerVariationStrength: 0.25,
27
+ maximumTiltDegrees: 5,
28
+ tiltDecimalPlaces: 2,
29
+ },
30
+ width: {
31
+ tailCollapseDelayMultiplier: 0.55,
32
+ tailCollapseDurationMultiplier: 0.6,
33
+ newSlotExpansionDurationMultiplier: 0.45,
34
+ minimumTransitionMs: 140,
35
+ resizeEasing: "cubic-bezier(0.2, 0, 0, 1)",
36
+ minimumVisibleChangePx: 0.5,
37
+ },
38
+ layout: {
39
+ probeOffscreenPositionPx: -9999,
40
+ fallbackLineHeightMultiplier: 1.3,
41
+ fallbackCharacterHeightPx: 18,
42
+ characterTransformOrigin: "50% 50%",
43
+ characterRestTransform: "translateY(0) rotate(0deg)",
44
+ },
45
+ chromatic: {
46
+ hueStartDegrees: 0,
47
+ hueSpreadDegrees: 320,
48
+ saturationPercent: 92,
49
+ lightnessPercent: 60,
50
+ fullHueRotationDegrees: 360,
51
+ },
52
+ lifecycle: {
53
+ completionBufferMs: 80,
54
+ flashRevertDelayMs: 1400,
55
+ },
56
+ };
package/dist/dom.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { type ResolvedSlotOptions } from "./timing.js";
2
+ /** Internal test seam; not re-exported from the package. */
3
+ export declare function resetSlotLayoutCache(): void;
4
+ interface CharacterSlotElements {
5
+ slot: HTMLElement;
6
+ sizer: HTMLElement;
7
+ previousFace: HTMLElement | null;
8
+ }
9
+ interface SlotMeasurement extends CharacterSlotElements {
10
+ index: number;
11
+ currentSegment: string;
12
+ targetSegment: string;
13
+ currentWidth: number;
14
+ targetWidth: number;
15
+ widthWillChange: boolean;
16
+ }
17
+ /** Render slot markup without touching animation lifecycle state. */
18
+ export declare function renderCharacterSlots(container: HTMLElement, text: string): void;
19
+ export declare function renderPlainText(container: HTMLElement, text: string): void;
20
+ export declare function getCharacterSlots(container: HTMLElement): HTMLElement[];
21
+ export declare function isSlotLayoutReady(slot: HTMLElement | undefined): boolean;
22
+ /**
23
+ * Check the globally imported stylesheet contract. A successful check is
24
+ * cached because an applied stylesheet does not normally disappear. A failed
25
+ * check is retried so asynchronously loaded CSS can upgrade plain text later.
26
+ */
27
+ export declare function canRenderSlotLayout(): boolean;
28
+ export declare function addMissingCharacterSlots(container: HTMLElement, characterSlots: HTMLElement[], requiredSlotCount: number): void;
29
+ export declare function measureCharacterHeight(container: HTMLElement, characterSlots: HTMLElement[], containerStyle: CSSStyleDeclaration): number;
30
+ /** Measure all old widths, apply all target sizers, then read all new widths. */
31
+ export declare function measureChangedSlots(characterSlots: HTMLElement[], currentSegments: string[], targetSegments: string[], skipUnchanged: boolean): SlotMeasurement[];
32
+ export declare function prepareSlotAnimation(measurement: SlotMeasurement, targetSegmentCount: number, characterHeight: number, options: ResolvedSlotOptions): {
33
+ measurement: SlotMeasurement;
34
+ timing: import("./timing.js").GlyphTiming;
35
+ widthTiming: import("./timing.js").WidthTiming;
36
+ incomingFace: HTMLSpanElement;
37
+ completionTimeMs: number;
38
+ };
39
+ export declare function scheduleSlotAnimation(preparedAnimation: ReturnType<typeof prepareSlotAnimation>, characterHeight: number, restingColor: string, options: ResolvedSlotOptions, scheduleTask: (callback: () => void, delayMs: number) => void): void;
40
+ export {};
package/dist/dom.js ADDED
@@ -0,0 +1,209 @@
1
+ import { TUNING } from "./constants.js";
2
+ import { getVisibleSegmentText, segmentTextIntoGraphemes, } from "./text.js";
3
+ import { calculateGlyphTiming, calculateWidthTiming, } from "./timing.js";
4
+ const CSS_PROBE_TEXT = "x";
5
+ const { layout, width } = TUNING;
6
+ let cachedSlotLayoutReady = false;
7
+ /** Internal test seam; not re-exported from the package. */
8
+ export function resetSlotLayoutCache() {
9
+ cachedSlotLayoutReady = false;
10
+ }
11
+ function createCharacterFace(segment) {
12
+ const face = document.createElement("span");
13
+ face.className = "char-face";
14
+ face.textContent = getVisibleSegmentText(segment);
15
+ return face;
16
+ }
17
+ function createCharacterSlot(segment) {
18
+ const slot = document.createElement("span");
19
+ slot.className = "char-slot";
20
+ slot.dataset.char = segment;
21
+ const sizer = document.createElement("span");
22
+ sizer.className = "char-sizer";
23
+ sizer.textContent = getVisibleSegmentText(segment);
24
+ slot.append(sizer, createCharacterFace(segment));
25
+ return slot;
26
+ }
27
+ /** Render slot markup without touching animation lifecycle state. */
28
+ export function renderCharacterSlots(container, text) {
29
+ container.classList.add("slot-text");
30
+ container.replaceChildren(...segmentTextIntoGraphemes(text).map(createCharacterSlot));
31
+ }
32
+ export function renderPlainText(container, text) {
33
+ container.classList.remove("slot-text");
34
+ container.textContent = text;
35
+ }
36
+ export function getCharacterSlots(container) {
37
+ return Array.from(container.querySelectorAll(".char-slot"));
38
+ }
39
+ export function isSlotLayoutReady(slot) {
40
+ if (!slot)
41
+ return false;
42
+ const face = slot.querySelector(".char-face");
43
+ if (!face)
44
+ return false;
45
+ const slotStyle = getComputedStyle(slot);
46
+ const faceStyle = getComputedStyle(face);
47
+ const slotIsFlex = slotStyle.display === "inline-flex" || slotStyle.display === "flex";
48
+ return (slotStyle.position === "relative" &&
49
+ slotIsFlex &&
50
+ faceStyle.position === "absolute");
51
+ }
52
+ /**
53
+ * Check the globally imported stylesheet contract. A successful check is
54
+ * cached because an applied stylesheet does not normally disappear. A failed
55
+ * check is retried so asynchronously loaded CSS can upgrade plain text later.
56
+ */
57
+ export function canRenderSlotLayout() {
58
+ if (cachedSlotLayoutReady)
59
+ return true;
60
+ if (!document.body)
61
+ return false;
62
+ const cssProbe = document.createElement("span");
63
+ cssProbe.setAttribute("aria-hidden", "true");
64
+ cssProbe.style.cssText =
65
+ `position:absolute;left:${layout.probeOffscreenPositionPx}px;` +
66
+ `top:${layout.probeOffscreenPositionPx}px;visibility:hidden;pointer-events:none;`;
67
+ renderCharacterSlots(cssProbe, CSS_PROBE_TEXT);
68
+ document.body.appendChild(cssProbe);
69
+ const firstProbeSlot = getCharacterSlots(cssProbe)[0];
70
+ cachedSlotLayoutReady = isSlotLayoutReady(firstProbeSlot);
71
+ cssProbe.remove();
72
+ return cachedSlotLayoutReady;
73
+ }
74
+ export function addMissingCharacterSlots(container, characterSlots, requiredSlotCount) {
75
+ for (let index = characterSlots.length; index < requiredSlotCount; index++) {
76
+ const slot = createCharacterSlot("");
77
+ container.appendChild(slot);
78
+ characterSlots.push(slot);
79
+ }
80
+ }
81
+ export function measureCharacterHeight(container, characterSlots, containerStyle) {
82
+ const firstCharacterSlot = characterSlots[0];
83
+ const sampleSlot = characterSlots.find((slot) => (slot.dataset.char ?? "") !== "") ??
84
+ firstCharacterSlot;
85
+ return (Math.ceil(sampleSlot?.getBoundingClientRect().height ||
86
+ sampleSlot?.offsetHeight ||
87
+ container.getBoundingClientRect().height ||
88
+ parseFloat(containerStyle.lineHeight) ||
89
+ 0) ||
90
+ Math.ceil(parseFloat(containerStyle.fontSize) *
91
+ layout.fallbackLineHeightMultiplier) ||
92
+ layout.fallbackCharacterHeightPx);
93
+ }
94
+ /** Measure all old widths, apply all target sizers, then read all new widths. */
95
+ export function measureChangedSlots(characterSlots, currentSegments, targetSegments, skipUnchanged) {
96
+ const changedSlots = [];
97
+ for (let index = 0; index < characterSlots.length; index++) {
98
+ const currentSegment = currentSegments[index] ?? "";
99
+ const targetSegment = targetSegments[index] ?? "";
100
+ if (currentSegment === targetSegment &&
101
+ (skipUnchanged || currentSegment === "")) {
102
+ continue;
103
+ }
104
+ const slot = characterSlots[index];
105
+ const sizer = slot.querySelector(".char-sizer");
106
+ changedSlots.push({
107
+ index,
108
+ slot,
109
+ sizer,
110
+ previousFace: slot.querySelector(".char-face"),
111
+ currentSegment,
112
+ targetSegment,
113
+ currentWidth: slot.getBoundingClientRect().width,
114
+ });
115
+ }
116
+ changedSlots.forEach(({ sizer, targetSegment }) => {
117
+ sizer.textContent = getVisibleSegmentText(targetSegment);
118
+ });
119
+ return changedSlots.map((changedSlot) => {
120
+ const targetWidth = changedSlot.sizer.getBoundingClientRect().width;
121
+ return {
122
+ ...changedSlot,
123
+ targetWidth,
124
+ widthWillChange: Math.abs(targetWidth - changedSlot.currentWidth) >
125
+ width.minimumVisibleChangePx,
126
+ };
127
+ });
128
+ }
129
+ export function prepareSlotAnimation(measurement, targetSegmentCount, characterHeight, options) {
130
+ const { index, slot, currentSegment, targetSegment } = measurement;
131
+ const { currentWidth, widthWillChange } = measurement;
132
+ const timing = calculateGlyphTiming(index, targetSegmentCount, targetSegment === "", options);
133
+ const widthTiming = calculateWidthTiming(currentSegment, targetSegment, timing);
134
+ const incomingOffsetY = options.direction === "down" ? -characterHeight : characterHeight;
135
+ const incomingColor = typeof options.color === "function"
136
+ ? options.color(index, targetSegmentCount)
137
+ : options.color;
138
+ if (widthWillChange)
139
+ slot.style.width = `${currentWidth}px`;
140
+ if (currentSegment === "" || targetSegment === "") {
141
+ slot.classList.add("is-resizing");
142
+ }
143
+ const incomingFace = createCharacterFace(targetSegment);
144
+ incomingFace.style.transformOrigin = layout.characterTransformOrigin;
145
+ incomingFace.style.transform =
146
+ `translateY(${incomingOffsetY}px) ` +
147
+ `rotate(${timing.startingTiltDegrees}deg)`;
148
+ if (incomingColor)
149
+ incomingFace.style.color = incomingColor;
150
+ slot.appendChild(incomingFace);
151
+ const widthCompletionTimeMs = widthWillChange
152
+ ? widthTiming.startDelayMs + widthTiming.durationMs
153
+ : 0;
154
+ const glyphCompletionTimeMs = timing.startDelayMs +
155
+ options.exitOffset +
156
+ timing.durationMs +
157
+ (options.color ? options.colorFade : 0);
158
+ return {
159
+ measurement,
160
+ timing,
161
+ widthTiming,
162
+ incomingFace,
163
+ completionTimeMs: Math.max(widthCompletionTimeMs, glyphCompletionTimeMs),
164
+ };
165
+ }
166
+ export function scheduleSlotAnimation(preparedAnimation, characterHeight, restingColor, options, scheduleTask) {
167
+ const { measurement, timing, widthTiming, incomingFace } = preparedAnimation;
168
+ const { slot, previousFace, targetWidth, widthWillChange } = measurement;
169
+ const outgoingOffsetY = options.direction === "down" ? characterHeight : -characterHeight;
170
+ const rollTransition = `transform ${timing.durationMs}ms ${options.easing}`;
171
+ const incomingTransition = options.color
172
+ ? `${rollTransition}, color ${options.colorFade}ms linear ${timing.durationMs}ms`
173
+ : rollTransition;
174
+ if (widthWillChange) {
175
+ scheduleTask(() => {
176
+ slot.style.transition =
177
+ `width ${widthTiming.durationMs}ms ${width.resizeEasing}`;
178
+ slot.style.width = `${targetWidth}px`;
179
+ }, widthTiming.startDelayMs);
180
+ }
181
+ if (previousFace) {
182
+ scheduleTask(() => {
183
+ previousFace.style.transition = rollTransition;
184
+ previousFace.style.transform =
185
+ `translateY(${outgoingOffsetY}px) ` +
186
+ `rotate(${-timing.startingTiltDegrees}deg)`;
187
+ }, timing.startDelayMs);
188
+ }
189
+ scheduleTask(() => {
190
+ incomingFace.style.transition = incomingTransition;
191
+ incomingFace.style.transform = layout.characterRestTransform;
192
+ if (options.color)
193
+ incomingFace.style.color = restingColor;
194
+ const finishOnTransformEnd = (event) => {
195
+ if (event.propertyName !== "transform")
196
+ return;
197
+ incomingFace.removeEventListener("transitionend", finishOnTransformEnd);
198
+ slot.dataset.char = measurement.targetSegment;
199
+ slot.style.removeProperty("transition");
200
+ slot.style.removeProperty("width");
201
+ slot.classList.remove("is-resizing");
202
+ slot.querySelectorAll(".char-face").forEach((face) => {
203
+ if (face !== incomingFace)
204
+ face.remove();
205
+ });
206
+ };
207
+ incomingFace.addEventListener("transitionend", finishOnTransformEnd);
208
+ }, timing.startDelayMs + options.exitOffset);
209
+ }
package/dist/index.d.ts CHANGED
@@ -31,4 +31,4 @@ export interface SlotTextController {
31
31
  * label.set("Copied", { direction: "up" });
32
32
  * label.flash("Copied", { revertAfter: 1400 }); // auto-reverts to "Copy"
33
33
  */
34
- export declare function slotText(element: HTMLElement, initialText: string, options?: SlotOptions): SlotTextController;
34
+ export declare function slotText(element: HTMLElement, initialText: string, defaultOptions?: SlotOptions): SlotTextController;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { animateSlotText, buildSlotText, chromatic, clearSlotText, } from "./slotText.js";
2
- import { animateSlotText, buildSlotText, clearSlotText } from "./slotText.js";
2
+ import { TUNING } from "./constants.js";
3
+ import { animateSlotText, clearSlotText, renderTextWithCssFallback, } from "./slotText.js";
3
4
  /**
4
5
  * Create a text-roll controller for one element.
5
6
  *
@@ -9,54 +10,54 @@ import { animateSlotText, buildSlotText, clearSlotText } from "./slotText.js";
9
10
  * label.set("Copied", { direction: "up" });
10
11
  * label.flash("Copied", { revertAfter: 1400 }); // auto-reverts to "Copy"
11
12
  */
12
- export function slotText(element, initialText, options = {}) {
13
- let value = initialText;
14
- let revertTimeout;
13
+ export function slotText(element, initialText, defaultOptions = {}) {
14
+ let currentValue = initialText;
15
+ let revertTimerId;
15
16
  let restingText;
16
- buildSlotText(element, initialText);
17
+ renderTextWithCssFallback(element, initialText);
18
+ const animateWithoutInterrupt = (text, overrides) => animateSlotText(element, text, {
19
+ ...defaultOptions,
20
+ interrupt: false,
21
+ ...overrides,
22
+ });
17
23
  return {
18
24
  element,
19
25
  get value() {
20
- return value;
26
+ return currentValue;
21
27
  },
22
- set(text, nextOptions = {}) {
28
+ set(text, optionOverrides = {}) {
23
29
  // An explicit set wins over a pending flash revert.
24
- clearTimeout(revertTimeout);
30
+ clearTimeout(revertTimerId);
25
31
  restingText = undefined;
26
- value = text;
27
- animateSlotText(element, text, { ...options, ...nextOptions });
32
+ currentValue = text;
33
+ animateSlotText(element, text, {
34
+ ...defaultOptions,
35
+ ...optionOverrides,
36
+ });
28
37
  },
29
- flash(text, { revertAfter = 1400, enter, exit } = {}) {
38
+ flash(text, { revertAfter = TUNING.lifecycle.flashRevertDelayMs, enter: enterOptions, exit: exitOptions, } = {}) {
30
39
  // Capture the resting text only on the first flash of a burst, so a
31
40
  // flash-during-flash still reverts to the original label.
32
41
  if (restingText === undefined) {
33
- restingText = value;
42
+ restingText = currentValue;
34
43
  }
35
44
  // Flashes default to non-interrupting rolls: spam-friendly, no mid-roll
36
45
  // cutoffs. Callers can still override via `enter`/`exit`.
37
- value = text;
38
- animateSlotText(element, text, {
39
- ...options,
40
- interrupt: false,
41
- ...enter,
42
- });
46
+ currentValue = text;
47
+ animateWithoutInterrupt(text, enterOptions);
43
48
  // Restart the revert timer: one revert per burst, after the last flash.
44
- clearTimeout(revertTimeout);
45
- revertTimeout = window.setTimeout(() => {
46
- const back = restingText;
49
+ clearTimeout(revertTimerId);
50
+ revertTimerId = window.setTimeout(() => {
51
+ const originalText = restingText;
47
52
  restingText = undefined;
48
- revertTimeout = undefined;
49
- value = back;
50
- animateSlotText(element, back, {
51
- ...options,
52
- interrupt: false,
53
- ...exit,
54
- });
53
+ revertTimerId = undefined;
54
+ currentValue = originalText;
55
+ animateWithoutInterrupt(originalText, exitOptions);
55
56
  }, revertAfter);
56
57
  },
57
58
  destroy() {
58
- clearTimeout(revertTimeout);
59
- clearSlotText(element, value);
59
+ clearTimeout(revertTimerId);
60
+ clearSlotText(element, currentValue);
60
61
  },
61
62
  };
62
63
  }
package/dist/react.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createElement, forwardRef, useEffect, useImperativeHandle, useRef, } from "react";
2
- import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText.js";
2
+ import { animateSlotText, clearSlotText, renderTextWithCssFallback, } from "./slotText.js";
3
3
  export const SlotText = forwardRef(({ text, options, "aria-label": ariaLabel, ...props }, forwardedRef) => {
4
4
  const elementRef = useRef(null);
5
5
  const mountedRef = useRef(false);
@@ -13,7 +13,7 @@ export const SlotText = forwardRef(({ text, options, "aria-label": ariaLabel, ..
13
13
  const element = elementRef.current;
14
14
  if (!element)
15
15
  return;
16
- buildSlotText(element, text);
16
+ renderTextWithCssFallback(element, text);
17
17
  mountedRef.current = true;
18
18
  return () => {
19
19
  clearSlotText(element);
@@ -1,16 +1,8 @@
1
1
  /**
2
- * slotText - a dependency-free "text roll" animation.
3
- *
4
- * Adapted from motion-primitives' TextRoll (vertical-slide variant): each
5
- * character sits in its own clipped cell and changes by sliding. The new
6
- * glyph enters from one side while the old glyph slides out the other, with
7
- * the incoming glyph chasing the outgoing one by a stagger step. Pure
8
- * transform/transition, GPU-composited, with a springy overshoot easing, so
9
- * every letter lands with a little bounce.
10
- *
11
- * buildSlotText(el, "Copy"); // initialise
12
- * animateSlotText(el, "Copied", { direction: "up" }); // animate to new text
2
+ * Browser-only text-roll animation. Each grapheme owns one clipped slot; the
3
+ * previous face rolls out while the next face rolls in.
13
4
  */
5
+ /** Options shared by the low-level animation and all framework adapters. */
14
6
  export interface SlotOptions {
15
7
  /** "down" rolls glyphs downward (enter from top); "up" rolls upward. */
16
8
  direction?: "up" | "down";
@@ -23,49 +15,43 @@ export interface SlotOptions {
23
15
  /** Easing โ€” defaults to a springy, overshooting "back" curve. */
24
16
  easing?: string;
25
17
  /**
26
- * Per-letter personality: 0 = every glyph lands identically, 1 = lots of
27
- * individual variation in speed and a little tilt-wobble as each settles.
28
- * Default 0.6.
18
+ * Per-letter personality: 0 makes every glyph move identically; 1 adds the
19
+ * strongest timing variation and tilt. Default 0.6.
29
20
  */
30
21
  bounce?: number;
31
22
  /**
32
- * Chromatic flash: each incoming glyph rolls in tinted, then fades to its
33
- * resting color once it lands. Pass a single CSS color for a flat tint, or a
34
- * function `(index, total) => color` to give every glyph its own hue. That's
35
- * what produces the spectrum/rainbow sweep across the line. Omit for no flash.
23
+ * Incoming glyph tint. Pass one CSS color, or return a color for each
24
+ * `(segmentIndex, segmentCount)` pair.
36
25
  */
37
- color?: string | ((index: number, total: number) => string);
38
- /** How long the chromatic tint takes to fade back to rest, in ms (default 280). */
26
+ color?: string | ((segmentIndex: number, segmentCount: number) => string);
27
+ /** Tint fade duration in ms (default 280). */
39
28
  colorFade?: number;
40
29
  /**
41
- * Keep characters that are identical at the same index static. Ideal for
42
- * short aligned labels (Copy to Copied). Turn off when the shared parts of the
43
- * two strings are misaligned (different lengths) so the whole line rolls
44
- * uniformly instead of leaving stray letters frozen.
30
+ * Keep graphemes that are identical at the same index static. Disable this
31
+ * when differently sized strings are not positionally aligned.
45
32
  */
46
33
  skipUnchanged?: boolean;
47
34
  /**
48
- * true (default): a new call interrupts any roll in flight, snapping it to
49
- * its target before starting fresh. false: the current roll finishes and the
50
- * latest call made mid-roll plays after it lands; calls targeting the text
51
- * already displayed are dropped. Ideal for spam-prone triggers like buttons.
35
+ * true interrupts the running roll. false lets it finish, then plays only
36
+ * the latest queued target. Default true.
52
37
  */
53
38
  interrupt?: boolean;
54
39
  }
55
40
  export interface ChromaticOptions {
41
+ /** Starting hue in degrees. Default 0. */
56
42
  from?: number;
43
+ /** Hue distance from first to last grapheme. Default 320 degrees. */
57
44
  spread?: number;
45
+ /** HSL saturation percentage. Default 92. */
58
46
  saturation?: number;
47
+ /** HSL lightness percentage. Default 60. */
59
48
  lightness?: number;
60
49
  }
61
- /**
62
- * Build a `color` function that sweeps the hue across the line, giving every
63
- * glyph its own color so the roll lands as a chromatic spectrum.
64
- *
65
- * animateSlotText(el, txt, { color: chromatic() }); // full rainbow
66
- * animateSlotText(el, txt, { color: chromatic({ from: 18 }) }); // start gold
67
- */
68
- export declare function chromatic({ from, spread, saturation, lightness, }?: ChromaticOptions): (index: number, total: number) => string;
50
+ /** Build a color function that sweeps a hue range across the text. */
51
+ export declare function chromatic({ from, spread, saturation, lightness, }?: ChromaticOptions): (segmentIndex: number, segmentCount: number) => string;
52
+ /** Render settled text, falling back to plain text until the CSS is ready. */
53
+ export declare function renderTextWithCssFallback(container: HTMLElement, text: string): void;
54
+ /** Build slot markup immediately and cancel any animation that owns it. */
69
55
  export declare function buildSlotText(container: HTMLElement, text: string): void;
70
- export declare function animateSlotText(container: HTMLElement, toText: string, options?: SlotOptions): void;
56
+ export declare function animateSlotText(container: HTMLElement, targetText: string, options?: SlotOptions): void;
71
57
  export declare function clearSlotText(container: HTMLElement, text?: string): void;
package/dist/slotText.js CHANGED
@@ -1,266 +1,118 @@
1
- /**
2
- * slotText - a dependency-free "text roll" animation.
3
- *
4
- * Adapted from motion-primitives' TextRoll (vertical-slide variant): each
5
- * character sits in its own clipped cell and changes by sliding. The new
6
- * glyph enters from one side while the old glyph slides out the other, with
7
- * the incoming glyph chasing the outgoing one by a stagger step. Pure
8
- * transform/transition, GPU-composited, with a springy overshoot easing, so
9
- * every letter lands with a little bounce.
10
- *
11
- * buildSlotText(el, "Copy"); // initialise
12
- * animateSlotText(el, "Copied", { direction: "up" }); // animate to new text
13
- */
14
- const DEFAULTS = {
15
- direction: "down",
16
- stagger: 45,
17
- duration: 300,
18
- exitOffset: 50,
19
- easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
20
- bounce: 0.6,
21
- colorFade: 280,
22
- skipUnchanged: true,
23
- interrupt: true,
24
- };
25
- const NBSP = "\u00A0";
26
- const glyph = (char) => (char === " " ? NBSP : char);
27
- /**
28
- * Build a `color` function that sweeps the hue across the line, giving every
29
- * glyph its own color so the roll lands as a chromatic spectrum.
30
- *
31
- * animateSlotText(el, txt, { color: chromatic() }); // full rainbow
32
- * animateSlotText(el, txt, { color: chromatic({ from: 18 }) }); // start gold
33
- */
34
- export function chromatic({ from = 0, spread = 320, saturation = 92, lightness = 60, } = {}) {
35
- return (index, total) => {
36
- const t = total <= 1 ? 0 : index / (total - 1);
37
- return `hsl(${(from + t * spread) % 360} ${saturation}% ${lightness}%)`;
1
+ import { TUNING } from "./constants.js";
2
+ import { addMissingCharacterSlots, canRenderSlotLayout, getCharacterSlots, isSlotLayoutReady, measureChangedSlots, measureCharacterHeight, prepareSlotAnimation, renderPlainText, renderCharacterSlots, scheduleSlotAnimation, } from "./dom.js";
3
+ import { segmentTextIntoGraphemes } from "./text.js";
4
+ import { resolveAnimationOptions } from "./timing.js";
5
+ const { chromatic: chromaticTuning, lifecycle } = TUNING;
6
+ /** Build a color function that sweeps a hue range across the text. */
7
+ export function chromatic({ from = chromaticTuning.hueStartDegrees, spread = chromaticTuning.hueSpreadDegrees, saturation = chromaticTuning.saturationPercent, lightness = chromaticTuning.lightnessPercent, } = {}) {
8
+ return (segmentIndex, segmentCount) => {
9
+ const lastSegmentIndex = segmentCount - 1;
10
+ const progress = segmentCount <= 1 ? 0 : segmentIndex / lastSegmentIndex;
11
+ const hueDegrees = (from + progress * spread) % chromaticTuning.fullHueRotationDegrees;
12
+ return `hsl(${hueDegrees} ${saturation}% ${lightness}%)`;
38
13
  };
39
14
  }
40
- const states = new WeakMap();
41
- /** Cancel any running animation on a container and snap it to its target text. */
42
- function settle(container) {
43
- const state = states.get(container);
44
- if (!state)
45
- return;
46
- state.timers.forEach((t) => window.clearTimeout(t));
47
- states.delete(container);
48
- // Rebuild a pristine DOM at the text the interrupted roll was heading toward,
49
- // so the next animation starts from a clean, non-overlapping baseline.
50
- buildSlotText(container, state.target);
15
+ const animationStates = new WeakMap();
16
+ const initializedContainers = new WeakSet();
17
+ function cancelRunningAnimation(container) {
18
+ const animationState = animationStates.get(container);
19
+ if (!animationState)
20
+ return undefined;
21
+ animationState.timerIds.forEach((timerId) => window.clearTimeout(timerId));
22
+ animationStates.delete(container);
23
+ return animationState;
51
24
  }
52
- function makeFace(char) {
53
- const face = document.createElement("span");
54
- face.className = "char-face";
55
- face.textContent = glyph(char);
56
- return face;
25
+ /** Render settled text, falling back to plain text until the CSS is ready. */
26
+ export function renderTextWithCssFallback(container, text) {
27
+ initializedContainers.add(container);
28
+ if (canRenderSlotLayout())
29
+ renderCharacterSlots(container, text);
30
+ else
31
+ renderPlainText(container, text);
57
32
  }
58
- function buildSlot(char) {
59
- const slot = document.createElement("span");
60
- slot.className = "char-slot";
61
- slot.dataset.char = char;
62
- // Invisible sizer keeps the cell exactly the width/height of its glyph, so
63
- // the absolutely-positioned animating faces never reflow the line.
64
- const sizer = document.createElement("span");
65
- sizer.className = "char-sizer";
66
- sizer.textContent = glyph(char);
67
- slot.append(sizer, makeFace(char));
68
- return slot;
33
+ function finishRunningAnimationImmediately(container) {
34
+ const animationState = cancelRunningAnimation(container);
35
+ if (animationState) {
36
+ renderTextWithCssFallback(container, animationState.targetText);
37
+ }
69
38
  }
39
+ /** Build slot markup immediately and cancel any animation that owns it. */
70
40
  export function buildSlotText(container, text) {
71
- container.classList.add("slot-text");
72
- container.replaceChildren(...Array.from(text, buildSlot));
41
+ cancelRunningAnimation(container);
42
+ initializedContainers.add(container);
43
+ renderCharacterSlots(container, text);
73
44
  }
74
- export function animateSlotText(container, toText, options = {}) {
75
- const { direction, stagger, duration, exitOffset, easing, bounce, color, colorFade, skipUnchanged, interrupt, } = {
76
- ...DEFAULTS,
77
- ...options,
78
- };
79
- // Non-interrupting mode: if a roll is already in flight, let it finish and
80
- // remember this request instead. Only the latest request survives, so spam
81
- // taps coalesce into a single follow-up roll once the current one lands.
82
- const running = states.get(container);
83
- if (running && !interrupt) {
84
- if (toText !== running.target) {
85
- running.pending = { text: toText, options };
86
- }
45
+ function scheduleAnimationTask(animationState, callback, delayMs) {
46
+ const timerId = window.setTimeout(callback, delayMs);
47
+ animationState.timerIds.push(timerId);
48
+ }
49
+ export function animateSlotText(container, targetText, options = {}) {
50
+ const resolvedOptions = resolveAnimationOptions(options);
51
+ const runningAnimation = animationStates.get(container);
52
+ if (runningAnimation && !resolvedOptions.interrupt) {
53
+ runningAnimation.pendingAnimation =
54
+ targetText === runningAnimation.targetText
55
+ ? undefined
56
+ : { text: targetText, options };
87
57
  return;
88
58
  }
89
- // Interrupt: if a previous roll is still running, fast-forward it to its
90
- // target and tear down its timers before we start fresh. This is what kills
91
- // the "switch bunโ†’npm mid-animation" glitch.
92
- settle(container);
93
- // First run / empty container โ†’ just build it.
94
- if (!container.querySelector(".char-slot")) {
95
- buildSlotText(container, toText);
59
+ finishRunningAnimationImmediately(container);
60
+ let characterSlots = getCharacterSlots(container);
61
+ if (characterSlots.length === 0) {
62
+ if (!initializedContainers.has(container)) {
63
+ renderTextWithCssFallback(container, targetText);
64
+ return;
65
+ }
66
+ if (!canRenderSlotLayout()) {
67
+ renderPlainText(container, targetText);
68
+ return;
69
+ }
70
+ renderCharacterSlots(container, container.textContent ?? "");
71
+ characterSlots = getCharacterSlots(container);
72
+ }
73
+ const firstCharacterSlot = characterSlots[0];
74
+ if (firstCharacterSlot && !isSlotLayoutReady(firstCharacterSlot)) {
75
+ renderPlainText(container, targetText);
96
76
  return;
97
77
  }
98
- const slots = Array.from(container.querySelectorAll(".char-slot"));
99
- const fromText = slots.map((s) => s.dataset.char ?? "").join("");
100
- // Non-interrupting mode also drops rolls to the text already on screen, so
101
- // repeated triggers do not visibly re-roll an unchanged label.
102
- if (!interrupt && fromText === toText)
78
+ const currentSegments = characterSlots.map((slot) => slot.dataset.char ?? "");
79
+ const targetSegments = segmentTextIntoGraphemes(targetText);
80
+ if (!resolvedOptions.interrupt &&
81
+ currentSegments.length === targetSegments.length &&
82
+ currentSegments.every((segment, index) => segment === targetSegments[index])) {
103
83
  return;
104
- const maxLen = Math.max(fromText.length, toText.length);
105
- // Whole-pixel slide distance = one cell height, so glyphs clip cleanly.
106
- // If layout has not produced dimensions yet, fall back to line-height/font-size
107
- // so the text still rolls instead of swapping in place.
108
- const sample = slots.find((s) => (s.dataset.char ?? "") !== "") ?? slots[0];
109
- const cs = getComputedStyle(container);
110
- // Ceil, not round: if the slide distance is even half a pixel short of the
111
- // cell height, a sliver of the outgoing glyph stays visible at the clip edge.
112
- const H = Math.ceil(sample?.getBoundingClientRect().height ||
113
- sample?.offsetHeight ||
114
- container.getBoundingClientRect().height ||
115
- parseFloat(cs.lineHeight) ||
116
- 0) ||
117
- Math.ceil(parseFloat(cs.fontSize) * 1.3) ||
118
- 18;
119
- // Resting color to settle the chromatic flash back to.
120
- const restColor = color ? cs.color : "";
121
- // Pre-create any extra cells up front so the row never reflows mid-roll.
122
- for (let i = slots.length; i < maxLen; i++) {
123
- const slot = buildSlot("");
124
- container.appendChild(slot);
125
- slots.push(slot);
126
84
  }
127
- const timers = [];
128
- const state = { timers, target: toText };
129
- states.set(container, state);
130
- // down: new enters from above (-H to 0), old exits below (0 to +H)
131
- // up: new enters from below (+H to 0), old exits above (0 to -H)
132
- const outY = direction === "down" ? H : -H;
133
- const inStart = direction === "down" ? -H : H;
134
- // A tiny deterministic-feeling jitter in [-1, 1] per character. Scaled by
135
- // `bounce` it gives each glyph its own speed and a little tilt-wobble, so the
136
- // line does not land as one rigid block. Every letter has some personality.
137
- const wobble = (i, salt) => {
138
- const n = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453;
139
- return (n - Math.floor(n)) * 2 - 1;
140
- };
141
- // Track the slowest letter so the safety-net snap waits for everyone.
142
- let maxEnd = 0;
143
- for (let i = 0; i < maxLen; i++) {
144
- const fromChar = fromText[i] || "";
145
- const toChar = toText[i] || "";
146
- if (fromChar === toChar && (skipUnchanged || fromChar === ""))
147
- continue;
148
- const slot = slots[i];
149
- const sizer = slot.querySelector(".char-sizer");
150
- const oldFace = slot.querySelector(".char-face");
151
- // Resize the cell to the new glyph โ€” but ease the width instead of
152
- // snapping it, so a wide outgoing glyph (W โ†’ i) is never cropped by a
153
- // suddenly-narrow cell and neighbouring letters glide rather than jump.
154
- const oldW = slot.getBoundingClientRect().width;
155
- sizer.textContent = glyph(toChar);
156
- const newW = sizer.getBoundingClientRect().width;
157
- const widthChanges = Math.abs(newW - oldW) > 0.5;
158
- if (widthChanges)
159
- slot.style.width = `${oldW}px`;
160
- // A cell growing from or collapsing to empty changes width drastically โ€”
161
- // clip it horizontally while it resizes so its glyph wipes in/out with the
162
- // cell instead of spilling over and stacking onto the neighbours.
163
- if (fromChar === "" || toChar === "")
164
- slot.classList.add("is-resizing");
165
- const tint = typeof color === "function" ? color(i, maxLen) : color;
166
- // Per-letter personality: vary the speed, the stagger and a starting tilt
167
- // that springs back to upright as the glyph settles. Tilt is kept small so
168
- // rotated corners never swing into the neighbouring cells.
169
- // Tail cells (rolling out to nothing) join the same wave instead of
170
- // queuing behind it: they start mid-wave, roll a little faster, and are
171
- // gone before the new word finishes landing โ€” so nothing trails.
172
- const isTail = toChar === "";
173
- const d = Math.round(duration * (isTail ? 0.75 : 1) * (1 + bounce * 0.45 * wobble(i, 1)));
174
- const staggerIndex = isTail
175
- ? toText.length * 0.5 + (i - toText.length) * 0.25
176
- : i;
177
- const base = Math.round(staggerIndex * stagger * (1 + bounce * 0.25 * wobble(i, 2)));
178
- const tilt = (bounce * 5 * wobble(i, 3)).toFixed(2);
179
- const rollTrans = `transform ${d}ms ${easing}`;
180
- const trans = color
181
- ? `${rollTrans}, color ${colorFade}ms linear ${d}ms`
182
- : rollTrans;
183
- const newFace = makeFace(toChar);
184
- newFace.style.transformOrigin = "50% 50%";
185
- newFace.style.transform = `translateY(${inStart}px) rotate(${tilt}deg)`;
186
- if (tint)
187
- newFace.style.color = tint;
188
- slot.appendChild(newFace);
189
- void slot.offsetWidth; // commit start transforms
190
- // Glide the cell to its new width with a clean ease-out (no overshoot) so
191
- // it never pinches narrower than either glyph. Timing depends on the kind
192
- // of change:
193
- // - glyph โ†’ glyph: resize alongside the roll.
194
- // - glyph โ†’ empty: let the glyph roll out vertically at full width FIRST,
195
- // then snap the empty cell closed quickly โ€” so the exit reads as a roll,
196
- // not a horizontal crush.
197
- // - empty โ†’ glyph: open the cell quickly BEFORE the glyph rolls in, so it
198
- // arrives into a full-width cell.
199
- if (widthChanges) {
200
- let wDelay = base;
201
- let wDur = d;
202
- if (isTail) {
203
- // Keep full width while the glyph is visibly rolling (first ~55%),
204
- // then close just behind it โ€” the exit reads as a roll, and the line
205
- // has fully contracted by the time the new word lands.
206
- wDelay = base + Math.round(d * 0.55);
207
- wDur = Math.max(140, Math.round(d * 0.6));
208
- }
209
- else if (fromChar === "") {
210
- wDur = Math.max(140, Math.round(d * 0.45));
211
- }
212
- timers.push(window.setTimeout(() => {
213
- slot.style.transition = `width ${wDur}ms cubic-bezier(0.2, 0, 0, 1)`;
214
- slot.style.width = `${newW}px`;
215
- }, wDelay));
216
- maxEnd = Math.max(maxEnd, wDelay + wDur);
217
- }
218
- maxEnd = Math.max(maxEnd, base + exitOffset + d + (color ? colorFade : 0));
219
- // Outgoing glyph slides away first (with its own little counter-tilt).
220
- if (oldFace) {
221
- timers.push(window.setTimeout(() => {
222
- oldFace.style.transition = rollTrans;
223
- oldFace.style.transform = `translateY(${outY}px) rotate(${-Number(tilt)}deg)`;
224
- }, base));
225
- }
226
- // Incoming glyph chases it in (and, if tinted, fades to rest afterwards).
227
- timers.push(window.setTimeout(() => {
228
- newFace.style.transition = trans;
229
- newFace.style.transform = "translateY(0) rotate(0deg)";
230
- if (color)
231
- newFace.style.color = restColor;
232
- const done = (e) => {
233
- if (e.propertyName !== "transform")
234
- return; // ignore the colour fade
235
- newFace.removeEventListener("transitionend", done);
236
- slot.dataset.char = toChar;
237
- // Hand sizing back to the sizer (same px, so nothing visibly moves).
238
- slot.style.removeProperty("transition");
239
- slot.style.removeProperty("width");
240
- slot.classList.remove("is-resizing");
241
- slot.querySelectorAll(".char-face").forEach((f) => {
242
- if (f !== newFace)
243
- f.remove();
244
- });
245
- };
246
- newFace.addEventListener("transitionend", done);
247
- }, base + exitOffset));
85
+ const requiredSlotCount = Math.max(currentSegments.length, targetSegments.length);
86
+ addMissingCharacterSlots(container, characterSlots, requiredSlotCount);
87
+ const containerStyle = getComputedStyle(container);
88
+ const characterHeight = measureCharacterHeight(container, characterSlots, containerStyle);
89
+ const restingColor = resolvedOptions.color ? containerStyle.color : "";
90
+ const changedSlotMeasurements = measureChangedSlots(characterSlots, currentSegments, targetSegments, resolvedOptions.skipUnchanged);
91
+ if (changedSlotMeasurements.length === 0) {
92
+ renderCharacterSlots(container, targetText);
93
+ return;
248
94
  }
249
- // Safety net: snap to a pristine DOM once the slowest letter has settled.
250
- // If a non-interrupting call was deferred mid-roll, replay it now โ€” it runs
251
- // as a fresh roll from this clean baseline.
252
- const total = maxEnd + 80;
253
- timers.push(window.setTimeout(() => {
254
- const pending = state.pending;
255
- states.delete(container);
256
- buildSlotText(container, toText);
257
- if (pending) {
258
- animateSlotText(container, pending.text, pending.options);
95
+ const animationState = { timerIds: [], targetText };
96
+ animationStates.set(container, animationState);
97
+ const preparedSlotAnimations = changedSlotMeasurements.map((measurement) => prepareSlotAnimation(measurement, targetSegments.length, characterHeight, resolvedOptions));
98
+ // Commit every start style with one layout flush instead of one per glyph.
99
+ void container.offsetWidth;
100
+ preparedSlotAnimations.forEach((preparedSlotAnimation) => scheduleSlotAnimation(preparedSlotAnimation, characterHeight, restingColor, resolvedOptions, (callback, delayMs) => scheduleAnimationTask(animationState, callback, delayMs)));
101
+ const completionDelayMs = Math.max(...preparedSlotAnimations.map(({ completionTimeMs }) => completionTimeMs)) +
102
+ lifecycle.completionBufferMs;
103
+ scheduleAnimationTask(animationState, () => {
104
+ if (animationStates.get(container) !== animationState)
105
+ return;
106
+ const pendingAnimation = animationState.pendingAnimation;
107
+ animationStates.delete(container);
108
+ renderTextWithCssFallback(container, targetText);
109
+ if (pendingAnimation) {
110
+ animateSlotText(container, pendingAnimation.text, pendingAnimation.options);
259
111
  }
260
- }, total));
112
+ }, completionDelayMs);
261
113
  }
262
114
  export function clearSlotText(container, text = "") {
263
- settle(container);
264
- container.classList.remove("slot-text");
265
- container.textContent = text;
115
+ cancelRunningAnimation(container);
116
+ initializedContainers.delete(container);
117
+ renderPlainText(container, text);
266
118
  }
package/dist/text.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Split text into user-perceived characters, with code-point fallback. */
2
+ export declare function segmentTextIntoGraphemes(text: string): string[];
3
+ /** Preserve regular spaces inside individually measured glyph cells. */
4
+ export declare function getVisibleSegmentText(segment: string): string;
package/dist/text.js ADDED
@@ -0,0 +1,14 @@
1
+ const NBSP = "\u00A0";
2
+ const graphemeSegmenter = Intl.Segmenter
3
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+ : undefined;
5
+ /** Split text into user-perceived characters, with code-point fallback. */
6
+ export function segmentTextIntoGraphemes(text) {
7
+ if (!graphemeSegmenter)
8
+ return Array.from(text);
9
+ return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
10
+ }
11
+ /** Preserve regular spaces inside individually measured glyph cells. */
12
+ export function getVisibleSegmentText(segment) {
13
+ return segment === " " ? NBSP : segment;
14
+ }
@@ -0,0 +1,25 @@
1
+ import type { SlotOptions } from "./slotText.js";
2
+ export interface ResolvedSlotOptions {
3
+ direction: "up" | "down";
4
+ stagger: number;
5
+ duration: number;
6
+ exitOffset: number;
7
+ easing: string;
8
+ bounce: number;
9
+ color?: string | ((segmentIndex: number, segmentCount: number) => string);
10
+ colorFade: number;
11
+ skipUnchanged: boolean;
12
+ interrupt: boolean;
13
+ }
14
+ export interface GlyphTiming {
15
+ durationMs: number;
16
+ startDelayMs: number;
17
+ startingTiltDegrees: number;
18
+ }
19
+ export interface WidthTiming {
20
+ durationMs: number;
21
+ startDelayMs: number;
22
+ }
23
+ export declare function resolveAnimationOptions(options: SlotOptions): ResolvedSlotOptions;
24
+ export declare function calculateGlyphTiming(segmentIndex: number, targetSegmentCount: number, isTrailingSlot: boolean, options: Pick<ResolvedSlotOptions, "duration" | "stagger" | "bounce">): GlyphTiming;
25
+ export declare function calculateWidthTiming(currentSegment: string, targetSegment: string, timing: GlyphTiming): WidthTiming;
package/dist/timing.js ADDED
@@ -0,0 +1,68 @@
1
+ import { TUNING } from "./constants.js";
2
+ const { animation, glyph, variation, width } = TUNING;
3
+ export function resolveAnimationOptions(options) {
4
+ return {
5
+ direction: options.direction ?? animation.direction,
6
+ stagger: options.stagger ?? animation.stagger,
7
+ duration: options.duration ?? animation.duration,
8
+ exitOffset: options.exitOffset ?? animation.exitOffset,
9
+ easing: options.easing ?? animation.easing,
10
+ bounce: options.bounce ?? animation.bounce,
11
+ color: options.color,
12
+ colorFade: options.colorFade ?? animation.colorFade,
13
+ skipUnchanged: options.skipUnchanged ?? animation.skipUnchanged,
14
+ interrupt: options.interrupt ?? animation.interrupt,
15
+ };
16
+ }
17
+ /** Return stable pseudo-random variation in the range -1 to 1. */
18
+ function getDeterministicVariation(segmentIndex, variationChannel) {
19
+ // Start at one so the first grapheme never uses a zero seed.
20
+ const oneBasedSegmentIndex = segmentIndex + 1;
21
+ const pseudoRandomSeed = Math.sin(oneBasedSegmentIndex * variation.indexFrequency +
22
+ variationChannel * variation.channelFrequency) * variation.seedScale;
23
+ const zeroToOne = pseudoRandomSeed - Math.floor(pseudoRandomSeed);
24
+ // Stretch 0..1 to 0..2, then center it around zero to get -1..1.
25
+ return zeroToOne * 2 - 1;
26
+ }
27
+ export function calculateGlyphTiming(segmentIndex, targetSegmentCount, isTrailingSlot, options) {
28
+ const { duration, stagger, bounce } = options;
29
+ const variationMultiplier = (strength, channel) => 1 + bounce * strength * getDeterministicVariation(segmentIndex, channel);
30
+ const durationMs = Math.round(duration *
31
+ (isTrailingSlot ? glyph.tailDurationMultiplier : 1) *
32
+ variationMultiplier(glyph.durationVariationStrength, variation.durationChannel));
33
+ const staggerPosition = isTrailingSlot
34
+ ? targetSegmentCount * glyph.tailStaggerStartMultiplier +
35
+ (segmentIndex - targetSegmentCount) *
36
+ glyph.tailStaggerStepMultiplier
37
+ : segmentIndex;
38
+ const startingTiltDegrees = Number((bounce *
39
+ glyph.maximumTiltDegrees *
40
+ getDeterministicVariation(segmentIndex, variation.tiltChannel)).toFixed(glyph.tiltDecimalPlaces));
41
+ return {
42
+ durationMs,
43
+ startDelayMs: Math.round(staggerPosition *
44
+ stagger *
45
+ variationMultiplier(glyph.staggerVariationStrength, variation.staggerChannel)),
46
+ startingTiltDegrees,
47
+ };
48
+ }
49
+ export function calculateWidthTiming(currentSegment, targetSegment, timing) {
50
+ const scaledDuration = (multiplier) => Math.max(width.minimumTransitionMs, Math.round(timing.durationMs * multiplier));
51
+ if (targetSegment === "") {
52
+ return {
53
+ startDelayMs: timing.startDelayMs +
54
+ Math.round(timing.durationMs * width.tailCollapseDelayMultiplier),
55
+ durationMs: scaledDuration(width.tailCollapseDurationMultiplier),
56
+ };
57
+ }
58
+ if (currentSegment === "") {
59
+ return {
60
+ startDelayMs: timing.startDelayMs,
61
+ durationMs: scaledDuration(width.newSlotExpansionDurationMultiplier),
62
+ };
63
+ }
64
+ return {
65
+ startDelayMs: timing.startDelayMs,
66
+ durationMs: timing.durationMs,
67
+ };
68
+ }
package/dist/vue.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, } from "vue";
2
- import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText.js";
2
+ import { animateSlotText, clearSlotText, renderTextWithCssFallback, } from "./slotText.js";
3
3
  export const SlotText = defineComponent({
4
4
  name: "SlotText",
5
5
  inheritAttrs: false,
@@ -19,7 +19,7 @@ export const SlotText = defineComponent({
19
19
  onMounted(() => {
20
20
  if (!element.value)
21
21
  return;
22
- buildSlotText(element.value, props.text);
22
+ renderTextWithCssFallback(element.value, props.text);
23
23
  mounted = true;
24
24
  });
25
25
  watch(() => props.text, (text) => {
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>slot-text example</title>
6
+ <title>slot-text playground</title>
7
7
  <link rel="stylesheet" href="../../style.css" />
8
8
  <style>
9
9
  body {
@@ -11,24 +11,63 @@
11
11
  margin: 0;
12
12
  display: grid;
13
13
  place-items: center;
14
- font: 600 18px/1.2 ui-sans-serif, system-ui, sans-serif;
14
+ font:
15
+ 600 16px/1.2 ui-sans-serif,
16
+ system-ui,
17
+ sans-serif;
15
18
  background: #0e1116;
16
19
  color: #edf2f7;
20
+ -webkit-font-smoothing: antialiased;
21
+ }
22
+
23
+ main {
24
+ display: grid;
25
+ gap: 16px;
26
+ justify-items: center;
27
+ }
28
+
29
+ p {
30
+ margin: 0;
31
+ color: #9ca3af;
32
+ font-size: 13px;
33
+ font-weight: 500;
34
+ letter-spacing: 0.02em;
17
35
  }
18
36
 
19
37
  button {
20
- border: 1px solid #2a3342;
21
- border-radius: 10px;
38
+ --button-text-color: #0c50c6;
39
+ min-width: 112px;
40
+ min-height: 44px;
41
+ border: 0;
42
+ border-radius: 12px;
22
43
  padding: 12px 18px;
23
- background: #151a22;
24
- color: inherit;
44
+ background: #edf2f7;
45
+ color: var(--button-text-color);
25
46
  font: inherit;
26
47
  cursor: pointer;
48
+ box-shadow: 0 1px 1px rgb(0 0 0 / 20%);
49
+ transition: transform 160ms cubic-bezier(0.2, 0, 0, 1);
50
+ }
51
+
52
+ button:hover {
53
+ transform: translateY(-1px);
54
+ }
55
+
56
+ button:active {
57
+ transform: scale(0.96);
58
+ }
59
+
60
+ #copy-label,
61
+ #copy-label .char-face {
62
+ color: var(--button-text-color);
27
63
  }
28
64
  </style>
29
65
  </head>
30
66
  <body>
31
- <button type="button"><span id="copy-label"></span></button>
67
+ <main>
68
+ <p>Click the button to test slot-text.</p>
69
+ <button type="button"><span id="copy-label"></span></button>
70
+ </main>
32
71
 
33
72
  <script type="module">
34
73
  import { slotText, chromatic } from "../../dist/index.js";
@@ -0,0 +1,39 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>slot-text delayed CSS example</title>
7
+ <style>
8
+ body {
9
+ min-height: 100vh;
10
+ margin: 0;
11
+ display: grid;
12
+ place-items: center;
13
+ font: 800 48px/1.1 ui-sans-serif, system-ui, sans-serif;
14
+ }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <button type="button"><span id="label">Take the next step</span></button>
19
+
20
+ <script type="module">
21
+ import { slotText } from "../../dist/index.js";
22
+
23
+ const STYLESHEET_DELAY_MS = 900;
24
+
25
+ const label = slotText(
26
+ document.querySelector("#label"),
27
+ "Take the next step",
28
+ );
29
+ label.set("Continue");
30
+
31
+ window.setTimeout(() => {
32
+ const stylesheet = document.createElement("link");
33
+ stylesheet.rel = "stylesheet";
34
+ stylesheet.href = "../../style.css";
35
+ document.head.appendChild(stylesheet);
36
+ }, STYLESHEET_DELAY_MS);
37
+ </script>
38
+ </body>
39
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slot-text",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Dependency-free text roll animation for tiny, tactile UI labels.",
5
5
  "type": "module",
6
6
  "license": "MIT",