summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components/cycle-label.tsx
blob: dc12bd75c8714b8231afb1f0a1d37d3702cf80a7 (plain)
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 : {}),
      }}
    />
  )
}