Initial commit

This commit is contained in:
vitalijmulika
2026-02-09 19:05:46 +02:00
commit b37a4ddd3b
656 changed files with 77339 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
"use client";
import { useRef, useEffect, memo } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { cls } from "@/lib/utils";
gsap.registerPlugin(ScrollTrigger, SplitText);
interface ScrollTriggerConfig {
trigger: HTMLElement;
start: string;
end: string;
markers: boolean;
toggleActions: string;
}
type AnimationType = "entrance-slide" | "reveal-blur" | "background-highlight";
type VariantType = "trigger" | "words-trigger";
interface TextAnimationProps {
type?: AnimationType;
text: string;
children?: React.ReactNode;
className?: string;
duration?: number;
stagger?: number;
start?: string;
end?: string;
variant?: VariantType;
ariaLabel?: string;
as?: "div" | "span" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
gradientColors?: {
from: string;
to: string;
};
}
const ANIMATION_CONFIG = {
trigger: {
stagger: 0.0075,
useDuration: false,
duration: 0.6,
},
"words-trigger": {
stagger: 0.03,
useDuration: false,
duration: 0.6,
},
} as const;
const ANIMATION_TYPES = {
"entrance-slide": {
classPrefix: "slide",
fromVars: {
opacity: 0,
yPercent: 50,
},
toVars: {
opacity: 1,
yPercent: 0,
ease: "power1",
},
},
"reveal-blur": {
classPrefix: "blur",
fromVars: {
autoAlpha: 0,
filter: "blur(10px)",
},
toVars: {
autoAlpha: 1,
filter: "blur(0px)",
ease: "power1.inOut",
},
},
"background-highlight": {
classPrefix: "highlight",
fromVars: {
opacity: 0.25,
},
toVars: {
opacity: 1,
ease: "power2.inOut",
},
},
} as const;
const TextAnimation = ({
type = "entrance-slide",
text,
children,
className = "",
duration = 1,
stagger,
start = "top 80%",
end = "top 20%",
variant = "trigger",
ariaLabel,
as = "div",
gradientColors,
}: TextAnimationProps) => {
const textRef = useRef<HTMLDivElement | HTMLSpanElement>(null);
const splitRef = useRef<SplitText | null>(null);
const scrollTriggerRef = useRef<ScrollTrigger | null>(null);
useEffect(() => {
if (!textRef.current) return;
const ctx = gsap.context(() => {
const isWords = variant === "words-trigger";
const animConfig = ANIMATION_TYPES[type];
const classPrefix = animConfig.classPrefix;
splitRef.current = new SplitText(textRef.current!, {
type: isWords ? "lines,words" : "lines,words,chars",
linesClass: `${classPrefix}-line`,
wordsClass: `${classPrefix}-word`,
charsClass: isWords ? undefined : `${classPrefix}-char`,
});
const lines = splitRef.current.lines;
if (type === "entrance-slide") {
gsap.set(lines, {
overflow: "hidden",
display: as === "span" ? "inline" : undefined,
});
} else if (as === "span") {
gsap.set(lines, {
display: "inline",
});
}
const words = splitRef.current.words;
gsap.set(words, {
display: "inline-block",
whiteSpace: "nowrap",
});
const animateTarget = isWords
? splitRef.current.words
: splitRef.current.chars;
const config = ANIMATION_CONFIG[variant];
const animationDuration = config.useDuration
? duration
: config.duration!;
const animationStagger = stagger ?? config.stagger;
if (gradientColors) {
animateTarget.forEach((element) => {
gsap.set(element as HTMLElement, {
backgroundImage: `linear-gradient(180deg, ${gradientColors.from} 0%, ${gradientColors.to} 100%)`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
display: "inline-block",
});
});
}
const scrollTriggerConfig: ScrollTriggerConfig = {
trigger: textRef.current!,
start: start,
end: end,
markers: false,
toggleActions: "play none none none",
};
if (type === "reveal-blur") {
const tl = gsap.timeline({
scrollTrigger: scrollTriggerConfig,
});
const parentElement = textRef.current;
const splitInstance = splitRef.current;
tl.fromTo(
animateTarget,
animConfig.fromVars,
{
...animConfig.toVars,
duration: animationDuration,
stagger: animationStagger,
force3D: true,
onStart: function () {
if (this._targets && this._targets.length > 0) {
this._targets.forEach((target: HTMLElement) => {
target.style.willChange = "filter, opacity, transform";
});
}
},
onComplete: function () {
if (this._targets && this._targets.length > 0) {
this._targets.forEach((target: HTMLElement) => {
target.style.willChange = "auto";
});
}
if (parentElement && splitInstance) {
gsap.set(parentElement, {
opacity: 1,
visibility: 'visible',
filter: 'blur(0px)',
transform: 'none'
});
splitInstance.revert();
splitRef.current = null;
}
},
}
);
scrollTriggerRef.current = tl.scrollTrigger as ScrollTrigger;
} else {
const parentElement = textRef.current;
const splitInstance = splitRef.current;
gsap.fromTo(
animateTarget,
animConfig.fromVars,
{
...animConfig.toVars,
duration: animationDuration,
stagger: animationStagger,
force3D: true,
scrollTrigger: scrollTriggerConfig,
onComplete: () => {
if (parentElement && splitInstance) {
gsap.set(parentElement, {
opacity: 1,
transform: 'none'
});
splitInstance.revert();
splitRef.current = null;
}
},
}
);
}
}, textRef);
const currentTextRef = textRef.current;
return () => {
if (
type === "reveal-blur" &&
scrollTriggerRef.current &&
scrollTriggerRef.current.trigger === currentTextRef
) {
scrollTriggerRef.current.kill();
}
ctx.revert();
if (splitRef.current) {
splitRef.current.revert();
}
};
}, [text, type, duration, stagger, start, end, variant, as, gradientColors]);
const animConfig = ANIMATION_TYPES[type];
const Component = as;
return (
<Component
// @ts-expect-error - Dynamic component type requires flexible ref
ref={textRef}
className={cls(
`${animConfig.classPrefix}-text`,
className
)}
style={{
fontKerning: 'none',
textRendering: 'optimizeSpeed',
}}
aria-label={ariaLabel || text || undefined}
>
{children || text?.replace(/-/g, '\u2011')}
</Component>
);
};
TextAnimation.displayName = "TextAnimation";
export default memo(TextAnimation);

