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
|
import React, { useEffect, useRef, useState } from 'react'
/**
* TypewriterRotator
*
* Cycles through a list of phrases with a typing-and-deleting effect.
* Designed for the soryu landing hero — sits inside a centred container,
* so the caller controls position and the component just renders text.
*
* Honours `prefers-reduced-motion` by rendering the first phrase only,
* statically.
*/
interface TypewriterRotatorProps {
phrases: string[]
/** ms per character while typing */
typeMs?: number
/** ms per character while deleting */
deleteMs?: number
/** ms to hold a fully-typed phrase before deleting */
holdMs?: number
/** ms to wait after deletion before the next phrase types in */
gapMs?: number
className?: string
}
export function TypewriterRotator({
phrases,
typeMs = 55,
deleteMs = 28,
holdMs = 2200,
gapMs = 320,
className,
}: TypewriterRotatorProps) {
const [text, setText] = useState('')
const [index, setIndex] = useState(0)
const [phase, setPhase] = useState<'typing' | 'holding' | 'deleting' | 'gap'>('typing')
const reduceMotion = useRef(
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
)
useEffect(() => {
if (reduceMotion.current) {
setText(phrases[0] ?? '')
return
}
const current = phrases[index % phrases.length] ?? ''
let timer: number
if (phase === 'typing') {
if (text.length < current.length) {
timer = window.setTimeout(() => setText(current.slice(0, text.length + 1)), typeMs)
} else {
timer = window.setTimeout(() => setPhase('holding'), 0)
}
} else if (phase === 'holding') {
// Skip the hold-then-delete cycle entirely if there's only one phrase
if (phrases.length <= 1) return
timer = window.setTimeout(() => setPhase('deleting'), holdMs)
} else if (phase === 'deleting') {
if (text.length > 0) {
timer = window.setTimeout(() => setText(current.slice(0, text.length - 1)), deleteMs)
} else {
timer = window.setTimeout(() => setPhase('gap'), 0)
}
} else if (phase === 'gap') {
timer = window.setTimeout(() => {
setIndex((i) => (i + 1) % phrases.length)
setPhase('typing')
}, gapMs)
}
return () => window.clearTimeout(timer)
}, [text, phase, index, phrases, typeMs, deleteMs, holdMs, gapMs])
return (
<span className={className} role="status" aria-live="polite">
<span className="tw-text">{text}</span>
<span className="tw-caret" aria-hidden="true" />
</span>
)
}
|