@gfazioli/mantine-text-animate
Version:
The TextAnimate component allows you to animate text with various effects.
231 lines (228 loc) • 7.94 kB
JavaScript
'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