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