@gfazioli/mantine-text-animate
Version:
The TextAnimate component allows you to animate text with various effects.
239 lines (235 loc) • 7.32 kB
JavaScript
'use client';
;
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