UNPKG

@tuel/text-effects

Version:

Advanced text animation effects for React. Typewriter effects, text reveals, character animations, and dynamic typography components.

568 lines (565 loc) 18.8 kB
// src/components/AnimatedText.tsx import { cn } from "@tuel/utils"; import { motion, useInView } from "framer-motion"; import gsap from "gsap"; import { useEffect, useRef } from "react"; import { jsx } from "react/jsx-runtime"; function AnimatedText({ children, className, variant = "fade", splitType = "chars", staggerDelay = 0.03, duration = 0.5, triggerOnScroll = true, delay = 0, as: Component = "div" }) { const textRef = useRef(null); const splitRef = useRef(null); const isInView = useInView(textRef, { once: true, amount: 0.5 }); const getVariants = () => { switch (variant) { case "slide": return { hidden: { opacity: 0, y: 50 }, visible: { opacity: 1, y: 0, transition: { duration, delay, staggerChildren: staggerDelay } } }; case "typewriter": return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: 0.05, delay, staggerChildren: 0.05 } } }; case "wave": return { hidden: { opacity: 0, y: 100, rotateZ: -10 }, visible: { opacity: 1, y: 0, rotateZ: 0, transition: { duration, delay, staggerChildren: staggerDelay, ease: [0.6, 0.01, -0.05, 0.95] } } }; default: return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration, delay, staggerChildren: staggerDelay } } }; } }; useEffect(() => { if (typeof window === "undefined" || !textRef.current) return; if (variant === "split" || variant === "explode" || variant === "scramble") { const text = children; const element = textRef.current; if (splitType === "chars") { element.innerHTML = text.split("").map( (char) => `<span class="split-char">${char === " " ? "&nbsp;" : char}</span>` ).join(""); } else if (splitType === "words") { element.innerHTML = text.split(/\s+/).map((word) => `<span class="split-word">${word}</span>`).join(" "); } else if (splitType === "lines") { element.innerHTML = text.split(/\s+/).map((word) => `<span class="split-line">${word}</span>`).join(" "); } const elements = element.querySelectorAll(`.split-${splitType}`); if (variant === "split") { gsap.set(elements, { opacity: 0, y: 50, rotateX: -90 }); const tl = gsap.timeline({ delay, onComplete: () => { if (textRef.current) { textRef.current.innerHTML = children; } } }); tl.to(elements, { opacity: 1, y: 0, rotateX: 0, duration, stagger: staggerDelay, ease: "back.out(1.7)" }); if (triggerOnScroll && !isInView) { tl.pause(); } } else if (variant === "explode") { gsap.set(elements, { opacity: 0, scale: 0 }); const tl = gsap.timeline({ delay }); tl.to(elements, { opacity: 1, scale: 1, duration, stagger: { amount: 0.5, from: "center", grid: "auto" }, ease: "elastic.out(1, 0.5)" }); if (triggerOnScroll && !isInView) { tl.pause(); } } else if (variant === "scramble") { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()"; elements.forEach((el, i) => { const element2 = el; const originalChar = element2.textContent || ""; let scrambleCount = 0; const maxScrambles = 10; const scrambleInterval = setInterval(() => { if (scrambleCount < maxScrambles) { element2.textContent = chars[Math.floor(Math.random() * chars.length)]; scrambleCount++; } else { element2.textContent = originalChar; clearInterval(scrambleInterval); } }, 50); }); } } return () => { if (textRef.current) { textRef.current.innerHTML = children; } }; }, [ variant, splitType, duration, staggerDelay, delay, isInView, triggerOnScroll, children ]); useEffect(() => { if (triggerOnScroll && isInView && splitRef.current) { const tl = gsap.timeline(); if (variant === "split" || variant === "explode") { tl.play(); } } }, [isInView, triggerOnScroll, variant]); if (variant === "fade" || variant === "slide" || variant === "typewriter" || variant === "wave") { const variants = getVariants(); return /* @__PURE__ */ jsx( motion.div, { ref: textRef, initial: "hidden", animate: triggerOnScroll ? isInView ? "visible" : "hidden" : "visible", variants, className: cn("overflow-hidden", className), children: variant === "typewriter" ? children.split("").map((char, i) => /* @__PURE__ */ jsx( motion.span, { variants: { hidden: { opacity: 0 }, visible: { opacity: 1 } }, children: char === " " ? "\xA0" : char }, i )) : /* @__PURE__ */ jsx(Component, { children }) } ); } return /* @__PURE__ */ jsx( Component, { ref: textRef, className: cn("overflow-hidden", className), children } ); } // src/components/NavigateScrollAnimatedText.tsx import { cn as cn2 } from "@tuel/utils"; import gsap2 from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useEffect as useEffect2, useRef as useRef2 } from "react"; import { jsx as jsx2 } from "react/jsx-runtime"; function NavigateScrollAnimatedText({ paragraphs, keywords = [ "vibrant", "living", "clarity", "expression", "shape", "intuitive", "storytelling", "interactive", "vision" ], className, wordHighlightBgColor = "60, 60, 60", pinHeight = 4, overlapWords = 15, reverseOverlapWords = 5, onProgress }) { const containerRef = useRef2(null); useEffect2(() => { if (typeof window === "undefined" || !containerRef.current) return; gsap2.registerPlugin(ScrollTrigger); const container = containerRef.current; const paragraphElements = container.querySelectorAll(".anime-text p"); paragraphElements.forEach((paragraph) => { const text = paragraph.textContent || ""; const words = text.split(/\s+/); paragraph.innerHTML = ""; words.forEach((word) => { if (word.trim()) { const wordContainer = document.createElement("div"); wordContainer.className = "word"; const wordText = document.createElement("span"); wordText.textContent = word; const normalizedWord = word.toLowerCase().replace(/[.,!?;:"]/g, ""); if (keywords.includes(normalizedWord)) { wordContainer.classList.add("keyword-wrapper"); wordText.classList.add("keyword", normalizedWord); } wordContainer.appendChild(wordText); paragraph.appendChild(wordContainer); } }); }); const scrollTrigger = ScrollTrigger.create({ trigger: container, pin: container, start: "top top", end: `+=${window.innerHeight * pinHeight}`, pinSpacing: true, onUpdate: (self) => { const progress = self.progress; const words = Array.from( container.querySelectorAll(".anime-text .word") ); const totalWords = words.length; onProgress?.(progress); words.forEach((word, index) => { const wordText = word.querySelector("span"); if (progress <= 0.7) { const progressTarget = 0.7; const revealProgress = Math.min(1, progress / progressTarget); const totalAnimationLength = 1 + overlapWords / totalWords; const wordStart = index / totalWords; const wordEnd = wordStart + overlapWords / totalWords; const timelineScale = 1 / Math.min( totalAnimationLength, 1 + (totalWords - 1) / totalWords + overlapWords / totalWords ); const adjustedStart = wordStart * timelineScale; const adjustedEnd = wordEnd * timelineScale; const duration = adjustedEnd - adjustedStart; const wordProgress = revealProgress <= adjustedStart ? 0 : revealProgress >= adjustedEnd ? 1 : (revealProgress - adjustedStart) / duration; word.style.opacity = wordProgress.toString(); const backgroundFadeStart = wordProgress >= 0.9 ? (wordProgress - 0.9) / 0.1 : 0; const backgroundOpacity = Math.max(0, 1 - backgroundFadeStart); word.style.backgroundColor = `rgba(${wordHighlightBgColor}, ${backgroundOpacity})`; const textRevealThreshold = 0.9; const textRevealProgress = wordProgress >= textRevealThreshold ? (wordProgress - textRevealThreshold) / (1 - textRevealThreshold) : 0; wordText.style.opacity = Math.pow( textRevealProgress, 0.5 ).toString(); } else { const reverseProgress = (progress - 0.7) / 0.3; word.style.opacity = "1"; const targetTextOpacity = 1; const reverseWordStart = index / totalWords; const reverseWordEnd = reverseWordStart + reverseOverlapWords / totalWords; const reverseTimelineScale = 1 / Math.max( 1, (totalWords - 1) / totalWords + reverseOverlapWords / totalWords ); const reverseAdjustedStart = reverseWordStart * reverseTimelineScale; const reverseAdjustedEnd = reverseWordEnd * reverseTimelineScale; const reverseDuration = reverseAdjustedEnd - reverseAdjustedStart; const reverseWordProgress = reverseProgress <= reverseAdjustedStart ? 0 : reverseProgress >= reverseAdjustedEnd ? 1 : (reverseProgress - reverseAdjustedStart) / reverseDuration; if (reverseWordProgress > 0) { wordText.style.opacity = (targetTextOpacity * (1 - reverseWordProgress)).toString(); word.style.backgroundColor = `rgba(${wordHighlightBgColor}, ${reverseWordProgress})`; } else { wordText.style.opacity = targetTextOpacity.toString(); word.style.backgroundColor = `rgba(${wordHighlightBgColor}, 0)`; } } }); } }); return () => { scrollTrigger.kill(); }; }, [ paragraphs, keywords, wordHighlightBgColor, pinHeight, overlapWords, reverseOverlapWords, onProgress ]); return /* @__PURE__ */ jsx2("div", { className: cn2("navigate-scroll-animated-text", className), children: /* @__PURE__ */ jsx2("div", { className: "anime-text-container", ref: containerRef, children: /* @__PURE__ */ jsx2("div", { className: "anime-text", children: paragraphs.map((paragraph, index) => /* @__PURE__ */ jsx2("p", { children: paragraph }, index)) }) }) }); } // src/components/ParticleText.tsx import { cn as cn3 } from "@tuel/utils"; import { useEffect as useEffect3, useRef as useRef3, useState } from "react"; import { jsx as jsx3 } from "react/jsx-runtime"; function ParticleText({ text, className, font = "bold 80px Arial", fontSize = 80, color = "#ffffff", backgroundColor = "transparent", particleSize = 2, particleGap = 3, mouseRadius = 100, mouseForce = 2, returnSpeed = 0.1, friction = 0.95, ease = 0.1, hover = true, explode = false, wave = false, waveSpeed = 2e-3, waveAmplitude = 20, interactive = true, density = 1 }) { const canvasRef = useRef3(null); const animationFrameRef = useRef3(void 0); const particlesRef = useRef3([]); const mouseRef = useRef3({ x: -1e3, y: -1e3 }); const waveOffsetRef = useRef3(0); const [isExploded, setIsExploded] = useState(false); useEffect3(() => { if (typeof window === "undefined") return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const resizeCanvas = () => { canvas.width = canvas.offsetWidth * window.devicePixelRatio; canvas.height = canvas.offsetHeight * window.devicePixelRatio; ctx.scale(window.devicePixelRatio, window.devicePixelRatio); initializeParticles(); }; const initializeParticles = () => { particlesRef.current = []; const tempCanvas = document.createElement("canvas"); const tempCtx = tempCanvas.getContext("2d"); if (!tempCtx) return; tempCtx.font = font; const textMetrics = tempCtx.measureText(text); const textWidth = textMetrics.width; const textHeight = fontSize * 1.2; tempCanvas.width = textWidth; tempCanvas.height = textHeight; tempCtx.font = font; tempCtx.fillStyle = color; tempCtx.textBaseline = "middle"; tempCtx.fillText(text, 0, textHeight / 2); const imageData = tempCtx.getImageData(0, 0, textWidth, textHeight); const data = imageData.data; const centerX = canvas.offsetWidth / 2 - textWidth / 2; const centerY = canvas.offsetHeight / 2 - textHeight / 2; const gap = Math.max(1, Math.floor(particleGap / density)); for (let y = 0; y < textHeight; y += gap) { for (let x = 0; x < textWidth; x += gap) { const index = (y * textWidth + x) * 4; const alpha = data[index + 3]; if (alpha > 128) { const particle = { x: centerX + x, y: centerY + y, originX: centerX + x, originY: centerY + y, vx: 0, vy: 0, size: particleSize, color: `rgba(${data[index]}, ${data[index + 1]}, ${data[index + 2]}, ${alpha / 255})`, distance: 0 }; particlesRef.current.push(particle); } } } }; const updateParticle = (particle) => { if (interactive && mouseRef.current.x > 0) { const dx = mouseRef.current.x - particle.x; const dy = mouseRef.current.y - particle.y; const distance = Math.sqrt(dx * dx + dy * dy); particle.distance = distance; if (distance < mouseRadius) { const angle = Math.atan2(dy, dx); const force = (1 - distance / mouseRadius) * mouseForce; if (hover) { particle.vx -= Math.cos(angle) * force; particle.vy -= Math.sin(angle) * force; } else { particle.vx += Math.cos(angle) * force; particle.vy += Math.sin(angle) * force; } } } if (explode && isExploded) { const angle = Math.random() * Math.PI * 2; const force = Math.random() * 10 + 5; particle.vx = Math.cos(angle) * force; particle.vy = Math.sin(angle) * force; } if (wave) { const waveY = Math.sin(particle.originX * 0.01 + waveOffsetRef.current) * waveAmplitude; particle.y = particle.originY + waveY; } if (!isExploded) { const dx = particle.originX - particle.x; const dy = particle.originY - particle.y; particle.vx += dx * returnSpeed; particle.vy += dy * returnSpeed; } particle.vx *= friction; particle.vy *= friction; particle.x += particle.vx; particle.y += particle.vy; }; const drawParticle = (particle) => { ctx.save(); let alpha = 1; if (interactive && particle.distance > 0 && particle.distance < mouseRadius * 2) { alpha = 1 - (particle.distance - mouseRadius) / mouseRadius; alpha = Math.max(0.3, Math.min(1, alpha)); } ctx.globalAlpha = alpha; ctx.fillStyle = particle.color; ctx.beginPath(); ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }; const animate = () => { if (backgroundColor === "transparent") { ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight); } else { ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvas.offsetWidth, canvas.offsetHeight); } if (wave) { waveOffsetRef.current += waveSpeed; } particlesRef.current.forEach((particle) => { updateParticle(particle); drawParticle(particle); }); animationFrameRef.current = requestAnimationFrame(animate); }; const handleMouseMove = (e) => { const rect = canvas.getBoundingClientRect(); mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; const handleMouseLeave = () => { mouseRef.current = { x: -1e3, y: -1e3 }; }; const handleClick = () => { if (explode) { setIsExploded(!isExploded); } }; resizeCanvas(); animationFrameRef.current = requestAnimationFrame(animate); window.addEventListener("resize", resizeCanvas); if (interactive) { canvas.addEventListener("mousemove", handleMouseMove); canvas.addEventListener("mouseleave", handleMouseLeave); if (explode) { canvas.addEventListener("click", handleClick); } } return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } window.removeEventListener("resize", resizeCanvas); canvas.removeEventListener("mousemove", handleMouseMove); canvas.removeEventListener("mouseleave", handleMouseLeave); canvas.removeEventListener("click", handleClick); }; }, [ text, font, fontSize, color, backgroundColor, particleSize, particleGap, mouseRadius, mouseForce, returnSpeed, friction, ease, hover, explode, wave, waveSpeed, waveAmplitude, interactive, density, isExploded ]); return /* @__PURE__ */ jsx3( "canvas", { ref: canvasRef, className: cn3("w-full h-full cursor-pointer", className), style: { background: backgroundColor } } ); } export { AnimatedText, NavigateScrollAnimatedText, ParticleText };