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 +21 -0
- package/README.md +156 -0
- package/assets/usage.svg +32 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +27 -0
- package/dist/react.d.ts +7 -0
- package/dist/react.js +40 -0
- package/dist/slotText.d.ts +64 -0
- package/dist/slotText.js +186 -0
- package/dist/vue.d.ts +25 -0
- package/dist/vue.js +42 -0
- package/examples/basic/index.html +52 -0
- package/examples/react.tsx +27 -0
- package/examples/vue.vue +28 -0
- package/package.json +58 -0
- package/style.css +28 -0
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
|
+

|
|
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.
|
package/assets/usage.svg
ADDED
|
@@ -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>
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -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;
|
package/dist/slotText.js
ADDED
|
@@ -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
|
+
}
|
package/examples/vue.vue
ADDED
|
@@ -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
|
+
}
|