UNPKG

@gfazioli/mantine-text-animate

Version:

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

231 lines (228 loc) 7.94 kB
'use client'; import { createVarsResolver, getSize, polymorphicFactory, useProps, useStyles, Box, Text } from '@mantine/core'; import { useMergedRef } from '@mantine/hooks'; import React, { useRef, useState, useEffect, useCallback } from 'react'; import { Gradient } from './Gradient/Gradient.mjs'; import { Highlight } from './Highlight/Highlight.mjs'; import { Morphing } from './Morphing/Morphing.mjs'; import { NumberTicker } from './NumberTicker/NumberTicker.mjs'; import { RotatingText } from './RotatingText/RotatingText.mjs'; import { Spinner } from './Spinner/Spinner.mjs'; import { SplitFlap } from './SplitFlap/SplitFlap.mjs'; import { TextTicker } from './TextTicker/TextTicker.mjs'; import { Typewriter } from './Typewriter/Typewriter.mjs'; import classes from './TextAnimate.module.css.mjs'; const defaultProps = { delay: 0, duration: 0.3, segmentDelay: 0.05, by: "word", animation: "fade", animateProps: { translateDistance: "20", scaleAmount: 2, blurAmount: "10" } }; const defaultStaggerTimings = { text: 0.06, word: 0.05, character: 0.03, line: 0.06 }; const containerStyles = { whiteSpace: "pre-wrap", position: "relative", display: "block", minHeight: "1em" }; const varsResolver = createVarsResolver((_, { animateProps }) => ({ root: { "--text-animate-translation-distance": animateProps?.translateDistance ? getSize(animateProps.translateDistance, "translate-distance") : "20px", "--text-animate-blur-amount": animateProps?.blurAmount ? getSize(animateProps.blurAmount, "blur-amount") : "10px", "--text-animate-scale-amount": animateProps?.scaleAmount ? animateProps.scaleAmount.toString() : "0.8" } })); const TextAnimate = polymorphicFactory((_props) => { const { ref, ...restProps } = _props; const props = useProps("TextAnimate", defaultProps, restProps); const { delay, duration, segmentClassName, animate, by, animation, segmentDelay, animateProps, onAnimationStart, onAnimationEnd, onAnimationComplete, trigger, triggerOptions, loopDelay, classNames, style, styles, unstyled, vars, children, className, ...others } = props; const staggerTiming = segmentDelay !== void 0 ? segmentDelay : defaultStaggerTimings[by ?? "character"]; const completedCountRef = useRef(0); const [isAnimating, setIsAnimating] = useState(false); const [loopPhase, setLoopPhase] = useState("in"); const loopTimerRef = useRef(null); useEffect(() => { return () => { if (loopTimerRef.current) { clearTimeout(loopTimerRef.current); } }; }, []); const inViewRef = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { if (trigger !== "inView" || !inViewRef.current || typeof IntersectionObserver === "undefined") { return; } const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } }, { threshold: triggerOptions?.threshold ?? 0.1, rootMargin: triggerOptions?.rootMargin ?? "0px" } ); observer.observe(inViewRef.current); return () => observer.disconnect(); }, [trigger, triggerOptions?.threshold, triggerOptions?.rootMargin]); const mergedRef = useMergedRef(ref, inViewRef); let effectiveAnimate = animate; if (animate === "loop") { effectiveAnimate = loopPhase; } if (trigger === "inView") { effectiveAnimate = inView ? animate === "loop" ? loopPhase : animate || "in" : void 0; } const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const prevEffectiveRef = useRef(effectiveAnimate); useEffect(() => { if (effectiveAnimate !== prevEffectiveRef.current) { completedCountRef.current = 0; prevEffectiveRef.current = effectiveAnimate; if (prefersReducedMotion) { setIsAnimating(false); onAnimationComplete?.(effectiveAnimate); if (animate === "loop") { const delayMs = loopDelay ?? 2e3; if (loopTimerRef.current) { clearTimeout(loopTimerRef.current); } loopTimerRef.current = setTimeout(() => { setLoopPhase((prev) => prev === "in" ? "out" : "in"); }, delayMs); } } else { setIsAnimating(true); } } }, [effectiveAnimate, prefersReducedMotion, onAnimationComplete, animate, loopDelay]); const getStyles = useStyles({ name: "TextAnimate", props, classes, className, style, classNames, styles, unstyled, vars, varsResolver }); let segments = []; switch (by) { case "word": segments = children.split(/(\s+)/); break; case "character": segments = children.split(""); break; case "line": segments = children.split("\n"); break; case "text": default: segments = [children]; break; } const handleOnAnimationStart = useCallback(() => { setIsAnimating(true); onAnimationStart?.(effectiveAnimate); }, [onAnimationStart, effectiveAnimate]); const handleOnAnimationEnd = useCallback(() => { onAnimationEnd?.(effectiveAnimate); completedCountRef.current += 1; if (completedCountRef.current === segments.length) { setIsAnimating(false); onAnimationComplete?.(effectiveAnimate); if (animate === "loop") { const delayMs = loopDelay ?? 2e3; if (loopTimerRef.current) { clearTimeout(loopTimerRef.current); } loopTimerRef.current = setTimeout(() => { setLoopPhase((prev) => prev === "in" ? "out" : "in"); }, delayMs); } } }, [onAnimationEnd, onAnimationComplete, effectiveAnimate, segments.length, animate, loopDelay]); if (effectiveAnimate === "none" || effectiveAnimate === false || effectiveAnimate === void 0) { return /* @__PURE__ */ React.createElement(Box, { ref: mergedRef, ...getStyles("root"), style: containerStyles, "aria-live": "polite" }, /* @__PURE__ */ React.createElement(Text, { component: "span", ...others, style: { visibility: "hidden" } }, children)); } if (effectiveAnimate === "static") { return /* @__PURE__ */ React.createElement(Box, { ref: mergedRef, ...getStyles("root"), style: containerStyles, "aria-live": "polite" }, /* @__PURE__ */ React.createElement(Text, { component: "span", ...others }, children)); } return /* @__PURE__ */ React.createElement(Box, { ref: mergedRef, ...getStyles("root", { style: containerStyles }), "aria-live": "polite" }, segments.map((segment, i) => /* @__PURE__ */ React.createElement( Text, { "data-text-animate": effectiveAnimate, "data-text-animate-animation": animation, "data-animating": isAnimating || void 0, key: `${by}-${effectiveAnimate}-${i}`, ...getStyles("segment", { style: { ...by === "line" ? { display: "block", whiteSpace: "normal" } : {}, animationDelay: `${(delay ?? 0) + i * staggerTiming}s`, animationDuration: `${duration}s`, animationFillMode: "forwards", animationDirection: effectiveAnimate === "in" ? "normal" : "reverse" } }), component: "span", onAnimationStart: handleOnAnimationStart, onAnimationEnd: handleOnAnimationEnd, ...others }, segment ))); }); TextAnimate.classes = classes; TextAnimate.displayName = "TextAnimate"; TextAnimate.Typewriter = Typewriter; TextAnimate.Spinner = Spinner; TextAnimate.NumberTicker = NumberTicker; TextAnimate.TextTicker = TextTicker; TextAnimate.Gradient = Gradient; TextAnimate.Highlight = Highlight; TextAnimate.SplitFlap = SplitFlap; TextAnimate.Morphing = Morphing; TextAnimate.RotatingText = RotatingText; export { TextAnimate }; //# sourceMappingURL=TextAnimate.mjs.map