slot-text 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Belyi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # slot-text
2
+
3
+ Dependency-free text roll animation for tiny, tactile UI labels.
4
+
5
+ ![slot-text usage card](./assets/usage.svg)
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install slot-text
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ### Vanilla
16
+
17
+ ```ts
18
+ import "slot-text/style.css";
19
+ import { slotText, chromatic } from "slot-text";
20
+
21
+ const label = slotText(document.querySelector("#copy")!, "Copy");
22
+
23
+ label.set("Copied", {
24
+ direction: "up",
25
+ color: chromatic(),
26
+ });
27
+ ```
28
+
29
+ ### React
30
+
31
+ ```tsx
32
+ import "slot-text/style.css";
33
+ import { SlotText } from "slot-text/react";
34
+ import { chromatic } from "slot-text";
35
+
36
+ export function CopyLabel({ copied }: { copied: boolean }) {
37
+ return (
38
+ <SlotText
39
+ text={copied ? "Copied" : "Copy"}
40
+ options={{
41
+ direction: copied ? "up" : "down",
42
+ skipUnchanged: false,
43
+ color: copied ? chromatic() : undefined,
44
+ }}
45
+ />
46
+ );
47
+ }
48
+ ```
49
+
50
+ ### Vue
51
+
52
+ ```vue
53
+ <script setup lang="ts">
54
+ import "slot-text/style.css";
55
+ import { SlotText } from "slot-text/vue";
56
+ import { chromatic } from "slot-text";
57
+
58
+ const options = {
59
+ direction: "up",
60
+ skipUnchanged: false,
61
+ color: chromatic(),
62
+ } as const;
63
+ </script>
64
+
65
+ <template>
66
+ <SlotText text="Copied" :options="options" />
67
+ </template>
68
+ ```
69
+
70
+ ## API
71
+
72
+ Vanilla controller:
73
+
74
+ ```ts
75
+ const label = slotText(element, "Copy", options);
76
+
77
+ label.set("Copied");
78
+ label.set("Copy", { direction: "down" });
79
+ label.destroy();
80
+ ```
81
+
82
+ Framework components:
83
+
84
+ ```ts
85
+ import { SlotText as ReactSlotText } from "slot-text/react";
86
+ import { SlotText as VueSlotText } from "slot-text/vue";
87
+ ```
88
+
89
+ Low-level helpers:
90
+
91
+ ```ts
92
+ import {
93
+ buildSlotText,
94
+ animateSlotText,
95
+ chromatic,
96
+ } from "slot-text";
97
+ ```
98
+
99
+ ## Options
100
+
101
+ ```ts
102
+ type SlotOptions = {
103
+ direction?: "up" | "down";
104
+ stagger?: number;
105
+ duration?: number;
106
+ exitOffset?: number;
107
+ easing?: string;
108
+ bounce?: number;
109
+ color?: string | ((index: number, total: number) => string);
110
+ colorFade?: number;
111
+ skipUnchanged?: boolean;
112
+ };
113
+ ```
114
+
115
+ Defaults are tuned for a soft, springy roll:
116
+
117
+ ```ts
118
+ {
119
+ direction: "down",
120
+ stagger: 45,
121
+ duration: 300,
122
+ exitOffset: 50,
123
+ easing: "cubic-bezier(0.34, 1.56, 0.64, 1)",
124
+ bounce: 0.6,
125
+ colorFade: 280,
126
+ skipUnchanged: true,
127
+ }
128
+ ```
129
+
130
+ ## Example
131
+
132
+ ```html
133
+ <button>
134
+ <span id="copy-label"></span>
135
+ </button>
136
+
137
+ <script type="module">
138
+ import "slot-text/style.css";
139
+ import { slotText } from "slot-text";
140
+
141
+ const label = slotText(document.querySelector("#copy-label"), "Copy");
142
+
143
+ document.querySelector("button").addEventListener("click", () => {
144
+ label.set("Copied", { direction: "up", skipUnchanged: false });
145
+ window.setTimeout(() => label.set("Copy"), 1400);
146
+ });
147
+ </script>
148
+ ```
149
+
150
+ ## Notes
151
+
152
+ - Browser-only DOM utility.
153
+ - Core API has no runtime dependencies.
154
+ - React and Vue are optional peer dependencies. Plain JS users do not need them.
155
+ - Works best on short labels, buttons, counters, and command text.
156
+ - Import the CSS once before using the animation.
@@ -0,0 +1,32 @@
1
+ <svg width="920" height="520" viewBox="0 0 920 520" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
2
+ <title id="title">slot-text usage example</title>
3
+ <desc id="desc">A visual code card showing how to import and use slot-text.</desc>
4
+ <rect width="920" height="520" rx="28" fill="#0E1116"/>
5
+ <rect x="34" y="34" width="852" height="452" rx="18" fill="#151A22" stroke="#2A3342"/>
6
+ <circle cx="66" cy="66" r="7" fill="#FF6B6B"/>
7
+ <circle cx="90" cy="66" r="7" fill="#FFD166"/>
8
+ <circle cx="114" cy="66" r="7" fill="#5CD85C"/>
9
+ <text x="58" y="118" fill="#7D8A9B" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">npm install slot-text</text>
10
+ <rect x="58" y="146" width="804" height="272" rx="14" fill="#0B0F14" stroke="#252D3A"/>
11
+ <text x="86" y="194" fill="#7D8A9B" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">import</text>
12
+ <text x="159" y="194" fill="#F5D76E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">"slot-text/style.css"</text>
13
+ <text x="379" y="194" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">;</text>
14
+ <text x="86" y="232" fill="#7D8A9B" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">import</text>
15
+ <text x="159" y="232" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18"> { slotText, chromatic } </text>
16
+ <text x="437" y="232" fill="#7D8A9B" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">from</text>
17
+ <text x="492" y="232" fill="#F5D76E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18"> "slot-text"</text>
18
+ <text x="625" y="232" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">;</text>
19
+ <text x="86" y="294" fill="#7D8A9B" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">const</text>
20
+ <text x="148" y="294" fill="#8ED3FF" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18"> label</text>
21
+ <text x="225" y="294" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18"> = slotText(copyEl, </text>
22
+ <text x="431" y="294" fill="#F5D76E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">"Copy"</text>
23
+ <text x="499" y="294" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">);</text>
24
+ <text x="86" y="356" fill="#8ED3FF" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">label</text>
25
+ <text x="142" y="356" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">.set(</text>
26
+ <text x="203" y="356" fill="#F5D76E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">"Copied"</text>
27
+ <text x="289" y="356" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">, { direction: </text>
28
+ <text x="467" y="356" fill="#F5D76E" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">"up"</text>
29
+ <text x="516" y="356" fill="#B9C4D0" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="18">, color: chromatic() });</text>
30
+ <rect x="58" y="438" width="160" height="34" rx="17" fill="#EAF2FF"/>
31
+ <text x="82" y="461" fill="#0E1116" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="16" font-weight="700">Copy → Copied</text>
32
+ </svg>
@@ -0,0 +1,17 @@
1
+ export { animateSlotText, buildSlotText, chromatic, clearSlotText, type ChromaticOptions, type SlotOptions, } from "./slotText";
2
+ import { type SlotOptions } from "./slotText";
3
+ export interface SlotTextController {
4
+ readonly element: HTMLElement;
5
+ readonly value: string;
6
+ set(text: string, options?: SlotOptions): void;
7
+ destroy(): void;
8
+ }
9
+ /**
10
+ * Create a text-roll controller for one element.
11
+ *
12
+ * Import `slot-text/style.css` once in your app, then call:
13
+ *
14
+ * const label = slotText(buttonLabel, "Copy");
15
+ * label.set("Copied", { direction: "up" });
16
+ */
17
+ export declare function slotText(element: HTMLElement, initialText: string, options?: SlotOptions): SlotTextController;
package/dist/index.js ADDED
@@ -0,0 +1,27 @@
1
+ export { animateSlotText, buildSlotText, chromatic, clearSlotText, } from "./slotText";
2
+ import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
3
+ /**
4
+ * Create a text-roll controller for one element.
5
+ *
6
+ * Import `slot-text/style.css` once in your app, then call:
7
+ *
8
+ * const label = slotText(buttonLabel, "Copy");
9
+ * label.set("Copied", { direction: "up" });
10
+ */
11
+ export function slotText(element, initialText, options = {}) {
12
+ let value = initialText;
13
+ buildSlotText(element, initialText);
14
+ return {
15
+ element,
16
+ get value() {
17
+ return value;
18
+ },
19
+ set(text, nextOptions = {}) {
20
+ value = text;
21
+ animateSlotText(element, text, { ...options, ...nextOptions });
22
+ },
23
+ destroy() {
24
+ clearSlotText(element, value);
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,7 @@
1
+ import { type HTMLAttributes } from "react";
2
+ import { type SlotOptions } from "./slotText";
3
+ export interface SlotTextProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {
4
+ text: string;
5
+ options?: SlotOptions;
6
+ }
7
+ export declare const SlotText: import("react").ForwardRefExoticComponent<SlotTextProps & import("react").RefAttributes<HTMLSpanElement>>;
package/dist/react.js ADDED
@@ -0,0 +1,40 @@
1
+ import { createElement, forwardRef, useEffect, useImperativeHandle, useRef, } from "react";
2
+ import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
3
+ export const SlotText = forwardRef(({ text, options, "aria-label": ariaLabel, ...props }, forwardedRef) => {
4
+ const elementRef = useRef(null);
5
+ const mountedRef = useRef(false);
6
+ const firstTextEffectRef = useRef(true);
7
+ const optionsRef = useRef(options);
8
+ useImperativeHandle(forwardedRef, () => elementRef.current, []);
9
+ useEffect(() => {
10
+ optionsRef.current = options;
11
+ }, [options]);
12
+ useEffect(() => {
13
+ const element = elementRef.current;
14
+ if (!element)
15
+ return;
16
+ buildSlotText(element, text);
17
+ mountedRef.current = true;
18
+ return () => {
19
+ clearSlotText(element);
20
+ mountedRef.current = false;
21
+ firstTextEffectRef.current = true;
22
+ };
23
+ }, []);
24
+ useEffect(() => {
25
+ const element = elementRef.current;
26
+ if (!element || !mountedRef.current)
27
+ return;
28
+ if (firstTextEffectRef.current) {
29
+ firstTextEffectRef.current = false;
30
+ return;
31
+ }
32
+ animateSlotText(element, text, optionsRef.current);
33
+ }, [text]);
34
+ return createElement("span", {
35
+ ...props,
36
+ "aria-label": ariaLabel ?? text,
37
+ ref: elementRef,
38
+ });
39
+ });
40
+ SlotText.displayName = "SlotText";
@@ -0,0 +1,64 @@
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
+ export interface SlotOptions {
15
+ /** "down" rolls glyphs downward (enter from top); "up" rolls upward. */
16
+ direction?: "up" | "down";
17
+ /** Per-character stagger in ms (default 45). */
18
+ stagger?: number;
19
+ /** Slide duration per character in ms (default 300). */
20
+ duration?: number;
21
+ /** How long the incoming glyph trails the outgoing one, in ms (default 50). */
22
+ exitOffset?: number;
23
+ /** Easing — defaults to a springy, overshooting "back" curve. */
24
+ easing?: string;
25
+ /**
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.
29
+ */
30
+ bounce?: number;
31
+ /**
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.
36
+ */
37
+ color?: string | ((index: number, total: number) => string);
38
+ /** How long the chromatic tint takes to fade back to rest, in ms (default 280). */
39
+ colorFade?: number;
40
+ /**
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.
45
+ */
46
+ skipUnchanged?: boolean;
47
+ }
48
+ export interface ChromaticOptions {
49
+ from?: number;
50
+ spread?: number;
51
+ saturation?: number;
52
+ lightness?: number;
53
+ }
54
+ /**
55
+ * Build a `color` function that sweeps the hue across the line, giving every
56
+ * glyph its own color so the roll lands as a chromatic spectrum.
57
+ *
58
+ * animateSlotText(el, txt, { color: chromatic() }); // full rainbow
59
+ * animateSlotText(el, txt, { color: chromatic({ from: 18 }) }); // start gold
60
+ */
61
+ export declare function chromatic({ from, spread, saturation, lightness, }?: ChromaticOptions): (index: number, total: number) => string;
62
+ export declare function buildSlotText(container: HTMLElement, text: string): void;
63
+ export declare function animateSlotText(container: HTMLElement, toText: string, options?: SlotOptions): void;
64
+ export declare function clearSlotText(container: HTMLElement, text?: string): void;
@@ -0,0 +1,186 @@
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
+ };
24
+ const NBSP = "\u00A0";
25
+ const glyph = (char) => (char === " " ? NBSP : char);
26
+ /**
27
+ * Build a `color` function that sweeps the hue across the line, giving every
28
+ * glyph its own color so the roll lands as a chromatic spectrum.
29
+ *
30
+ * animateSlotText(el, txt, { color: chromatic() }); // full rainbow
31
+ * animateSlotText(el, txt, { color: chromatic({ from: 18 }) }); // start gold
32
+ */
33
+ export function chromatic({ from = 0, spread = 320, saturation = 92, lightness = 60, } = {}) {
34
+ return (index, total) => {
35
+ const t = total <= 1 ? 0 : index / (total - 1);
36
+ return `hsl(${(from + t * spread) % 360} ${saturation}% ${lightness}%)`;
37
+ };
38
+ }
39
+ const states = new WeakMap();
40
+ /** Cancel any running animation on a container and snap it to its target text. */
41
+ function settle(container) {
42
+ const state = states.get(container);
43
+ if (!state)
44
+ return;
45
+ state.timers.forEach((t) => window.clearTimeout(t));
46
+ states.delete(container);
47
+ // Rebuild a pristine DOM at the text the interrupted roll was heading toward,
48
+ // so the next animation starts from a clean, non-overlapping baseline.
49
+ buildSlotText(container, state.target);
50
+ }
51
+ function makeFace(char) {
52
+ const face = document.createElement("span");
53
+ face.className = "char-face";
54
+ face.textContent = glyph(char);
55
+ return face;
56
+ }
57
+ function buildSlot(char) {
58
+ const slot = document.createElement("span");
59
+ slot.className = "char-slot";
60
+ slot.dataset.char = char;
61
+ // Invisible sizer keeps the cell exactly the width/height of its glyph, so
62
+ // the absolutely-positioned animating faces never reflow the line.
63
+ const sizer = document.createElement("span");
64
+ sizer.className = "char-sizer";
65
+ sizer.textContent = glyph(char);
66
+ slot.append(sizer, makeFace(char));
67
+ return slot;
68
+ }
69
+ export function buildSlotText(container, text) {
70
+ container.classList.add("slot-text");
71
+ container.replaceChildren(...Array.from(text, buildSlot));
72
+ }
73
+ export function animateSlotText(container, toText, options = {}) {
74
+ const { direction, stagger, duration, exitOffset, easing, bounce, color, colorFade, skipUnchanged, } = {
75
+ ...DEFAULTS,
76
+ ...options,
77
+ };
78
+ // Interrupt: if a previous roll is still running, fast-forward it to its
79
+ // target and tear down its timers before we start fresh. This is what kills
80
+ // the "switch bun→npm mid-animation" glitch.
81
+ settle(container);
82
+ // First run / empty container → just build it.
83
+ if (!container.querySelector(".char-slot")) {
84
+ buildSlotText(container, toText);
85
+ return;
86
+ }
87
+ const slots = Array.from(container.querySelectorAll(".char-slot"));
88
+ const fromText = slots.map((s) => s.dataset.char ?? "").join("");
89
+ const maxLen = Math.max(fromText.length, toText.length);
90
+ // Whole-pixel slide distance = one cell height, so glyphs clip cleanly.
91
+ // If layout has not produced dimensions yet, fall back to line-height/font-size
92
+ // so the text still rolls instead of swapping in place.
93
+ const sample = slots.find((s) => (s.dataset.char ?? "") !== "") ?? slots[0];
94
+ const cs = getComputedStyle(container);
95
+ const H = Math.round(sample?.getBoundingClientRect().height ||
96
+ sample?.offsetHeight ||
97
+ container.getBoundingClientRect().height ||
98
+ parseFloat(cs.lineHeight) ||
99
+ 0) || Math.round(parseFloat(cs.fontSize) * 1.3) || 18;
100
+ // Resting color to settle the chromatic flash back to.
101
+ const restColor = color ? cs.color : "";
102
+ // Pre-create any extra cells up front so the row never reflows mid-roll.
103
+ for (let i = slots.length; i < maxLen; i++) {
104
+ const slot = buildSlot("");
105
+ container.appendChild(slot);
106
+ slots.push(slot);
107
+ }
108
+ const timers = [];
109
+ const state = { timers, target: toText };
110
+ states.set(container, state);
111
+ // down: new enters from above (-H to 0), old exits below (0 to +H)
112
+ // up: new enters from below (+H to 0), old exits above (0 to -H)
113
+ const outY = direction === "down" ? H : -H;
114
+ const inStart = direction === "down" ? -H : H;
115
+ // A tiny deterministic-feeling jitter in [-1, 1] per character. Scaled by
116
+ // `bounce` it gives each glyph its own speed and a little tilt-wobble, so the
117
+ // line does not land as one rigid block. Every letter has some personality.
118
+ const wobble = (i, salt) => {
119
+ const n = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453;
120
+ return (n - Math.floor(n)) * 2 - 1;
121
+ };
122
+ // Track the slowest letter so the safety-net snap waits for everyone.
123
+ let maxEnd = 0;
124
+ for (let i = 0; i < maxLen; i++) {
125
+ const fromChar = fromText[i] || "";
126
+ const toChar = toText[i] || "";
127
+ if (fromChar === toChar && (skipUnchanged || fromChar === ""))
128
+ continue;
129
+ const slot = slots[i];
130
+ const sizer = slot.querySelector(".char-sizer");
131
+ const oldFace = slot.querySelector(".char-face");
132
+ sizer.textContent = glyph(toChar); // resize the cell to the new glyph
133
+ const tint = typeof color === "function" ? color(i, maxLen) : color;
134
+ // 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);
139
+ const rollTrans = `transform ${d}ms ${easing}`;
140
+ const trans = color ? `${rollTrans}, color ${colorFade}ms linear ${d}ms` : rollTrans;
141
+ const newFace = makeFace(toChar);
142
+ newFace.style.transformOrigin = "50% 50%";
143
+ newFace.style.transform = `translateY(${inStart}px) rotate(${tilt}deg)`;
144
+ if (tint)
145
+ newFace.style.color = tint;
146
+ slot.appendChild(newFace);
147
+ void slot.offsetWidth; // commit start transforms
148
+ maxEnd = Math.max(maxEnd, base + exitOffset + d + (color ? colorFade : 0));
149
+ // Outgoing glyph slides away first (with its own little counter-tilt).
150
+ if (oldFace) {
151
+ timers.push(window.setTimeout(() => {
152
+ oldFace.style.transition = rollTrans;
153
+ oldFace.style.transform = `translateY(${outY}px) rotate(${-Number(tilt)}deg)`;
154
+ }, base));
155
+ }
156
+ // Incoming glyph chases it in (and, if tinted, fades to rest afterwards).
157
+ timers.push(window.setTimeout(() => {
158
+ newFace.style.transition = trans;
159
+ newFace.style.transform = "translateY(0) rotate(0deg)";
160
+ if (color)
161
+ newFace.style.color = restColor;
162
+ const done = (e) => {
163
+ if (e.propertyName !== "transform")
164
+ return; // ignore the colour fade
165
+ newFace.removeEventListener("transitionend", done);
166
+ slot.dataset.char = toChar;
167
+ slot.querySelectorAll(".char-face").forEach((f) => {
168
+ if (f !== newFace)
169
+ f.remove();
170
+ });
171
+ };
172
+ newFace.addEventListener("transitionend", done);
173
+ }, base + exitOffset));
174
+ }
175
+ // Safety net: snap to a pristine DOM once the slowest letter has settled.
176
+ const total = maxEnd + 80;
177
+ timers.push(window.setTimeout(() => {
178
+ states.delete(container);
179
+ buildSlotText(container, toText);
180
+ }, total));
181
+ }
182
+ export function clearSlotText(container, text = "") {
183
+ settle(container);
184
+ container.classList.remove("slot-text");
185
+ container.textContent = text;
186
+ }
package/dist/vue.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { type PropType } from "vue";
2
+ import { type SlotOptions } from "./slotText";
3
+ export declare const SlotText: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
4
+ text: {
5
+ type: StringConstructor;
6
+ required: true;
7
+ };
8
+ options: {
9
+ type: PropType<SlotOptions>;
10
+ default: undefined;
11
+ };
12
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
13
+ [key: string]: any;
14
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
15
+ text: {
16
+ type: StringConstructor;
17
+ required: true;
18
+ };
19
+ options: {
20
+ type: PropType<SlotOptions>;
21
+ default: undefined;
22
+ };
23
+ }>> & Readonly<{}>, {
24
+ options: SlotOptions;
25
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
package/dist/vue.js ADDED
@@ -0,0 +1,42 @@
1
+ import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch, } from "vue";
2
+ import { animateSlotText, buildSlotText, clearSlotText, } from "./slotText";
3
+ export const SlotText = defineComponent({
4
+ name: "SlotText",
5
+ inheritAttrs: false,
6
+ props: {
7
+ text: {
8
+ type: String,
9
+ required: true,
10
+ },
11
+ options: {
12
+ type: Object,
13
+ default: undefined,
14
+ },
15
+ },
16
+ setup(props, { attrs }) {
17
+ const element = ref(null);
18
+ let mounted = false;
19
+ onMounted(() => {
20
+ if (!element.value)
21
+ return;
22
+ buildSlotText(element.value, props.text);
23
+ mounted = true;
24
+ });
25
+ watch(() => props.text, (text) => {
26
+ if (!element.value || !mounted)
27
+ return;
28
+ animateSlotText(element.value, text, props.options);
29
+ });
30
+ onBeforeUnmount(() => {
31
+ if (!element.value)
32
+ return;
33
+ clearSlotText(element.value);
34
+ mounted = false;
35
+ });
36
+ return () => h("span", {
37
+ ...attrs,
38
+ "aria-label": attrs["aria-label"] ?? props.text,
39
+ ref: element,
40
+ });
41
+ },
42
+ });
@@ -0,0 +1,52 @@
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 example</title>
7
+ <link rel="stylesheet" href="../../style.css" />
8
+ <style>
9
+ body {
10
+ min-height: 100vh;
11
+ margin: 0;
12
+ display: grid;
13
+ place-items: center;
14
+ font: 600 18px/1.2 ui-sans-serif, system-ui, sans-serif;
15
+ background: #0e1116;
16
+ color: #edf2f7;
17
+ }
18
+
19
+ button {
20
+ border: 1px solid #2a3342;
21
+ border-radius: 10px;
22
+ padding: 12px 18px;
23
+ background: #151a22;
24
+ color: inherit;
25
+ font: inherit;
26
+ cursor: pointer;
27
+ }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <button type="button"><span id="copy-label"></span></button>
32
+
33
+ <script type="module">
34
+ import { slotText, chromatic } from "../../dist/index.js";
35
+
36
+ const button = document.querySelector("button");
37
+ const label = slotText(document.querySelector("#copy-label"), "Copy");
38
+
39
+ button.addEventListener("click", () => {
40
+ label.set("Copied", {
41
+ direction: "up",
42
+ skipUnchanged: false,
43
+ color: chromatic({ from: 190 }),
44
+ });
45
+
46
+ window.setTimeout(() => {
47
+ label.set("Copy", { direction: "down", skipUnchanged: false });
48
+ }, 1400);
49
+ });
50
+ </script>
51
+ </body>
52
+ </html>
@@ -0,0 +1,27 @@
1
+ import "slot-text/style.css";
2
+ import { useState } from "react";
3
+ import { chromatic } from "slot-text";
4
+ import { SlotText } from "slot-text/react";
5
+
6
+ export function CopyButton() {
7
+ const [copied, setCopied] = useState(false);
8
+
9
+ return (
10
+ <button
11
+ type="button"
12
+ onClick={() => {
13
+ setCopied(true);
14
+ window.setTimeout(() => setCopied(false), 1400);
15
+ }}
16
+ >
17
+ <SlotText
18
+ text={copied ? "Copied" : "Copy"}
19
+ options={{
20
+ direction: copied ? "up" : "down",
21
+ skipUnchanged: false,
22
+ color: copied ? chromatic({ from: 190 }) : undefined,
23
+ }}
24
+ />
25
+ </button>
26
+ );
27
+ }
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ import "slot-text/style.css";
3
+ import { ref } from "vue";
4
+ import { chromatic } from "slot-text";
5
+ import { SlotText } from "slot-text/vue";
6
+
7
+ const copied = ref(false);
8
+
9
+ function copy() {
10
+ copied.value = true;
11
+ window.setTimeout(() => {
12
+ copied.value = false;
13
+ }, 1400);
14
+ }
15
+ </script>
16
+
17
+ <template>
18
+ <button type="button" @click="copy">
19
+ <SlotText
20
+ :text="copied ? 'Copied' : 'Copy'"
21
+ :options="{
22
+ direction: copied ? 'up' : 'down',
23
+ skipUnchanged: false,
24
+ color: copied ? chromatic({ from: 190 }) : undefined,
25
+ }"
26
+ />
27
+ </button>
28
+ </template>
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "slot-text",
3
+ "version": "0.1.0",
4
+ "description": "Dependency-free text roll animation for tiny, tactile UI labels.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Daniel Belyi",
8
+ "sideEffects": [
9
+ "./style.css"
10
+ ],
11
+ "files": [
12
+ "assets",
13
+ "dist",
14
+ "examples",
15
+ "style.css",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./react": {
25
+ "types": "./dist/react.d.ts",
26
+ "import": "./dist/react.js"
27
+ },
28
+ "./vue": {
29
+ "types": "./dist/vue.d.ts",
30
+ "import": "./dist/vue.js"
31
+ },
32
+ "./style.css": "./style.css"
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "peerDependencies": {
36
+ "react": ">=18 <20",
37
+ "vue": ">=3.4 <4"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ },
43
+ "vue": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json",
49
+ "check": "tsc -p tsconfig.json --noEmit",
50
+ "pack:check": "npm pack --dry-run"
51
+ },
52
+ "devDependencies": {
53
+ "@types/react": "^19.2.17",
54
+ "react": "^19.2.7",
55
+ "typescript": "^5.8.3",
56
+ "vue": "^3.5.35"
57
+ }
58
+ }
package/style.css ADDED
@@ -0,0 +1,28 @@
1
+ .slot-text {
2
+ display: inline-flex;
3
+ white-space: pre;
4
+ }
5
+
6
+ .char-slot {
7
+ position: relative;
8
+ display: inline-flex;
9
+ justify-content: center;
10
+ overflow: hidden;
11
+ line-height: 1.3;
12
+ vertical-align: bottom;
13
+ }
14
+
15
+ .char-sizer {
16
+ visibility: hidden;
17
+ white-space: pre;
18
+ }
19
+
20
+ .char-face {
21
+ position: absolute;
22
+ inset: 0;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ white-space: pre;
27
+ will-change: transform;
28
+ }