View File

@@ -0,0 +1,106 @@
"use client";
import { memo, useState, useEffect, useRef, useCallback } from "react";
import { AnimateNumber } from "motion-number";
interface TextNumberCountProps {
value: number;
format?: Omit<Intl.NumberFormatOptions, "notation"> & {
notation?: Exclude<
Intl.NumberFormatOptions["notation"],
"scientific" | "engineering"
>;
};
locales?: Intl.LocalesArgument;
className?: string;
suffix?: string;
prefix?: string;
animateOnScroll?: boolean;
startFrom?: number;
duration?: number;
threshold?: number;
}
const TextNumberCount = ({
value,
format,
locales = "en-US",
className = "",
suffix,
prefix,
animateOnScroll = false,
startFrom,
duration = 2,
threshold = 0.5,
}: TextNumberCountProps) => {
const initialValue = animateOnScroll ? (startFrom ?? 0) : value;
const [displayValue, setDisplayValue] = useState(initialValue);
const [hasAnimated, setHasAnimated] = useState(false);
const containerRef = useRef<HTMLSpanElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry?.isIntersecting && !hasAnimated) {
setDisplayValue(value);
setHasAnimated(true);
observerRef.current?.disconnect();
}
},
[value, hasAnimated]
);
useEffect(() => {
if (!animateOnScroll || hasAnimated || typeof window === "undefined") {
return;
}
const element = containerRef.current;
if (!element) return;
observerRef.current = new IntersectionObserver(handleIntersection, {
threshold: Math.min(Math.max(threshold, 0), 1),
rootMargin: "0px",
});
observerRef.current.observe(element);
return () => {
observerRef.current?.disconnect();
};
}, [animateOnScroll, hasAnimated, threshold, handleIntersection]);
useEffect(() => {
if (!animateOnScroll) {
setDisplayValue(value);
}
}, [value, animateOnScroll]);
const animateProps = animateOnScroll
? { animate: { duration } }
: {};
const content = (
<AnimateNumber
format={format}
locales={locales}
className={className}
suffix={suffix}
prefix={prefix}
{...animateProps}
>
{displayValue}
</AnimateNumber>
);
if (animateOnScroll) {
return <span ref={containerRef}>{content}</span>;
}
return content;
};
TextNumberCount.displayName = "TextNumberCount";
export default memo(TextNumberCount);

View File

@@ -0,0 +1 @@
export type AnimationType = "entrance-slide" | "reveal-blur" | "background-highlight";