UNPKG

@gfazioli/mantine-text-animate

Version:

The TextAnimate component allows you to animate text with various effects.

239 lines (235 loc) 7.32 kB
'use client'; 'use strict'; var React = require('react'); function useTypewriter(options) { const { value, animate = true, multiline = false, speed: _speed = 1, delay = 2e3, loop = true, onTypeEnd, onTypeLoop, onCharType, pauseAt, withSound = false, soundVolume = 0.3 } = options; const speed = Math.max(0.1, _speed); const textArray = Array.isArray(value) ? value : [value]; const [displayText, setDisplayText] = React.useState(""); const [completedLines, setCompletedLines] = React.useState([]); const [currentTextIndex, setCurrentTextIndex] = React.useState(0); const [isTyping, setIsTyping] = React.useState(true); const [isDeleting, setIsDeleting] = React.useState(false); const [isActive, setIsActive] = React.useState(animate); const timeoutRef = React.useRef(null); const prevAnimateRef = React.useRef(animate); const prefersReducedMotionRef = React.useRef( typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); const audioContextRef = React.useRef(null); const playClick = React.useCallback( (isDeleting2 = false) => { if (!withSound || prefersReducedMotionRef.current) { return; } if (typeof AudioContext === "undefined") { return; } try { if (!audioContextRef.current) { audioContextRef.current = new AudioContext(); } const ctx = audioContextRef.current; if (ctx.state === "suspended") { ctx.resume().catch(() => { }); } const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.connect(gain); gain.connect(ctx.destination); const baseFreq = isDeleting2 ? 400 : 800; const freqVariation = (Math.random() - 0.5) * 200; oscillator.frequency.value = baseFreq + freqVariation; oscillator.type = "square"; const baseVol = Math.max(0, Math.min(1, soundVolume)); if (baseVol <= 0) { return; } const volVariation = (Math.random() - 0.5) * 0.15; const vol = Math.max(1e-3, Math.min(1, baseVol + volVariation)); const duration = 0.012 + Math.random() * 6e-3; gain.gain.setValueAtTime(vol, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(1e-3, ctx.currentTime + duration); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + duration); } catch { } }, [withSound, soundVolume] ); const currentFullText = textArray[currentTextIndex]; React.useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } }; }, []); React.useEffect(() => { if (prefersReducedMotionRef.current && animate) { if (multiline) { setCompletedLines(textArray); setDisplayText(""); } else { setDisplayText(textArray[textArray.length - 1]); } setIsTyping(false); setIsActive(false); onTypeEnd?.(); } }, []); React.useEffect(() => { if (!prevAnimateRef.current && animate) { setIsActive(true); } if (prevAnimateRef.current && !animate) { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } setIsActive(false); setDisplayText(""); setCompletedLines([]); setCurrentTextIndex(0); setIsTyping(true); setIsDeleting(false); } prevAnimateRef.current = animate; }, [animate]); const reset = React.useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } setDisplayText(""); setCompletedLines([]); setCurrentTextIndex(0); setIsTyping(true); setIsDeleting(false); }, []); const start = React.useCallback(() => { setIsActive(true); }, []); const stop = React.useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } setIsActive(false); }, []); React.useEffect(() => { if (textArray.length === 0 || !isActive) { return; } if (isTyping && !isDeleting) { if (displayText.length < currentFullText.length) { const nextIndex = displayText.length; const baseDelay = pauseAt?.[nextIndex] ?? 30 / speed; const jitter = withSound ? baseDelay * (0.7 + Math.random() * 0.6) + (Math.random() < 0.08 ? baseDelay * 1.5 : 0) : baseDelay; timeoutRef.current = setTimeout(() => { onCharType?.(currentFullText[nextIndex], nextIndex); setDisplayText(currentFullText.substring(0, nextIndex + 1)); playClick(false); }, jitter); } else { setIsTyping(false); const isLastText = currentTextIndex === textArray.length - 1; if (multiline) { timeoutRef.current = setTimeout(() => { setCompletedLines((prev) => [...prev, displayText]); setDisplayText(""); if (isLastText && loop) { setCompletedLines([]); setCurrentTextIndex(0); onTypeLoop?.(); } else if (!isLastText) { setCurrentTextIndex((prev) => prev + 1); } if (!isLastText || loop) { setIsTyping(true); } else { onTypeEnd?.(); } }, delay); } else { if ((!isLastText || loop) && !multiline) { timeoutRef.current = setTimeout(() => { setIsDeleting(true); setIsTyping(true); }, delay); } if (isLastText && loop) { onTypeLoop?.(); } if (isLastText && !loop) { onTypeEnd?.(); } } } } if (isTyping && isDeleting) { if (displayText.length > 0) { const deleteBaseDelay = 15 / speed; const deleteJitter = withSound ? deleteBaseDelay * (0.7 + Math.random() * 0.6) : deleteBaseDelay; timeoutRef.current = setTimeout(() => { setDisplayText(displayText.substring(0, displayText.length - 1)); playClick(true); }, deleteJitter); } else { setIsDeleting(false); if (currentTextIndex === textArray.length - 1 && !loop) { setCurrentTextIndex(0); } else { setCurrentTextIndex((prev) => (prev + 1) % textArray.length); } } } }, [ displayText, isActive, isTyping, isDeleting, currentFullText, multiline, // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: adding deps would cause animation loops textArray, currentTextIndex, speed, delay, loop, onTypeEnd, onTypeLoop, onCharType, pauseAt, playClick, withSound ]); const outputText = multiline ? [...completedLines, displayText] : displayText; return { text: outputText, isTyping: isTyping && isActive, start, stop, reset }; } exports.useTypewriter = useTypewriter; //# sourceMappingURL=use-typewriter.cjs.map