chore: add 2 custom components
This commit is contained in:
parent
2ad6eab666
commit
4015cb3143
65
src/components/custom/highlight.tsx
Normal file
65
src/components/custom/highlight.tsx
Normal 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 };
|
310
src/components/custom/text-effect.tsx
Normal file
310
src/components/custom/text-effect.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user