feat: add various animated text components including AnimatedGradientText, AnimatedShinyText, TypingAnimation, MorphingText, and others to Magicui
This commit is contained in:
parent
1cecd22213
commit
29b225c1eb
37
src/components/magicui/animated-gradient-text.tsx
Normal file
37
src/components/magicui/animated-gradient-text.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export interface AnimatedGradientTextProps
|
||||
extends ComponentPropsWithoutRef<"div"> {
|
||||
speed?: number;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
}
|
||||
|
||||
export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
speed = 1,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
...props
|
||||
}: AnimatedGradientTextProps) {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
"--bg-size": `${speed * 300}%`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
`inline animate-gradient bg-gradient-to-r from-[var(--color-from)] via-[var(--color-to)] to-[var(--color-from)] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
@ -27,7 +27,7 @@ export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
||||
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
|
||||
|
||||
// Shine gradient
|
||||
"bg-linear-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||
|
||||
className,
|
||||
)}
|
||||
|
69
src/components/magicui/box-reveal.tsx
Normal file
69
src/components/magicui/box-reveal.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useAnimation, useInView } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface BoxRevealProps {
|
||||
children: JSX.Element;
|
||||
width?: "fit-content" | "100%";
|
||||
boxColor?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export const BoxReveal = ({
|
||||
children,
|
||||
width = "fit-content",
|
||||
boxColor = "#5046e6",
|
||||
duration,
|
||||
}: BoxRevealProps) => {
|
||||
const mainControls = useAnimation();
|
||||
const slideControls = useAnimation();
|
||||
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
slideControls.start("visible");
|
||||
mainControls.start("visible");
|
||||
} else {
|
||||
slideControls.start("hidden");
|
||||
mainControls.start("hidden");
|
||||
}
|
||||
}, [isInView, mainControls, slideControls]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative", width, overflow: "hidden" }}>
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 75 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate={mainControls}
|
||||
transition={{ duration: duration ? duration : 0.5, delay: 0.25 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { left: 0 },
|
||||
visible: { left: "100%" },
|
||||
}}
|
||||
initial="hidden"
|
||||
animate={slideControls}
|
||||
transition={{ duration: duration ? duration : 0.5, ease: "easeIn" }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 20,
|
||||
background: boxColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
64
src/components/magicui/flip-text.tsx
Normal file
64
src/components/magicui/flip-text.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, Variants, MotionProps } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ElementType } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface FlipTextProps extends MotionProps {
|
||||
/** The duration of the animation */
|
||||
duration?: number;
|
||||
/** The delay between each character */
|
||||
delayMultiple?: number;
|
||||
/** The variants of the animation */
|
||||
framerProps?: Variants;
|
||||
/** The class name of the component */
|
||||
className?: string;
|
||||
/** The element type of the component */
|
||||
as?: ElementType;
|
||||
/** The children of the component */
|
||||
children: React.ReactNode;
|
||||
/** The variants of the animation */
|
||||
variants?: Variants;
|
||||
}
|
||||
|
||||
const defaultVariants: Variants = {
|
||||
hidden: { rotateX: -90, opacity: 0 },
|
||||
visible: { rotateX: 0, opacity: 1 },
|
||||
};
|
||||
|
||||
export function FlipText({
|
||||
children,
|
||||
duration = 0.5,
|
||||
delayMultiple = 0.08,
|
||||
|
||||
className,
|
||||
as: Component = "span",
|
||||
variants,
|
||||
...props
|
||||
}: FlipTextProps) {
|
||||
const MotionComponent = motion.create(Component);
|
||||
const characters = React.Children.toArray(children).join("").split("");
|
||||
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<AnimatePresence mode="wait">
|
||||
{characters.map((char, i) => (
|
||||
<MotionComponent
|
||||
key={i}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
variants={variants || defaultVariants}
|
||||
transition={{ duration, delay: i * delayMultiple }}
|
||||
className={cn("origin-center drop-shadow-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
</MotionComponent>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
146
src/components/magicui/hyper-text.tsx
Normal file
146
src/components/magicui/hyper-text.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnimatePresence, motion, MotionProps } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type CharacterSet = string[] | readonly string[];
|
||||
|
||||
interface HyperTextProps extends MotionProps {
|
||||
/** The text content to be animated */
|
||||
children: string;
|
||||
/** Optional className for styling */
|
||||
className?: string;
|
||||
/** Duration of the animation in milliseconds */
|
||||
duration?: number;
|
||||
/** Delay before animation starts in milliseconds */
|
||||
delay?: number;
|
||||
/** Component to render as - defaults to div */
|
||||
as?: React.ElementType;
|
||||
/** Whether to start animation when element comes into view */
|
||||
startOnView?: boolean;
|
||||
/** Whether to trigger animation on hover */
|
||||
animateOnHover?: boolean;
|
||||
/** Custom character set for scramble effect. Defaults to uppercase alphabet */
|
||||
characterSet?: CharacterSet;
|
||||
}
|
||||
|
||||
const DEFAULT_CHARACTER_SET = Object.freeze(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""),
|
||||
) as readonly string[];
|
||||
|
||||
const getRandomInt = (max: number): number => Math.floor(Math.random() * max);
|
||||
|
||||
export function HyperText({
|
||||
children,
|
||||
className,
|
||||
duration = 800,
|
||||
delay = 0,
|
||||
as: Component = "div",
|
||||
startOnView = false,
|
||||
animateOnHover = true,
|
||||
characterSet = DEFAULT_CHARACTER_SET,
|
||||
...props
|
||||
}: HyperTextProps) {
|
||||
const MotionComponent = motion.create(Component, {
|
||||
forwardMotionProps: true,
|
||||
});
|
||||
|
||||
const [displayText, setDisplayText] = useState<string[]>(() =>
|
||||
children.split(""),
|
||||
);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const iterationCount = useRef(0);
|
||||
const elementRef = useRef<HTMLElement>(null);
|
||||
|
||||
const handleAnimationTrigger = () => {
|
||||
if (animateOnHover && !isAnimating) {
|
||||
iterationCount.current = 0;
|
||||
setIsAnimating(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle animation start based on view or delay
|
||||
useEffect(() => {
|
||||
if (!startOnView) {
|
||||
const startTimeout = setTimeout(() => {
|
||||
setIsAnimating(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(startTimeout);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
setIsAnimating(true);
|
||||
}, delay);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "-30% 0px -30% 0px" },
|
||||
);
|
||||
|
||||
if (elementRef.current) {
|
||||
observer.observe(elementRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [delay, startOnView]);
|
||||
|
||||
// Handle scramble animation
|
||||
useEffect(() => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
const maxIterations = children.length;
|
||||
const startTime = performance.now();
|
||||
let animationFrameId: number;
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
iterationCount.current = progress * maxIterations;
|
||||
|
||||
setDisplayText((currentText) =>
|
||||
currentText.map((letter, index) =>
|
||||
letter === " "
|
||||
? letter
|
||||
: index <= iterationCount.current
|
||||
? children[index]
|
||||
: characterSet[getRandomInt(characterSet.length)],
|
||||
),
|
||||
);
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [children, duration, isAnimating, characterSet]);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn("overflow-hidden py-2 text-4xl font-bold", className)}
|
||||
onMouseEnter={handleAnimationTrigger}
|
||||
{...props}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{displayText.map((letter, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
className={cn("font-mono", letter === " " ? "w-3" : "")}
|
||||
>
|
||||
{letter.toUpperCase()}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</MotionComponent>
|
||||
);
|
||||
}
|
149
src/components/magicui/morphing-text.tsx
Normal file
149
src/components/magicui/morphing-text.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const morphTime = 1.5;
|
||||
const cooldownTime = 0.5;
|
||||
|
||||
const useMorphingText = (texts: string[]) => {
|
||||
const textIndexRef = useRef(0);
|
||||
const morphRef = useRef(0);
|
||||
const cooldownRef = useRef(0);
|
||||
const timeRef = useRef(new Date());
|
||||
|
||||
const text1Ref = useRef<HTMLSpanElement>(null);
|
||||
const text2Ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const setStyles = useCallback(
|
||||
(fraction: number) => {
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current];
|
||||
if (!current1 || !current2) return;
|
||||
|
||||
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
|
||||
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;
|
||||
|
||||
const invertedFraction = 1 - fraction;
|
||||
current1.style.filter = `blur(${Math.min(
|
||||
8 / invertedFraction - 8,
|
||||
100,
|
||||
)}px)`;
|
||||
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`;
|
||||
|
||||
current1.textContent = texts[textIndexRef.current % texts.length];
|
||||
current2.textContent = texts[(textIndexRef.current + 1) % texts.length];
|
||||
},
|
||||
[texts],
|
||||
);
|
||||
|
||||
const doMorph = useCallback(() => {
|
||||
morphRef.current -= cooldownRef.current;
|
||||
cooldownRef.current = 0;
|
||||
|
||||
let fraction = morphRef.current / morphTime;
|
||||
|
||||
if (fraction > 1) {
|
||||
cooldownRef.current = cooldownTime;
|
||||
fraction = 1;
|
||||
}
|
||||
|
||||
setStyles(fraction);
|
||||
|
||||
if (fraction === 1) {
|
||||
textIndexRef.current++;
|
||||
}
|
||||
}, [setStyles]);
|
||||
|
||||
const doCooldown = useCallback(() => {
|
||||
morphRef.current = 0;
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current];
|
||||
if (current1 && current2) {
|
||||
current2.style.filter = "none";
|
||||
current2.style.opacity = "100%";
|
||||
current1.style.filter = "none";
|
||||
current1.style.opacity = "0%";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number;
|
||||
|
||||
const animate = () => {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
|
||||
const newTime = new Date();
|
||||
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000;
|
||||
timeRef.current = newTime;
|
||||
|
||||
cooldownRef.current -= dt;
|
||||
|
||||
if (cooldownRef.current <= 0) doMorph();
|
||||
else doCooldown();
|
||||
};
|
||||
|
||||
animate();
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [doMorph, doCooldown]);
|
||||
|
||||
return { text1Ref, text2Ref };
|
||||
};
|
||||
|
||||
interface MorphingTextProps {
|
||||
className?: string;
|
||||
texts: string[];
|
||||
}
|
||||
|
||||
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
|
||||
const { text1Ref, text2Ref } = useMorphingText(texts);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
|
||||
ref={text1Ref}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
|
||||
ref={text2Ref}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SvgFilters: React.FC = () => (
|
||||
<svg
|
||||
id="filters"
|
||||
className="fixed h-0 w-0"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<defs>
|
||||
<filter id="threshold">
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 255 -140"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MorphingText: React.FC<MorphingTextProps> = ({
|
||||
texts,
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto h-16 w-full max-w-screen-md text-center font-sans text-[40pt] font-bold leading-none [filter:url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Texts texts={texts} />
|
||||
<SvgFilters />
|
||||
</div>
|
||||
);
|
67
src/components/magicui/number-ticker.tsx
Normal file
67
src/components/magicui/number-ticker.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import { ComponentPropsWithoutRef, useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
|
||||
value: number;
|
||||
startValue?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
decimalPlaces?: number;
|
||||
}
|
||||
|
||||
export function NumberTicker({
|
||||
value,
|
||||
startValue = 0,
|
||||
direction = "up",
|
||||
delay = 0,
|
||||
className,
|
||||
decimalPlaces = 0,
|
||||
...props
|
||||
}: NumberTickerProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? value : startValue);
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping: 60,
|
||||
stiffness: 100,
|
||||
});
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
const timer = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? startValue : value);
|
||||
}, delay * 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [motionValue, isInView, delay, value, direction, startValue]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
springValue.on("change", (latest) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(Number(latest.toFixed(decimalPlaces)));
|
||||
}
|
||||
}),
|
||||
[springValue, decimalPlaces],
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block tabular-nums tracking-wider text-black dark:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{startValue}
|
||||
</span>
|
||||
);
|
||||
}
|
128
src/components/magicui/scroll-based-velocity.tsx
Normal file
128
src/components/magicui/scroll-based-velocity.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useMotionValue,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
useVelocity,
|
||||
} from "motion/react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VelocityScrollProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultVelocity?: number;
|
||||
className?: string;
|
||||
numRows?: number;
|
||||
}
|
||||
|
||||
interface ParallaxProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
baseVelocity: number;
|
||||
}
|
||||
|
||||
export const wrap = (min: number, max: number, v: number) => {
|
||||
const rangeSize = max - min;
|
||||
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
|
||||
};
|
||||
|
||||
function ParallaxText({
|
||||
children,
|
||||
baseVelocity = 100,
|
||||
...props
|
||||
}: ParallaxProps) {
|
||||
const baseX = useMotionValue(0);
|
||||
const { scrollY } = useScroll();
|
||||
const scrollVelocity = useVelocity(scrollY);
|
||||
const smoothVelocity = useSpring(scrollVelocity, {
|
||||
damping: 50,
|
||||
stiffness: 400,
|
||||
});
|
||||
|
||||
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
|
||||
clamp: false,
|
||||
});
|
||||
|
||||
const [repetitions, setRepetitions] = useState(1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const calculateRepetitions = () => {
|
||||
if (containerRef.current && textRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const textWidth = textRef.current.offsetWidth;
|
||||
const newRepetitions = Math.ceil(containerWidth / textWidth) + 2;
|
||||
setRepetitions(newRepetitions);
|
||||
}
|
||||
};
|
||||
|
||||
calculateRepetitions();
|
||||
|
||||
window.addEventListener("resize", calculateRepetitions);
|
||||
return () => window.removeEventListener("resize", calculateRepetitions);
|
||||
}, [children]);
|
||||
|
||||
const x = useTransform(baseX, (v) => `${wrap(-100 / repetitions, 0, v)}%`);
|
||||
|
||||
const directionFactor = React.useRef<number>(1);
|
||||
useAnimationFrame((t, delta) => {
|
||||
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
|
||||
|
||||
if (velocityFactor.get() < 0) {
|
||||
directionFactor.current = -1;
|
||||
} else if (velocityFactor.get() > 0) {
|
||||
directionFactor.current = 1;
|
||||
}
|
||||
|
||||
moveBy += directionFactor.current * moveBy * velocityFactor.get();
|
||||
|
||||
baseX.set(baseX.get() + moveBy);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full overflow-hidden whitespace-nowrap"
|
||||
{...props}
|
||||
>
|
||||
<motion.div className="inline-block" style={{ x }}>
|
||||
{Array.from({ length: repetitions }).map((_, i) => (
|
||||
<span key={i} ref={i === 0 ? textRef : null}>
|
||||
{children}{" "}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VelocityScroll({
|
||||
defaultVelocity = 5,
|
||||
numRows = 2,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: VelocityScrollProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full text-4xl font-bold tracking-[-0.02em] md:text-7xl md:leading-[5rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: numRows }).map((_, i) => (
|
||||
<ParallaxText
|
||||
key={i}
|
||||
baseVelocity={defaultVelocity * (i % 2 === 0 ? 1 : -1)}
|
||||
>
|
||||
{children}
|
||||
</ParallaxText>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
150
src/components/magicui/sparkles-text.tsx
Normal file
150
src/components/magicui/sparkles-text.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { CSSProperties, ReactElement, useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Sparkle {
|
||||
id: string;
|
||||
x: string;
|
||||
y: string;
|
||||
color: string;
|
||||
delay: number;
|
||||
scale: number;
|
||||
lifespan: number;
|
||||
}
|
||||
|
||||
const Sparkle: React.FC<Sparkle> = ({ id, x, y, color, delay, scale }) => {
|
||||
return (
|
||||
<motion.svg
|
||||
key={id}
|
||||
className="pointer-events-none absolute z-20"
|
||||
initial={{ opacity: 0, left: x, top: y }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, scale, 0],
|
||||
rotate: [75, 120, 150],
|
||||
}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, delay }}
|
||||
width="21"
|
||||
height="21"
|
||||
viewBox="0 0 21 21"
|
||||
>
|
||||
<path
|
||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
||||
fill={color}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface SparklesTextProps {
|
||||
/**
|
||||
* @default <div />
|
||||
* @type ReactElement
|
||||
* @description
|
||||
* The component to be rendered as the text
|
||||
* */
|
||||
as?: ReactElement;
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the text
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* @required
|
||||
* @type ReactNode
|
||||
* @description
|
||||
* The content to be displayed
|
||||
* */
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* @default 10
|
||||
* @type number
|
||||
* @description
|
||||
* The count of sparkles
|
||||
* */
|
||||
sparklesCount?: number;
|
||||
|
||||
/**
|
||||
* @default "{first: '#9E7AFF', second: '#FE8BBB'}"
|
||||
* @type string
|
||||
* @description
|
||||
* The colors of the sparkles
|
||||
* */
|
||||
colors?: {
|
||||
first: string;
|
||||
second: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const SparklesText: React.FC<SparklesTextProps> = ({
|
||||
children,
|
||||
colors = { first: "#9E7AFF", second: "#FE8BBB" },
|
||||
className,
|
||||
sparklesCount = 10,
|
||||
...props
|
||||
}) => {
|
||||
const [sparkles, setSparkles] = useState<Sparkle[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateStar = (): Sparkle => {
|
||||
const starX = `${Math.random() * 100}%`;
|
||||
const starY = `${Math.random() * 100}%`;
|
||||
const color = Math.random() > 0.5 ? colors.first : colors.second;
|
||||
const delay = Math.random() * 2;
|
||||
const scale = Math.random() * 1 + 0.3;
|
||||
const lifespan = Math.random() * 10 + 5;
|
||||
const id = `${starX}-${starY}-${Date.now()}`;
|
||||
return { id, x: starX, y: starY, color, delay, scale, lifespan };
|
||||
};
|
||||
|
||||
const initializeStars = () => {
|
||||
const newSparkles = Array.from({ length: sparklesCount }, generateStar);
|
||||
setSparkles(newSparkles);
|
||||
};
|
||||
|
||||
const updateStars = () => {
|
||||
setSparkles((currentSparkles) =>
|
||||
currentSparkles.map((star) => {
|
||||
if (star.lifespan <= 0) {
|
||||
return generateStar();
|
||||
} else {
|
||||
return { ...star, lifespan: star.lifespan - 0.1 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
initializeStars();
|
||||
const interval = setInterval(updateStars, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [colors.first, colors.second, sparklesCount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("text-6xl font-bold", className)}
|
||||
{...props}
|
||||
style={
|
||||
{
|
||||
"--sparkles-first-color": `${colors.first}`,
|
||||
"--sparkles-second-color": `${colors.second}`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="relative inline-block">
|
||||
{sparkles.map((sparkle) => (
|
||||
<Sparkle key={sparkle.id} {...sparkle} />
|
||||
))}
|
||||
<strong>{children}</strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
90
src/components/magicui/typing-animation.tsx
Normal file
90
src/components/magicui/typing-animation.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion, MotionProps } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface TypingAnimationProps extends MotionProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
as?: React.ElementType;
|
||||
startOnView?: boolean;
|
||||
}
|
||||
|
||||
export function TypingAnimation({
|
||||
children,
|
||||
className,
|
||||
duration = 100,
|
||||
delay = 0,
|
||||
as: Component = "div",
|
||||
startOnView = false,
|
||||
...props
|
||||
}: TypingAnimationProps) {
|
||||
const MotionComponent = motion.create(Component, {
|
||||
forwardMotionProps: true,
|
||||
});
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("");
|
||||
const [started, setStarted] = useState(false);
|
||||
const elementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startOnView) {
|
||||
const startTimeout = setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(startTimeout);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
setStarted(true);
|
||||
}, delay);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
if (elementRef.current) {
|
||||
observer.observe(elementRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [delay, startOnView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return;
|
||||
|
||||
let i = 0;
|
||||
const typingEffect = setInterval(() => {
|
||||
if (i < children.length) {
|
||||
setDisplayedText(children.substring(0, i + 1));
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(typingEffect);
|
||||
}
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
clearInterval(typingEffect);
|
||||
};
|
||||
}, [children, duration, started]);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"text-4xl font-bold leading-[5rem] tracking-[-0.02em]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
</MotionComponent>
|
||||
);
|
||||
}
|
50
src/components/magicui/word-rotate.tsx
Normal file
50
src/components/magicui/word-rotate.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, MotionProps } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WordRotateProps {
|
||||
words: string[];
|
||||
duration?: number;
|
||||
motionProps?: MotionProps;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WordRotate({
|
||||
words,
|
||||
duration = 2500,
|
||||
motionProps = {
|
||||
initial: { opacity: 0, y: -50 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: 50 },
|
||||
transition: { duration: 0.25, ease: "easeOut" },
|
||||
},
|
||||
className,
|
||||
}: WordRotateProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prevIndex) => (prevIndex + 1) % words.length);
|
||||
}, duration);
|
||||
|
||||
// Clean up interval on unmount
|
||||
return () => clearInterval(interval);
|
||||
}, [words, duration]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden py-2">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.h1
|
||||
key={words[index]}
|
||||
className={cn(className)}
|
||||
{...motionProps}
|
||||
>
|
||||
{words[index]}
|
||||
</motion.h1>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -73,6 +73,7 @@
|
||||
infinite;
|
||||
--animate-pulse: pulse var(--duration) ease-out infinite;
|
||||
--animate-meteor: meteor 5s linear infinite;
|
||||
--animate-gradient: gradient 8s linear infinite;
|
||||
|
||||
@keyframes shiny-text {
|
||||
0%,
|
||||
@ -164,6 +165,12 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
to {
|
||||
background-position: var(--bg-size, 300%) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
Loading…
Reference in New Issue
Block a user