Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/motion-primitives/text-effect.tsx
T

251 lines
6.8 KiB
TypeScript

"use client";
import { cn } from "~/lib/utils";
import { AnimatePresence, motion, TargetAndTransition, Transition, Variant, Variants } from "motion/react";
import React from "react";
export type PresetType = "blur-xs" | "fade-in-blur" | "scale" | "fade" | "slide";
export type PerType = "word" | "char" | "line";
export type TextEffectProps = {
children: string;
per?: PerType;
as?: keyof React.JSX.IntrinsicElements;
variants?: {
container?: Variants;
item?: Variants;
};
className?: string;
preset?: PresetType;
delay?: number;
speedReveal?: number;
speedSegment?: number;
trigger?: boolean;
onAnimationComplete?: () => void;
onAnimationStart?: () => void;
segmentWrapperClassName?: string;
containerTransition?: Transition;
segmentTransition?: Transition;
style?: React.CSSProperties;
};
const defaultStaggerTimes: Record<PerType, number> = {
char: 0.03,
word: 0.05,
line: 0.1,
};
const defaultContainerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
exit: {
transition: { staggerChildren: 0.05, staggerDirection: -1 },
},
};
const defaultItemVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
},
exit: { opacity: 0 },
};
const presetVariants: Record<PresetType, { container: Variants; item: Variants }> = {
"blur-xs": {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, filter: "blur(12px)" },
visible: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(12px)" },
},
},
"fade-in-blur": {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20, filter: "blur(12px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: 20, filter: "blur(12px)" },
},
},
scale: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, scale: 0 },
visible: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0 },
},
},
fade: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
},
},
slide: {
container: defaultContainerVariants,
item: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
},
},
};
const AnimationComponent: React.FC<{
segment: string;
variants: Variants;
per: "line" | "word" | "char";
segmentWrapperClassName?: string;
}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => {
const content =
per === "line" ? (
<motion.span variants={variants} className="block">
{segment}
</motion.span>
) : per === "word" ? (
<motion.span aria-hidden="true" variants={variants} className="inline-block whitespace-pre">
{segment}
</motion.span>
) : (
<motion.span className="inline-block whitespace-pre">
{segment.split("").map((char, charIndex) => (
<motion.span key={`char-${charIndex}`} aria-hidden="true" variants={variants} className="inline-block whitespace-pre">
{char}
</motion.span>
))}
</motion.span>
);
if (!segmentWrapperClassName) {
return content;
}
const defaultWrapperClassName = per === "line" ? "block" : "inline-block";
return <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}>{content}</span>;
});
AnimationComponent.displayName = "AnimationComponent";
const splitText = (text: string, per: "line" | "word" | "char") => {
if (per === "line") return text.split("\n");
return text.split(/(\s+)/);
};
const hasTransition = (variant: Variant): variant is TargetAndTransition & { transition?: Transition } => {
return typeof variant === "object" && variant !== null && "transition" in variant;
};
const createVariantsWithTransition = (baseVariants: Variants, transition?: Transition & { exit?: Transition }): Variants => {
if (!transition) return baseVariants;
const { ...mainTransition } = transition;
return {
...baseVariants,
visible: {
...baseVariants.visible,
transition: {
...(hasTransition(baseVariants.visible) ? baseVariants.visible.transition : {}),
...mainTransition,
},
},
exit: {
...baseVariants.exit,
transition: {
...(hasTransition(baseVariants.exit) ? baseVariants.exit.transition : {}),
...mainTransition,
staggerDirection: -1,
},
},
};
};
export function TextEffect({
children,
per = "word",
as = "p",
variants,
className,
preset = "fade",
delay = 0,
speedReveal = 1,
speedSegment = 1,
trigger = true,
onAnimationComplete,
onAnimationStart,
segmentWrapperClassName,
containerTransition,
segmentTransition,
style,
}: TextEffectProps) {
const segments = splitText(children, per);
const MotionTag = motion[as as keyof typeof motion] as typeof motion.div;
const baseVariants = preset ? presetVariants[preset] : { container: defaultContainerVariants, item: defaultItemVariants };
const stagger = defaultStaggerTimes[per] / speedReveal;
const baseDuration = 0.3 / speedSegment;
const customStagger = hasTransition(variants?.container?.visible ?? {})
? (variants?.container?.visible as TargetAndTransition).transition?.staggerChildren
: undefined;
const customDelay = hasTransition(variants?.container?.visible ?? {})
? (variants?.container?.visible as TargetAndTransition).transition?.delayChildren
: undefined;
const computedVariants = {
container: createVariantsWithTransition(variants?.container || baseVariants.container, {
staggerChildren: customStagger ?? stagger,
delayChildren: customDelay ?? delay,
...containerTransition,
exit: {
staggerChildren: customStagger ?? stagger,
staggerDirection: -1,
},
}),
item: createVariantsWithTransition(variants?.item || baseVariants.item, {
duration: baseDuration,
...segmentTransition,
}),
};
return (
<AnimatePresence mode="popLayout">
{trigger && (
<MotionTag
initial="hidden"
animate="visible"
exit="exit"
variants={computedVariants.container}
className={className}
onAnimationComplete={onAnimationComplete}
onAnimationStart={onAnimationStart}
style={style}
>
{per !== "line" ? <span className="sr-only">{children}</span> : null}
{segments.map((segment, index) => (
<AnimationComponent
key={`${per}-${index}-${segment}`}
segment={segment}
variants={computedVariants.item}
per={per}
segmentWrapperClassName={segmentWrapperClassName}
/>
))}
</MotionTag>
)}
</AnimatePresence>
);
}