chore: add 2 custom components

This commit is contained in:
javayhu 2025-06-14 19:00:21 +08:00
parent 2ad6eab666
commit 4015cb3143
2 changed files with 375 additions and 0 deletions

View File

@ -0,0 +1,65 @@
'use client';
import {
type HTMLMotionProps,
type Transition,
type UseInViewOptions,
motion,
useInView,
} from 'motion/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
type HighlightTextProps = HTMLMotionProps<'span'> & {
text: string;
inView?: boolean;
inViewMargin?: UseInViewOptions['margin'];
inViewOnce?: boolean;
transition?: Transition;
};
function CustomHighlightText({
ref,
text,
className,
inView = false,
inViewMargin = '0px',
transition = { duration: 2, ease: 'easeInOut' },
...props
}: HighlightTextProps) {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLSpanElement);
const inViewResult = useInView(localRef, {
once: true,
margin: inViewMargin,
});
const isInView = !inView || inViewResult;
return (
<motion.span
ref={localRef}
data-slot="highlight-text"
initial={{
backgroundSize: '0% 100%',
}}
animate={isInView ? { backgroundSize: '100% 100%' } : undefined}
transition={transition}
style={{
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left center',
display: 'inline',
}}
className={cn(
'relative inline-block px-2 py-1 rounded-lg bg-gradient-to-r from-blue-100 to-purple-100 dark:from-blue-500 dark:to-purple-500',
className
)}
{...props}
>
{text}
</motion.span>
);
}
export { CustomHighlightText, type HighlightTextProps };

View File

@ -0,0 +1,310 @@
'use client';
import { cn } from '@/lib/utils';
import {
AnimatePresence,
type TargetAndTransition,
type Transition,
type Variant,
type Variants,
motion,
} from 'motion/react';
import React from 'react';
export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide';
export type PerType = 'word' | 'char' | 'line';
export type TextEffectProps = {
children: string | React.ReactNode[];
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: {
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 | React.ReactNode;
variants: Variants;
per: 'line' | 'word' | 'char';
segmentWrapperClassName?: string;
children?: React.ReactNode;
}> = React.memo(
({ segment, variants, per, segmentWrapperClassName, children }) => {
const content =
per === 'line' ? (
<motion.span variants={variants} className="block">
{children ?? segment}
</motion.span>
) : per === 'word' ? (
<motion.span
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
{children ?? segment}
</motion.span>
) : (
<motion.span className="inline-block whitespace-pre">
{typeof (children ?? segment) === 'string'
? ((children ?? segment) as string)
.split('')
.map((char: string, charIndex: number) => (
<motion.span
key={`char-${charIndex}`}
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
{char}
</motion.span>
))
: React.isValidElement(children ?? segment)
? (children ?? segment)
: null}
</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 | React.ReactNode[],
per: 'line' | 'word' | 'char'
) => {
if (Array.isArray(text)) {
return text;
}
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 CustomTextEffect({
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' && typeof children === 'string' ? (
<span className="sr-only">{children}</span>
) : null}
{segments.map((segment, index) => (
<AnimationComponent
key={index}
segment={segment}
variants={computedVariants.item}
per={per}
segmentWrapperClassName={segmentWrapperClassName}
// biome-ignore lint/correctness/noChildrenProp: <explanation>
children={typeof segment !== 'string' ? segment : undefined}
/>
))}
</MotionTag>
)}
</AnimatePresence>
);
}