1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
import "./cycle-label.css"
import { createEffect, createSignal, JSX, on } from "solid-js"
export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
value: string
onValueChange?: (value: string) => void
duration?: number | ((value: string) => number)
stagger?: number
opacity?: [number, number]
blur?: [number, number]
skewX?: number
onAnimationStart?: () => void
onAnimationEnd?: () => void
}
const segmenter =
typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
const getChars = (text: string): string[] =>
segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export function CycleLabel(props: CycleLabelProps) {
const getDuration = (text: string) => {
const d =
props.duration ??
Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ??
200
return typeof d === "function" ? d(text) : d
}
const stagger = () => props?.stagger ?? 30
const opacity = () => props?.opacity ?? [0, 1]
const blur = () => props?.blur ?? [0, 0]
const skewX = () => props?.skewX ?? 10
let containerRef: HTMLSpanElement | undefined
let isAnimating = false
const [currentText, setCurrentText] = createSignal(props.value)
const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
el.innerHTML = ""
const chars = getChars(text)
chars.forEach((char, i) => {
const span = document.createElement("span")
span.textContent = char === " " ? "\u00A0" : char
span.className = `cycle-char ${state}`
span.style.setProperty("--i", String(i))
el.appendChild(span)
})
}
const animateToText = async (newText: string) => {
if (!containerRef || isAnimating) return
if (newText === currentText()) return
isAnimating = true
props.onAnimationStart?.()
const dur = getDuration(newText)
const stag = stagger()
containerRef.style.width = containerRef.offsetWidth + "px"
const oldChars = containerRef.querySelectorAll(".cycle-char")
oldChars.forEach((c) => c.classList.replace("enter", "exit"))
const clone = containerRef.cloneNode(false) as HTMLElement
Object.assign(clone.style, {
position: "absolute",
visibility: "hidden",
width: "auto",
transition: "none",
})
setChars(clone, newText)
document.body.appendChild(clone)
const nextWidth = clone.offsetWidth
clone.remove()
const exitTime = oldChars.length * stag + dur
await wait(exitTime * 0.3)
containerRef.style.width = nextWidth + "px"
const widthDur = 200
await wait(widthDur * 0.3)
setChars(containerRef, newText, "pre")
containerRef.offsetWidth
Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
setCurrentText(newText)
props.onValueChange?.(newText)
const enterTime = getChars(newText).length * stag + dur
await wait(enterTime)
containerRef.style.width = ""
isAnimating = false
props.onAnimationEnd?.()
}
createEffect(
on(
() => props.value,
(newValue) => {
if (newValue !== currentText()) {
animateToText(newValue)
}
},
),
)
const initRef = (el: HTMLSpanElement) => {
containerRef = el
setChars(el, props.value)
}
return (
<span
ref={initRef}
class={`cycle-label ${props.class ?? ""}`}
style={{
"--c-duration": `${getDuration(currentText())}ms`,
"--c-stagger": `${stagger()}ms`,
"--c-opacity-start": opacity()[0],
"--c-opacity-end": opacity()[1],
"--c-blur-start": `${blur()[0]}px`,
"--c-blur-end": `${blur()[1]}px`,
"--c-skew": `${skewX()}deg`,
...(typeof props.style === "object" ? props.style : {}),
}}
/>
)
}
|