85 lines
2.6 KiB
TypeScript
85 lines
2.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import './typing-words.css';
|
|
|
|
const WORDS = ['plan', 'idea', 'problem', 'goals'] as const;
|
|
|
|
const TYPING_MS = 90;
|
|
const DELETING_MS = 55;
|
|
const PAUSE_AFTER_TYPED_MS = 2000;
|
|
const PAUSE_AFTER_DELETED_MS = 400;
|
|
|
|
type Phase = 'typing' | 'pausing' | 'deleting' | 'waiting';
|
|
|
|
export default function TypingWords() {
|
|
const [wordIndex, setWordIndex] = useState(0);
|
|
const [charIndex, setCharIndex] = useState(0);
|
|
const [phase, setPhase] = useState<Phase>('typing');
|
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
setPrefersReducedMotion(media.matches);
|
|
|
|
const onChange = () => setPrefersReducedMotion(media.matches);
|
|
media.addEventListener('change', onChange);
|
|
return () => media.removeEventListener('change', onChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (prefersReducedMotion) return;
|
|
|
|
const currentWord = WORDS[wordIndex];
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
|
|
if (phase === 'typing') {
|
|
if (charIndex < currentWord.length) {
|
|
timeoutId = setTimeout(() => setCharIndex((i) => i + 1), TYPING_MS);
|
|
} else {
|
|
timeoutId = setTimeout(() => setPhase('pausing'), PAUSE_AFTER_TYPED_MS);
|
|
}
|
|
} else if (phase === 'pausing') {
|
|
timeoutId = setTimeout(() => setPhase('deleting'), 0);
|
|
} else if (phase === 'deleting') {
|
|
if (charIndex > 0) {
|
|
timeoutId = setTimeout(() => setCharIndex((i) => i - 1), DELETING_MS);
|
|
} else {
|
|
timeoutId = setTimeout(() => {
|
|
setWordIndex((i) => (i + 1) % WORDS.length);
|
|
setPhase('waiting');
|
|
}, PAUSE_AFTER_DELETED_MS);
|
|
}
|
|
} else if (phase === 'waiting') {
|
|
timeoutId = setTimeout(() => setPhase('typing'), 0);
|
|
}
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [charIndex, phase, wordIndex, prefersReducedMotion]);
|
|
|
|
useEffect(() => {
|
|
if (!prefersReducedMotion) return;
|
|
|
|
const intervalId = setInterval(() => {
|
|
setWordIndex((i) => (i + 1) % WORDS.length);
|
|
}, 3000);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [prefersReducedMotion]);
|
|
|
|
const displayedWord = prefersReducedMotion
|
|
? WORDS[wordIndex]
|
|
: WORDS[wordIndex].slice(0, charIndex);
|
|
|
|
return (
|
|
<span className="typing-words" aria-live="polite">
|
|
<span className="typing-words-text text-transparent bg-clip-text bg-gradient-to-r from-sky-400 to-violet-800">
|
|
{displayedWord}
|
|
</span>
|
|
<span className="typing-words-cursor" aria-hidden="true">
|
|
|
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|