{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "text-animator", "title": "Text Animator", "author": "designbycode", "dependencies": [ "gsap", "@gsap/react" ], "registryDependencies": [], "files": [ { "path": "resources/js/registry/new-york/components/ui/animations/text-animator.tsx", "content": "'use client';\n\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport type { CSSProperties, ElementType, KeyboardEvent } from 'react';\nimport React, {\n forwardRef,\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n} from 'react';\n\n// ─── Animation Type Union ────────────────────────────────────────────────────\n\nexport type AnimationType =\n // Fades\n | 'fadeIn'\n | 'fadeInUp'\n | 'fadeInDown'\n | 'fadeInLeft'\n | 'fadeInRight'\n | 'fadeInTopLeft'\n | 'fadeInTopRight'\n | 'fadeInBottomLeft'\n | 'fadeInBottomRight'\n // Slides\n | 'slideUp'\n | 'slideDown'\n | 'slideLeft'\n | 'slideRight'\n | 'slideTopLeft'\n | 'slideTopRight'\n | 'slideBottomLeft'\n | 'slideBottomRight'\n // Scale\n | 'scaleUp'\n | 'scaleDown'\n | 'scaleIn'\n | 'scaleInUp'\n | 'scaleInDown'\n // Blur\n | 'blurIn'\n | 'blurOut'\n | 'blurInLeft'\n | 'blurInRight'\n | 'blurInUp'\n | 'blurInDown'\n // Rotate\n | 'rotateIn'\n | 'rotateOut'\n | 'rotateInLeft'\n | 'rotateInRight'\n | 'rotateOutLeft'\n | 'rotateOutRight'\n // Physics\n | 'bounce'\n | 'elastic'\n | 'jelly'\n | 'squash'\n | 'liquid'\n | 'swing'\n | 'stretch'\n | 'spring'\n | 'wobble'\n | 'shake'\n | 'drift'\n | 'float'\n // Character\n | 'wave'\n | 'pop'\n | 'flip'\n | 'rollIn'\n | 'skewIn'\n | 'spiral'\n | 'morph'\n | 'crash'\n | 'explode'\n | 'letterByLetter'\n | 'typewriter'\n | 'jitter'\n // Text effects\n | 'reveal'\n | 'glitch'\n | 'gradient'\n | 'shadow'\n | 'neon'\n | 'marquee'\n | 'flicker'\n | 'spotlight'\n | 'outline'\n | 'pulse'\n | 'breathe'\n | 'aurora'\n // Special effects\n | 'matrix'\n | 'fire'\n | 'rainbow'\n | 'magnetic'\n | 'particles'\n | 'dissolve'\n | 'scramble'\n | 'zap'\n | 'orbit'\n | 'vortex'\n | 'ripple'\n | 'piano'\n | 'domino'\n | 'pendulum'\n | 'shatter'\n | 'smoke'\n | 'thunder'\n | 'crystallize'\n | 'warp'\n | 'cinema'\n | 'gravity'\n | 'levitate'\n | 'twinkle'\n | 'shimmerFade'\n | 'fold'\n | 'cascade'\n | 'pinball'\n | 'neonFlicker'\n | 'rise'\n | 'unfurl'\n | 'stampIn'\n | 'blinds';\n\n// ─── Trigger Type ─────────────────────────────────────────────────────────────\n\nexport type TriggerType = 'onClick' | 'onHover' | 'scrollTrigger';\n\n// ─── Split Mode ────────────────────────────────────────────────────────────────\n\nexport type SplitMode = 'chars' | 'words' | 'lines';\n\n// ─── Easing Presets ───────────────────────────────────────────────────────────\n\nexport type EasePreset =\n | 'power1.in'\n | 'power1.out'\n | 'power1.inOut'\n | 'power2.in'\n | 'power2.out'\n | 'power2.inOut'\n | 'power3.in'\n | 'power3.out'\n | 'power3.inOut'\n | 'power4.in'\n | 'power4.out'\n | 'power4.inOut'\n | 'back.in'\n | 'back.out'\n | 'back.inOut'\n | 'bounce.in'\n | 'bounce.out'\n | 'bounce.inOut'\n | 'elastic.in'\n | 'elastic.out'\n | 'elastic.inOut'\n | 'circ.in'\n | 'circ.out'\n | 'circ.inOut'\n | 'expo.in'\n | 'expo.out'\n | 'expo.inOut'\n | 'sine.in'\n | 'sine.out'\n | 'sine.inOut'\n | 'none'\n | (string & {});\n\n// ─── ScrollTrigger Options ────────────────────────────────────────────────────\n\nexport interface ScrollTriggerOptions {\n /** ScrollTrigger start position. Default: `\"top 80%\"` */\n start?: string;\n /** ScrollTrigger end position. Default: `\"bottom 20%\"` */\n end?: string;\n /** Scrub the animation to scroll position. Default: `false` */\n scrub?: boolean | number;\n /** Markers for debugging (dev only). Default: `false` */\n markers?: boolean;\n /** Toggle actions string. Default: `\"play none none reverse\"` */\n toggleActions?: string;\n /** Pin the element while animating. Default: `false` */\n pin?: boolean;\n}\n\n// ─── Stagger Options ─────────────────────────────────────────────────────────\n\nexport interface StaggerOptions {\n /** Time between each character animation in seconds. Default: `0.04` */\n each?: number;\n /** Stagger from: `\"start\"` | `\"end\"` | `\"center\"` | `\"edges\"` | number */\n from?: 'start' | 'end' | 'center' | 'edges' | number;\n /** Grid stagger for 2D layouts */\n grid?: [number, number] | 'auto';\n /** Axis for grid stagger */\n axis?: 'x' | 'y';\n /** Amount distributes the stagger across total duration */\n amount?: number;\n}\n\n// ─── Component Props ──────────────────────────────────────────────────────────\n\nexport interface TextAnimatorProps {\n text?: string;\n children?: string;\n animation?: AnimationType;\n trigger?: TriggerType;\n splitBy?: SplitMode;\n duration?: number;\n delay?: number;\n stagger?: number | StaggerOptions;\n ease?: EasePreset;\n repeat?: number;\n yoyo?: boolean;\n scrollTrigger?: ScrollTriggerOptions;\n tag?: ElementType;\n color?: string;\n fontSize?: string | number;\n className?: string;\n style?: CSSProperties;\n /**\n * Custom color(s) for color-driven animations:\n * aurora, fire, glitch, gradient, matrix, neon, neonFlicker, rainbow, zap.\n * Accepts a single CSS color string or an array.\n * When one color is given it fills both the primary and secondary slots.\n */\n effectColor?: string | string[];\n onComplete?: () => void;\n onStart?: () => void;\n onRepeat?: () => void;\n}\n\n// ─── Ref API ──────────────────────────────────────────────────────────────────\n\nexport interface TextAnimatorRef {\n play: () => void;\n pause: () => void;\n reverse: () => void;\n restart: () => void;\n seek: (timeOrProgress: number) => void;\n kill: () => void;\n timeline: () => gsap.core.Timeline | null;\n isPlaying: () => boolean;\n progress: () => number;\n}\n\n// ─── Animation Config (internal) ─────────────────────────────────────────────\n\nexport interface AnimationContext {\n chars: HTMLElement[];\n words: HTMLElement[];\n el: HTMLElement;\n opts: ResolvedAnimOpts;\n effectColors: string[];\n}\n\nexport interface ResolvedAnimOpts {\n duration: number;\n delay: number;\n stagger: number | StaggerOptions;\n ease: string;\n repeat: number;\n yoyo: boolean;\n}\n\nexport interface AnimationConfig {\n targets?: HTMLElement[];\n from?: gsap.TweenVars;\n to?: gsap.TweenVars;\n overrideEase?: boolean;\n special?: (tl: gsap.core.Timeline) => void;\n}\n\nexport type AnimationDefinition = (ctx: AnimationContext) => AnimationConfig;\n\n// ─── Plugin Registration ─────────────────────────────────────────────────────\n\ngsap.registerPlugin(ScrollTrigger);\n\n// ─── Utility: Resolve stagger ─────────────────────────────────────────────────\n\nfunction resolveStagger(s: number | StaggerOptions, fallback = 0.04): number {\n return typeof s === 'number' ? s : fallback;\n}\n\n// ─── Utility: Text Splitting ─────────────────────────────────────────────────\n\nfunction splitChars(el: HTMLElement): HTMLElement[] {\n const text = el.textContent ?? '';\n el.innerHTML = '';\n\n return [...text].map((ch) => {\n const span = document.createElement('span');\n span.textContent = ch === ' ' ? '\\u00A0' : ch;\n span.style.display = 'inline-block';\n el.appendChild(span);\n\n return span;\n });\n}\n\nfunction splitWords(el: HTMLElement): HTMLElement[] {\n const words = (el.textContent ?? '').split(' ');\n el.innerHTML = '';\n\n return words.map((word, i) => {\n const clip = document.createElement('span');\n clip.style.cssText =\n 'display:inline-block;overflow:hidden;vertical-align:bottom;';\n const inner = document.createElement('span');\n inner.textContent = word;\n inner.style.display = 'inline-block';\n clip.appendChild(inner);\n el.appendChild(clip);\n\n if (i < words.length - 1) {\n el.appendChild(document.createTextNode('\\u00A0'));\n }\n\n return inner;\n });\n}\n\nfunction splitLines(el: HTMLElement): HTMLElement[] {\n const lines = (el.textContent ?? '').split('\\n');\n el.innerHTML = '';\n\n return lines.map((line, lineIndex) => {\n const lineWrap = document.createElement('span');\n lineWrap.style.cssText = 'display:block;';\n const lineInner = document.createElement('span');\n lineInner.style.cssText =\n 'display:inline-block;overflow:hidden;vertical-align:bottom;';\n const words = line.split(' ');\n words.forEach((word, wordIndex) => {\n const wordClip = document.createElement('span');\n wordClip.style.cssText =\n 'display:inline-block;overflow:hidden;vertical-align:bottom;';\n const wordInner = document.createElement('span');\n wordInner.textContent = word;\n wordInner.style.display = 'inline-block';\n wordClip.appendChild(wordInner);\n lineInner.appendChild(wordClip);\n\n if (wordIndex < words.length - 1) {\n lineInner.appendChild(document.createTextNode('\\u00A0'));\n }\n });\n lineWrap.appendChild(lineInner);\n el.appendChild(lineWrap);\n\n if (lineIndex < lines.length - 1) {\n el.appendChild(document.createTextNode('\\n'));\n }\n\n return lineInner;\n });\n}\n\n// ─── Shared special builder helpers ──────────────────────────────────────────\n\n/** Builds a standard `from` tween over chars with stagger. */\nfunction fromChars(\n tl: gsap.core.Timeline,\n chars: HTMLElement[],\n opts: ResolvedAnimOpts,\n vars: gsap.TweenVars,\n staggerFallback = 0.04,\n): void {\n tl.from(chars, {\n ...vars,\n duration: opts.duration,\n stagger: resolveStagger(opts.stagger, staggerFallback),\n ease: vars.ease ?? opts.ease,\n });\n}\n\n// ─── Animation Definitions ───────────────────────────────────────────────────\n\nconst ANIMATIONS: Partial> = {\n // ── Fades ─────────────────────────────────────────────────────────────────\n\n fadeIn: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0 },\n to: { opacity: 1 },\n }),\n fadeInUp: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, y: 40 },\n to: { opacity: 1, y: 0 },\n }),\n fadeInDown: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, y: -40 },\n to: { opacity: 1, y: 0 },\n }),\n fadeInLeft: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, x: -40 },\n to: { opacity: 1, x: 0 },\n }),\n fadeInRight: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, x: 40 },\n to: { opacity: 1, x: 0 },\n }),\n fadeInTopLeft: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, x: -40, y: -40 },\n to: { opacity: 1, x: 0, y: 0 },\n }),\n fadeInTopRight: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, x: 40, y: -40 },\n to: { opacity: 1, x: 0, y: 0 },\n }),\n fadeInBottomLeft: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, x: -40, y: 40 },\n to: { opacity: 1, x: 0, y: 0 },\n }),\n fadeInBottomRight: ({ chars }) => ({\n targets: chars,\n from: { opacity: 0, x: 40, y: 40 },\n to: { opacity: 1, x: 0, y: 0 },\n }),\n\n // ── Slides ────────────────────────────────────────────────────────────────\n\n slideUp: ({ chars }) => ({\n targets: chars,\n from: { y: '100%', opacity: 0 },\n to: { y: '0%', opacity: 1 },\n }),\n slideDown: ({ chars }) => ({\n targets: chars,\n from: { y: '-100%', opacity: 0 },\n to: { y: '0%', opacity: 1 },\n }),\n slideLeft: ({ chars }) => ({\n targets: chars,\n from: { x: '-120%', opacity: 0 },\n to: { x: '0%', opacity: 1 },\n }),\n slideRight: ({ chars }) => ({\n targets: chars,\n from: { x: '120%', opacity: 0 },\n to: { x: '0%', opacity: 1 },\n }),\n slideTopLeft: ({ chars }) => ({\n targets: chars,\n from: { x: '-120%', y: '-100%', opacity: 0 },\n to: { x: '0%', y: '0%', opacity: 1 },\n }),\n slideTopRight: ({ chars }) => ({\n targets: chars,\n from: { x: '120%', y: '-100%', opacity: 0 },\n to: { x: '0%', y: '0%', opacity: 1 },\n }),\n slideBottomLeft: ({ chars }) => ({\n targets: chars,\n from: { x: '-120%', y: '100%', opacity: 0 },\n to: { x: '0%', y: '0%', opacity: 1 },\n }),\n slideBottomRight: ({ chars }) => ({\n targets: chars,\n from: { x: '120%', y: '100%', opacity: 0 },\n to: { x: '0%', y: '0%', opacity: 1 },\n }),\n\n // ── Scale ─────────────────────────────────────────────────────────────────\n\n scaleUp: ({ chars }) => ({\n targets: chars,\n from: { scale: 0, opacity: 0 },\n to: { scale: 1, opacity: 1 },\n }),\n scaleDown: ({ chars }) => ({\n targets: chars,\n from: { scale: 2.5, opacity: 0 },\n to: { scale: 1, opacity: 1 },\n }),\n scaleIn: ({ chars }) => ({\n targets: chars,\n from: { scale: 0, opacity: 0 },\n to: { scale: 1, opacity: 1, ease: 'back.out(2)' },\n overrideEase: true,\n }),\n scaleInUp: ({ chars }) => ({\n targets: chars,\n from: { scale: 0, y: 30, opacity: 0 },\n to: { scale: 1, y: 0, opacity: 1 },\n }),\n scaleInDown: ({ chars }) => ({\n targets: chars,\n from: { scale: 0, y: -30, opacity: 0 },\n to: { scale: 1, y: 0, opacity: 1 },\n }),\n\n // ── Blur ──────────────────────────────────────────────────────────────────\n\n blurIn: ({ chars }) => ({\n targets: chars,\n from: { filter: 'blur(12px)', opacity: 0 },\n to: { filter: 'blur(0px)', opacity: 1 },\n }),\n blurOut: ({ chars }) => ({\n targets: chars,\n from: { filter: 'blur(0px)', opacity: 1 },\n to: { filter: 'blur(12px)', opacity: 0 },\n }),\n blurInLeft: ({ chars }) => ({\n targets: chars,\n from: { filter: 'blur(12px)', x: -40, opacity: 0 },\n to: { filter: 'blur(0px)', x: 0, opacity: 1 },\n }),\n blurInRight: ({ chars }) => ({\n targets: chars,\n from: { filter: 'blur(12px)', x: 40, opacity: 0 },\n to: { filter: 'blur(0px)', x: 0, opacity: 1 },\n }),\n blurInUp: ({ chars }) => ({\n targets: chars,\n from: { filter: 'blur(12px)', y: 40, opacity: 0 },\n to: { filter: 'blur(0px)', y: 0, opacity: 1 },\n }),\n blurInDown: ({ chars }) => ({\n targets: chars,\n from: { filter: 'blur(12px)', y: -40, opacity: 0 },\n to: { filter: 'blur(0px)', y: 0, opacity: 1 },\n }),\n\n // ── Rotate ────────────────────────────────────────────────────────────────\n\n rotateIn: ({ chars }) => ({\n targets: chars,\n from: { rotation: -180, opacity: 0, scale: 0 },\n to: { rotation: 0, opacity: 1, scale: 1 },\n }),\n rotateOut: ({ chars }) => ({\n targets: chars,\n from: { rotation: 0, opacity: 1 },\n to: { rotation: 180, opacity: 0 },\n }),\n rotateInLeft: ({ chars }) => ({\n targets: chars,\n from: { rotation: -90, x: -40, opacity: 0 },\n to: { rotation: 0, x: 0, opacity: 1 },\n }),\n rotateInRight: ({ chars }) => ({\n targets: chars,\n from: { rotation: 90, x: 40, opacity: 0 },\n to: { rotation: 0, x: 0, opacity: 1 },\n }),\n rotateOutLeft: ({ chars }) => ({\n targets: chars,\n from: { rotation: 0, opacity: 1 },\n to: { rotation: -90, x: -40, opacity: 0 },\n }),\n rotateOutRight: ({ chars }) => ({\n targets: chars,\n from: { rotation: 0, opacity: 1 },\n to: { rotation: 90, x: 40, opacity: 0 },\n }),\n\n // ── Physics ───────────────────────────────────────────────────────────────\n\n bounce: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n y: -60,\n opacity: 0,\n ease: 'bounce.out',\n });\n },\n }),\n\n elastic: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n scale: 0,\n opacity: 0,\n ease: 'elastic.out(1, 0.3)',\n });\n },\n }),\n\n jelly: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n scaleX: 1.6,\n scaleY: 0.4,\n opacity: 0,\n ease: 'elastic.out(1, 0.4)',\n });\n },\n }),\n\n squash: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n scaleY: 2.5,\n scaleX: 0.5,\n y: -30,\n opacity: 0,\n ease: 'bounce.out',\n });\n },\n }),\n\n liquid: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n scaleY: 2.5,\n scaleX: 0.4,\n opacity: 0,\n ease: 'elastic.out(0.5, 0.3)',\n });\n },\n }),\n\n swing: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n rotation: -45,\n transformOrigin: 'top center',\n opacity: 0,\n ease: 'elastic.out(0.8, 0.4)',\n });\n },\n }),\n\n stretch: ({ chars }) => ({\n targets: chars,\n from: { scaleX: 3, opacity: 0 },\n to: { scaleX: 1, opacity: 1 },\n }),\n\n spring: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n scale: 0,\n opacity: 0,\n ease: 'elastic.out(1, 0.5)',\n });\n },\n }),\n\n wobble: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n rotation: -15,\n opacity: 0,\n ease: 'elastic.out(1, 0.3)',\n });\n },\n }),\n\n shake: ({ chars, opts }) => ({\n special: (tl) => {\n tl.from(chars, {\n x: -10,\n opacity: 0,\n duration: opts.duration * 0.5,\n stagger: resolveStagger(opts.stagger),\n ease: 'power1.inOut',\n });\n },\n }),\n\n drift: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n x: -30,\n y: 20,\n opacity: 0,\n ease: 'power2.out',\n });\n },\n }),\n\n float: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, { y: 30, opacity: 0, ease: 'sine.out' });\n },\n }),\n\n // ── Character ─────────────────────────────────────────────────────────────\n\n wave: ({ chars, opts }) => ({\n special: (tl) => {\n tl.from(chars, {\n y: -20,\n opacity: 0,\n duration: opts.duration,\n ease: 'sine.inOut',\n stagger: {\n each: resolveStagger(opts.stagger, 0.06),\n yoyo: true,\n repeat: 1,\n },\n });\n },\n }),\n\n pop: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n scale: 0,\n opacity: 0,\n ease: 'back.out(3)',\n });\n },\n }),\n\n flip: ({ chars }) => ({\n targets: chars,\n from: { rotationX: 90, opacity: 0, transformPerspective: 400 },\n to: { rotationX: 0, opacity: 1, transformPerspective: 400 },\n }),\n\n rollIn: ({ chars }) => ({\n targets: chars,\n from: { x: -60, rotation: -120, opacity: 0 },\n to: { x: 0, rotation: 0, opacity: 1 },\n }),\n\n skewIn: ({ chars }) => ({\n targets: chars,\n from: { skewX: 30, opacity: 0, x: -30 },\n to: { skewX: 0, opacity: 1, x: 0 },\n }),\n\n spiral: ({ chars }) => ({\n targets: chars,\n from: {\n rotation: -720,\n scale: 0,\n opacity: 0,\n x: () => gsap.utils.random(-40, 40) as number,\n },\n to: { rotation: 0, scale: 1, opacity: 1, x: 0 },\n }),\n\n morph: ({ chars }) => ({\n targets: chars,\n from: { borderRadius: '50%', scale: 0.3, opacity: 0 },\n to: { borderRadius: '0%', scale: 1, opacity: 1 },\n }),\n\n crash: ({ chars, opts }) => ({\n special: (tl) => {\n tl.from(chars, {\n y: () => gsap.utils.random(-200, -80) as number,\n x: () => gsap.utils.random(-20, 20) as number,\n rotation: () => gsap.utils.random(-30, 30) as number,\n opacity: 0,\n scale: () => gsap.utils.random(0.5, 1.5) as number,\n duration: opts.duration * 0.6,\n stagger: resolveStagger(opts.stagger),\n ease: 'bounce.out',\n });\n },\n }),\n\n explode: ({ chars, opts }) => ({\n special: (tl) => {\n tl.from(chars, {\n x: () => gsap.utils.random(-120, 120) as number,\n y: () => gsap.utils.random(-120, 120) as number,\n rotation: () => gsap.utils.random(-360, 360) as number,\n opacity: 0,\n scale: 0,\n duration: opts.duration,\n stagger: resolveStagger(opts.stagger),\n ease: opts.ease,\n });\n },\n }),\n\n // ── Text Entry ────────────────────────────────────────────────────────────\n\n letterByLetter: ({ chars, opts }) => ({\n special: (tl) => {\n gsap.set(chars, { visibility: 'hidden' });\n tl.to(chars, {\n visibility: 'visible',\n duration: 0,\n stagger: (opts.duration * 0.9) / chars.length,\n });\n },\n }),\n\n typewriter: ({ chars, opts }) => ({\n special: (tl) => {\n const firstChar = chars[0];\n\n if (!firstChar?.parentNode) {\n return;\n }\n\n const cursor = document.createElement('span');\n cursor.textContent = '|';\n cursor.style.cssText = 'display:inline-block;margin-left:1px;';\n\n const styleEl = document.createElement('style');\n styleEl.textContent =\n '@keyframes cursorBlink{0%,50%{opacity:1}51%,100%{opacity:0}}';\n cursor.style.animation = 'cursorBlink 0.8s infinite';\n document.head.appendChild(styleEl);\n\n firstChar.parentNode.insertBefore(cursor, firstChar);\n gsap.set(chars, { visibility: 'hidden' });\n\n const charTime = (opts.duration * 0.8) / chars.length;\n chars.forEach((char, i) => {\n tl.call(\n () => {\n char.style.visibility = 'visible';\n cursor.parentNode?.insertBefore(\n cursor,\n char.nextSibling,\n );\n },\n undefined,\n i * charTime,\n );\n });\n\n tl.call(\n () => {\n const last = chars[chars.length - 1];\n\n if (last) {\n cursor.parentNode?.insertBefore(\n cursor,\n last.nextSibling,\n );\n }\n },\n undefined,\n chars.length * charTime,\n );\n\n tl.eventCallback('onComplete', () => {\n cursor.remove();\n styleEl.remove();\n });\n },\n }),\n\n jitter: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.02);\n\n for (let j = 0; j < 3; j++) {\n tl.to(\n char,\n {\n x: () => gsap.utils.random(-3, 3),\n duration: 0.05,\n ease: 'none',\n },\n t + j * 0.05,\n );\n }\n\n tl.to(\n char,\n {\n x: 0,\n opacity: 1,\n duration: opts.duration * 0.3,\n ease: 'power2.out',\n },\n t,\n );\n });\n },\n }),\n\n reveal: ({ words, opts }) => ({\n special: (tl) => {\n tl.from(words, {\n y: '110%',\n opacity: 0,\n duration: opts.duration,\n stagger:\n typeof opts.stagger === 'number'\n ? opts.stagger * 2\n : opts.stagger,\n ease: opts.ease,\n });\n },\n }),\n\n // ── Visual Effects ────────────────────────────────────────────────────────\n\n glitch: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const glitchChars = '!@#$%^&*()[]{}|;:,.<>?~`';\n const primary = effectColors[0] ?? '#ff003c';\n const secondary = effectColors[1] ?? '#00f7ff';\n\n chars.forEach((char, i) => {\n const originalText = char.textContent ?? '';\n const t = i * resolveStagger(opts.stagger, 0.02);\n const glitchCount = 5 + Math.floor(Math.random() * 3);\n\n gsap.set(char, { opacity: 0, scale: 0.8 });\n tl.to(\n char,\n {\n opacity: 1,\n scale: 1,\n duration: opts.duration * 0.15,\n ease: 'power2.out',\n },\n t,\n );\n\n for (let g = 0; g < glitchCount; g++) {\n const gs =\n t + opts.duration * 0.2 + g * opts.duration * 0.08;\n tl.to(\n char,\n {\n x: () => gsap.utils.random(-6, 6),\n y: () => gsap.utils.random(-4, 4),\n duration: 0.03,\n ease: 'none',\n },\n gs,\n );\n tl.to(\n char,\n {\n textShadow: `${gsap.utils.random(-4, 4)}px ${gsap.utils.random(-2, 2)}px ${primary}, ${gsap.utils.random(-4, 4)}px ${gsap.utils.random(-2, 2)}px ${secondary}`,\n duration: 0.03,\n ease: 'none',\n },\n gs,\n );\n tl.to(\n char,\n {\n opacity: () => (Math.random() > 0.3 ? 1 : 0.5),\n duration: 0.02,\n },\n gs,\n );\n tl.call(\n () => {\n if (Math.random() > 0.4) {\n char.textContent =\n glitchChars[\n Math.floor(\n Math.random() * glitchChars.length,\n )\n ] ?? originalText;\n }\n },\n [],\n gs + 0.015,\n );\n }\n\n tl.to(\n char,\n {\n x: 0,\n y: 0,\n textShadow: 'none',\n opacity: 1,\n scale: 1,\n duration: opts.duration * 0.15,\n ease: 'elastic.out(1, 0.3)',\n },\n t + opts.duration * 0.6,\n );\n tl.call(\n () => {\n char.textContent = originalText;\n },\n [],\n t + opts.duration * 0.8,\n );\n });\n },\n }),\n\n gradient: ({ el, opts, effectColors }) => ({\n special: (tl) => {\n const colors =\n effectColors.length > 0\n ? effectColors\n : ['#ff6ec7', '#ffe259', '#4af', '#ff6ec7'];\n el.style.backgroundImage = `linear-gradient(90deg, ${colors.join(', ')})`;\n el.style.backgroundSize = '300% 100%';\n el.style.backgroundClip = 'text';\n (\n el.style as CSSStyleDeclaration & {\n webkitTextFillColor: string;\n }\n ).webkitTextFillColor = 'transparent';\n tl.fromTo(\n el,\n { backgroundPosition: '0% 50%' },\n {\n backgroundPosition: '100% 50%',\n duration: opts.duration,\n ease: 'none',\n repeat: opts.repeat,\n },\n );\n },\n }),\n\n shadow: ({ chars, opts }) => ({\n special: (tl) => {\n tl.from(chars, {\n opacity: 0,\n duration: opts.duration,\n stagger: resolveStagger(opts.stagger),\n }).to(\n chars,\n {\n textShadow: '4px 4px 12px rgba(0,0,0,0.5)',\n duration: opts.duration * 0.6,\n },\n 0,\n );\n },\n }),\n\n neon: ({ el, opts, effectColors }) => ({\n special: (tl) => {\n const c = effectColors[0] ?? '#39ff14';\n tl.fromTo(\n el,\n { textShadow: `0 0 0px ${c}`, opacity: 0 },\n {\n textShadow: `0 0 8px ${c}, 0 0 20px ${c}, 0 0 40px ${c}`,\n opacity: 1,\n duration: opts.duration,\n },\n );\n },\n }),\n\n marquee: ({ chars, opts }) => ({\n special: (tl) => {\n fromChars(tl, chars, opts, {\n x: '110%',\n opacity: 0,\n ease: 'power3.out',\n });\n },\n }),\n\n flicker: ({ chars, opts }) => ({\n special: (tl) => {\n gsap.set(chars, { opacity: 0 });\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.03);\n tl.to(char, { opacity: 1, duration: 0.02 }, t);\n tl.to(char, { opacity: 0.3, duration: 0.02 }, t + 0.02);\n tl.to(char, { opacity: 1, duration: 0.02 }, t + 0.04);\n tl.to(char, { opacity: 0.5, duration: 0.02 }, t + 0.06);\n tl.to(\n char,\n { opacity: 1, duration: opts.duration * 0.3 },\n t + 0.08,\n );\n });\n },\n }),\n\n spotlight: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n tl.from(\n char,\n {\n opacity: 0,\n scale: 0.8,\n textShadow: '0 0 0px transparent',\n duration: opts.duration,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n textShadow:\n '0 0 20px rgba(255,255,255,0.8), 0 0 40px rgba(255,255,255,0.4)',\n duration: opts.duration * 0.2,\n },\n t,\n );\n });\n },\n }),\n\n outline: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n tl.from(\n char,\n {\n opacity: 0,\n textShadow: '0 0 0 transparent',\n duration: opts.duration,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n textShadow:\n '0 0 2px white, 0 0 4px white, 0 0 6px white',\n duration: opts.duration * 0.5,\n },\n t,\n );\n });\n },\n }),\n\n pulse: ({ chars, opts }) => ({\n special: (tl) => {\n gsap.set(chars, { opacity: 0, scale: 0.8 });\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.03);\n tl.to(\n char,\n {\n opacity: 1,\n scale: 1,\n duration: opts.duration * 0.3,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n scale: 1.15,\n duration: opts.duration * 0.15,\n ease: 'sine.inOut',\n yoyo: true,\n repeat: 1,\n },\n t + opts.duration * 0.3,\n );\n tl.to(\n char,\n {\n scale: 1,\n duration: opts.duration * 0.15,\n ease: 'power2.out',\n },\n t + opts.duration * 0.6,\n );\n });\n },\n }),\n\n breathe: ({ chars, opts }) => ({\n special: (tl) => {\n gsap.set(chars, { opacity: 0, scale: 0.9 });\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n tl.to(\n char,\n {\n opacity: 1,\n scale: 1,\n duration: opts.duration * 0.4,\n ease: 'sine.out',\n },\n t,\n );\n tl.to(\n char,\n {\n scale: 1.05,\n duration: opts.duration * 0.25,\n ease: 'sine.inOut',\n yoyo: true,\n repeat: 1,\n },\n t + opts.duration * 0.4,\n );\n tl.to(\n char,\n {\n scale: 1,\n duration: opts.duration * 0.35,\n ease: 'sine.inOut',\n },\n t + opts.duration * 0.9,\n );\n });\n },\n }),\n\n aurora: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const colors =\n effectColors.length > 0\n ? effectColors\n : ['#00d4ff', '#7b2cbf', '#2ec4b6', '#ff6b6b', '#4ecdc4'];\n gsap.set(chars, { opacity: 0, filter: 'blur(6px)' });\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n const c = colors[i % colors.length]!;\n tl.to(\n char,\n {\n opacity: 1,\n filter: 'blur(0px)',\n textShadow: `0 0 10px ${c}, 0 0 20px ${c}40`,\n duration: opts.duration * 0.5,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n textShadow: `0 0 5px ${c}, 0 0 10px ${c}20`,\n duration: opts.duration * 0.5,\n ease: 'sine.inOut',\n },\n t + opts.duration * 0.5,\n );\n });\n },\n }),\n\n // ── Special Effects ───────────────────────────────────────────────────────\n\n matrix: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const charset =\n 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*';\n const originals = chars.map((c) => c.textContent ?? '');\n const color1 = effectColors[0] ?? '#00ff41';\n const color2 = effectColors[1] ?? '#39ff14';\n\n gsap.set(chars, { opacity: 0 });\n chars.forEach((char, i) => {\n const scrambleCount = 6;\n const stepDuration = (opts.duration * 0.6) / scrambleCount;\n const startTime = i * resolveStagger(opts.stagger, 0.05);\n\n for (let s = 0; s < scrambleCount; s++) {\n tl.call(\n () => {\n char.textContent =\n charset[\n Math.floor(Math.random() * charset.length)\n ] ?? originals[i];\n char.style.opacity = '1';\n char.style.color = s % 2 === 0 ? color1 : color2;\n },\n [],\n startTime + s * stepDuration,\n );\n }\n\n tl.call(\n () => {\n char.textContent = originals[i];\n char.style.color = '';\n },\n [],\n startTime + scrambleCount * stepDuration,\n );\n });\n },\n }),\n\n fire: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const colors =\n effectColors.length > 0\n ? effectColors\n : ['#ff4500', '#ff6a00', '#ffae00', '#ffffff'];\n gsap.set(chars, {\n opacity: 0,\n y: 40,\n scaleY: 1.4,\n transformOrigin: 'bottom center',\n });\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.05);\n tl.to(\n char,\n {\n opacity: 1,\n y: 0,\n scaleY: 1,\n duration: opts.duration * 0.5,\n ease: 'power2.out',\n },\n t,\n );\n colors.forEach((color, ci) => {\n tl.to(\n char,\n { color, duration: opts.duration * 0.15, ease: 'none' },\n t + ci * opts.duration * 0.15,\n );\n });\n tl.to(\n char,\n { color: '', duration: opts.duration * 0.15 },\n t + colors.length * opts.duration * 0.15,\n );\n });\n },\n }),\n\n rainbow: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const defaultHues = [0, 30, 60, 120, 180, 240, 270, 310];\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n const color =\n effectColors.length > 0\n ? effectColors[i % effectColors.length]!\n : `hsl(${defaultHues[i % defaultHues.length]}, 100%, 60%)`;\n tl.from(\n char,\n {\n opacity: 0,\n y: -20,\n duration: opts.duration,\n ease: opts.ease,\n },\n t,\n );\n tl.to(char, { color, duration: opts.duration * 0.5 }, t);\n });\n },\n }),\n\n magnetic: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const a = Math.random() * Math.PI * 2;\n const d = gsap.utils.random(100, 250) as number;\n tl.from(\n char,\n {\n x: Math.cos(a) * d,\n y: Math.sin(a) * d,\n opacity: 0,\n scale: 0.2,\n duration: opts.duration,\n ease: 'power4.out',\n },\n i * resolveStagger(opts.stagger, 0.05),\n );\n });\n },\n }),\n\n particles: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n scale: 3,\n opacity: 0,\n rotation: gsap.utils.random(-180, 180) as number,\n x: gsap.utils.random(-60, 60) as number,\n y: gsap.utils.random(-60, 60) as number,\n filter: 'blur(8px)',\n duration: opts.duration,\n ease: 'expo.out',\n },\n i * resolveStagger(opts.stagger, 0.06),\n );\n });\n },\n }),\n\n dissolve: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n const steps = 5;\n gsap.set(char, { opacity: 0 });\n\n for (let s = 0; s < steps; s++) {\n tl.to(\n char,\n {\n opacity: s % 2 === 0 ? 0.6 : 0.1,\n duration: opts.duration / steps / 2,\n ease: 'none',\n },\n t + s * (opts.duration / steps / 2),\n );\n }\n\n tl.to(\n char,\n {\n opacity: 1,\n duration: opts.duration / steps,\n ease: 'power2.out',\n },\n t + steps * (opts.duration / steps / 2),\n );\n });\n },\n }),\n\n scramble: ({ chars, opts }) => ({\n special: (tl) => {\n const pool = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';\n const originals = chars.map((c) => c.textContent ?? '');\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.05);\n const iters = 8;\n const step = (opts.duration * 0.7) / iters;\n gsap.set(char, { opacity: 1 });\n\n for (let s = 0; s < iters; s++) {\n tl.call(\n () => {\n char.textContent =\n pool[Math.floor(Math.random() * pool.length)] ??\n originals[i];\n },\n [],\n t + s * step,\n );\n }\n\n tl.call(\n () => {\n char.textContent = originals[i];\n },\n [],\n t + iters * step,\n );\n });\n },\n }),\n\n zap: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const c1 = effectColors[0] ?? '#ffe600';\n const c2 = effectColors[1] ?? '#ff6a00';\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n tl.set(char, { opacity: 0 }, t)\n .to(\n char,\n {\n opacity: 1,\n color: '#fff',\n textShadow: `0 0 20px ${c1}, 0 0 40px ${c2}`,\n scale: 1.3,\n duration: 0.06,\n },\n t,\n )\n .to(\n char,\n {\n color: '',\n textShadow: 'none',\n scale: 1,\n duration: opts.duration * 0.6,\n ease: 'power3.out',\n },\n t + 0.06,\n );\n });\n },\n }),\n\n orbit: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const angle = (i / chars.length) * Math.PI * 2 - Math.PI / 2;\n tl.from(\n char,\n {\n x: Math.cos(angle) * 80,\n y: Math.sin(angle) * 80,\n opacity: 0,\n scale: 0,\n duration: opts.duration,\n ease: 'power3.out',\n },\n i * resolveStagger(opts.stagger),\n );\n });\n },\n }),\n\n vortex: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n rotation: (i / chars.length) * 720,\n scale: 0,\n opacity: 0,\n x: Math.sin((i / chars.length) * Math.PI * 4) * 60,\n y: Math.cos((i / chars.length) * Math.PI * 4) * 60,\n duration: opts.duration,\n ease: 'power3.out',\n },\n i * resolveStagger(opts.stagger, 0.03),\n );\n });\n },\n }),\n\n ripple: ({ chars, opts }) => ({\n special: (tl) => {\n const center = Math.floor(chars.length / 2);\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n y: -30,\n opacity: 0,\n scale: 0.5,\n duration: opts.duration,\n ease: 'elastic.out(1, 0.5)',\n },\n Math.abs(i - center) *\n resolveStagger(opts.stagger, 0.04) *\n 1.5,\n );\n });\n },\n }),\n\n piano: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.05);\n tl.from(\n char,\n {\n y: -80,\n scaleY: 1.5,\n opacity: 0,\n duration: opts.duration * 0.4,\n ease: 'power2.in',\n },\n t,\n )\n .to(\n char,\n { y: 5, scaleY: 0.9, duration: opts.duration * 0.1 },\n t + opts.duration * 0.4,\n )\n .to(\n char,\n {\n y: 0,\n scaleY: 1,\n duration: opts.duration * 0.5,\n ease: 'bounce.out',\n },\n t + opts.duration * 0.5,\n );\n });\n },\n }),\n\n domino: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n rotationZ: -90,\n transformOrigin: 'bottom center',\n opacity: 0,\n duration: opts.duration * 0.6,\n ease: 'power2.out',\n },\n i * resolveStagger(opts.stagger, 0.07),\n );\n });\n },\n }),\n\n pendulum: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n rotationZ: (i % 2 === 0 ? 1 : -1) * 60,\n transformOrigin: 'top center',\n opacity: 0,\n duration: opts.duration,\n ease: 'elastic.out(0.6, 0.3)',\n },\n i * resolveStagger(opts.stagger, 0.05),\n );\n });\n },\n }),\n\n shatter: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n skewX: gsap.utils.random(-40, 40) as number,\n skewY: gsap.utils.random(-20, 20) as number,\n x: gsap.utils.random(-50, 50) as number,\n scale: gsap.utils.random(0.1, 2) as number,\n opacity: 0,\n rotation: gsap.utils.random(-45, 45) as number,\n duration: opts.duration,\n ease: 'power4.out',\n },\n i * resolveStagger(opts.stagger),\n );\n });\n },\n }),\n\n smoke: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n y: gsap.utils.random(20, 60) as number,\n x: gsap.utils.random(-15, 15) as number,\n opacity: 0,\n filter: 'blur(8px)',\n scale: gsap.utils.random(0.8, 1.4) as number,\n duration: opts.duration,\n ease: 'power1.out',\n },\n i * resolveStagger(opts.stagger, 0.05),\n );\n });\n },\n }),\n\n thunder: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.05);\n tl.set(char, { opacity: 0 }, t)\n .to(char, { opacity: 1, y: -100, duration: 0.01 }, t)\n .to(\n char,\n {\n y: 0,\n duration: opts.duration * 0.3,\n ease: 'power4.in',\n },\n t + 0.01,\n )\n .to(\n char,\n { textShadow: '0 0 20px #ffe600', duration: 0.05 },\n t + opts.duration * 0.3,\n )\n .to(\n char,\n {\n textShadow: 'none',\n duration: opts.duration * 0.3,\n ease: 'power2.out',\n },\n t + opts.duration * 0.35,\n )\n .to(\n char,\n { y: -5, duration: 0.06 },\n t + opts.duration * 0.3,\n )\n .to(\n char,\n { y: 0, duration: 0.1, ease: 'bounce.out' },\n t + opts.duration * 0.36,\n );\n });\n },\n }),\n\n crystallize: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.06);\n tl.from(\n char,\n {\n opacity: 0,\n scale: 1.8,\n skewX: gsap.utils.random(-30, 30) as number,\n skewY: gsap.utils.random(-15, 15) as number,\n filter: 'blur(4px) brightness(2)',\n color: '#a0e4ff',\n duration: opts.duration,\n ease: 'power3.out',\n },\n t,\n );\n tl.to(\n char,\n {\n color: '',\n filter: 'none',\n duration: opts.duration * 0.3,\n },\n t + opts.duration * 0.7,\n );\n });\n },\n }),\n\n warp: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n tl.from(\n char,\n {\n scaleX: 8,\n opacity: 0,\n duration: opts.duration * 0.4,\n ease: 'power3.out',\n },\n t,\n )\n .to(\n char,\n { scaleX: 0.8, duration: opts.duration * 0.15 },\n t + opts.duration * 0.4,\n )\n .to(\n char,\n {\n scaleX: 1,\n duration: opts.duration * 0.45,\n ease: 'elastic.out(1, 0.6)',\n },\n t + opts.duration * 0.55,\n );\n });\n },\n }),\n\n cinema: ({ el, opts }) => ({\n special: (tl) => {\n gsap.set(el, {\n opacity: 0,\n filter: 'sepia(1) contrast(2) brightness(0.5)',\n });\n\n for (let f = 0; f < 8; f++) {\n tl.to(el, {\n opacity:\n f % 2 === 0\n ? (gsap.utils.random(0.2, 0.7) as number)\n : 0,\n duration: opts.duration / 16,\n ease: 'none',\n });\n }\n\n tl.to(el, { opacity: 1, duration: opts.duration * 0.3 }).to(el, {\n filter: 'sepia(0) contrast(1) brightness(1)',\n duration: opts.duration * 0.5,\n ease: 'power2.inOut',\n });\n },\n }),\n\n gravity: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n y: -100,\n rotation: -30,\n opacity: 0,\n duration: opts.duration * 0.7,\n ease: 'bounce.out',\n },\n i * resolveStagger(opts.stagger),\n );\n });\n },\n }),\n\n levitate: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n y: 50,\n opacity: 0,\n duration: opts.duration,\n ease: 'power3.out',\n },\n i * resolveStagger(opts.stagger, 0.05),\n );\n });\n },\n }),\n\n twinkle: ({ chars, opts }) => ({\n special: (tl) => {\n gsap.set(chars, { opacity: 0, scale: 0.5 });\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.03);\n tl.to(\n char,\n {\n opacity: 1,\n scale: 1,\n duration: opts.duration * 0.3,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n opacity: 0.3,\n scale: 0.8,\n duration: opts.duration * 0.15,\n ease: 'sine.inOut',\n yoyo: true,\n repeat: 1,\n },\n t + opts.duration * 0.3,\n );\n tl.to(\n char,\n { opacity: 1, scale: 1, duration: opts.duration * 0.2 },\n t + opts.duration * 0.6,\n );\n });\n },\n }),\n\n shimmerFade: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger);\n tl.from(\n char,\n {\n opacity: 0,\n duration: opts.duration * 0.5,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n opacity: 0.7,\n duration: opts.duration * 0.2,\n ease: 'sine.inOut',\n yoyo: true,\n repeat: 1,\n },\n t + opts.duration * 0.5,\n );\n tl.to(\n char,\n { opacity: 1, duration: opts.duration * 0.3 },\n t + opts.duration * 0.9,\n );\n });\n },\n }),\n\n fold: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n scaleY: 0,\n opacity: 0,\n transformOrigin: 'bottom center',\n duration: opts.duration,\n ease: 'back.out(1.5)',\n },\n i * resolveStagger(opts.stagger, 0.05),\n );\n });\n },\n }),\n\n cascade: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const startY = gsap.utils.random(-120, -40) as number;\n const t = i * resolveStagger(opts.stagger, 0.04);\n tl.from(\n char,\n {\n y: startY,\n opacity: 0,\n duration: opts.duration * 0.6,\n ease: 'power3.in',\n },\n t,\n ).to(\n char,\n { y: 0, duration: opts.duration * 0.4, ease: 'bounce.out' },\n t + opts.duration * 0.6,\n );\n });\n },\n }),\n\n pinball: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.06);\n const dir = i % 2 === 0 ? 1 : -1;\n gsap.set(char, { opacity: 0 });\n tl.to(char, { opacity: 1, duration: 0.01 }, t)\n .from(\n char,\n {\n x: dir * 80,\n duration: opts.duration * 0.25,\n ease: 'power2.in',\n },\n t,\n )\n .to(\n char,\n {\n x: -dir * 30,\n duration: opts.duration * 0.2,\n ease: 'power1.out',\n },\n t + opts.duration * 0.25,\n )\n .to(\n char,\n {\n x: dir * 10,\n duration: opts.duration * 0.15,\n ease: 'power1.in',\n },\n t + opts.duration * 0.45,\n )\n .to(\n char,\n {\n x: 0,\n duration: opts.duration * 0.2,\n ease: 'bounce.out',\n },\n t + opts.duration * 0.6,\n );\n });\n },\n }),\n\n neonFlicker: ({ chars, opts, effectColors }) => ({\n special: (tl) => {\n const glowColor = effectColors[0] ?? '#39ff14';\n const flickerOffsets = [0, 0.05, 0.1, 0.16, 0.22, 0.3];\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.05);\n gsap.set(char, { opacity: 0 });\n flickerOffsets.forEach((offset, fi) => {\n tl.to(\n char,\n {\n opacity: fi % 2 === 0 ? 1 : 0.15,\n color: glowColor,\n textShadow:\n fi % 2 === 0\n ? `0 0 6px ${glowColor}, 0 0 20px ${glowColor}`\n : 'none',\n duration: 0.04,\n ease: 'none',\n },\n t + offset,\n );\n });\n tl.to(\n char,\n {\n opacity: 1,\n color: '',\n textShadow: 'none',\n duration: opts.duration * 0.4,\n ease: 'power2.out',\n },\n t + 0.34,\n );\n });\n },\n }),\n\n rise: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const sway = gsap.utils.random(-12, 12) as number;\n const t = i * resolveStagger(opts.stagger, 0.05);\n tl.from(\n char,\n {\n y: 60,\n x: sway,\n opacity: 0,\n scale: 0.7,\n filter: 'blur(4px)',\n duration: opts.duration,\n ease: 'power2.out',\n },\n t,\n );\n tl.to(\n char,\n {\n x: 0,\n filter: 'blur(0px)',\n duration: opts.duration * 0.4,\n ease: 'sine.out',\n },\n t + opts.duration * 0.6,\n );\n });\n },\n }),\n\n unfurl: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n tl.from(\n char,\n {\n rotationY: -90,\n opacity: 0,\n transformPerspective: 600,\n transformOrigin: 'left center',\n duration: opts.duration,\n ease: 'back.out(1.4)',\n },\n i * resolveStagger(opts.stagger, 0.05),\n );\n });\n },\n }),\n\n stampIn: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n const t = i * resolveStagger(opts.stagger, 0.12);\n tl.from(\n char,\n {\n y: -120,\n scaleY: 1.4,\n opacity: 0,\n transformOrigin: 'top center',\n duration: opts.duration * 0.35,\n ease: 'power4.in',\n },\n t,\n )\n .to(\n char,\n {\n scaleY: 0.6,\n scaleX: 1.3,\n duration: opts.duration * 0.1,\n ease: 'power1.out',\n },\n t + opts.duration * 0.35,\n )\n .to(\n char,\n {\n scaleY: 1.1,\n scaleX: 0.95,\n duration: opts.duration * 0.12,\n ease: 'power1.inOut',\n },\n t + opts.duration * 0.45,\n )\n .to(\n char,\n {\n scaleY: 1,\n scaleX: 1,\n duration: opts.duration * 0.31,\n ease: 'elastic.out(1, 0.5)',\n },\n t + opts.duration * 0.57,\n );\n });\n },\n }),\n\n blinds: ({ chars, opts }) => ({\n special: (tl) => {\n chars.forEach((char, i) => {\n gsap.set(char, {\n transformOrigin: 'top center',\n scaleY: 0,\n opacity: 1,\n });\n tl.to(\n char,\n {\n scaleY: 1,\n duration: opts.duration,\n ease: 'back.out(1.6)',\n },\n i * resolveStagger(opts.stagger, 0.1),\n );\n });\n },\n }),\n};\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst TextAnimator = forwardRef(\n function TextAnimator(\n {\n text,\n children,\n animation = 'fadeInUp',\n trigger = 'scrollTrigger',\n splitBy = 'chars',\n tag: Tag = 'span',\n duration = 0.8,\n delay = 0,\n stagger = 0.04,\n ease = 'power3.out',\n repeat = 0,\n yoyo = false,\n scrollTrigger: scrollTriggerOpts,\n color,\n fontSize,\n className = '',\n style = {},\n effectColor,\n onComplete,\n onStart,\n onRepeat,\n },\n ref,\n ) {\n const elRef = useRef(null);\n const tlRef = useRef(null);\n const stRef = useRef(null);\n\n // ── Stable callback refs ────────────────────────────────────────────\n const onCompleteRef = useRef(onComplete);\n const onStartRef = useRef(onStart);\n const onRepeatRef = useRef(onRepeat);\n\n useEffect(() => {\n onCompleteRef.current = onComplete;\n }, [onComplete]);\n useEffect(() => {\n onStartRef.current = onStart;\n }, [onStart]);\n useEffect(() => {\n onRepeatRef.current = onRepeat;\n }, [onRepeat]);\n\n const content: string = children ?? text ?? 'Animate Me';\n\n const resolvedOpts: ResolvedAnimOpts = useMemo(\n () => ({ duration, delay, stagger, ease, repeat, yoyo }),\n [duration, delay, stagger, ease, repeat, yoyo],\n );\n\n // ── Build timeline ──────────────────────────────────────────────────\n const buildTimeline = useCallback((): gsap.core.Timeline | null => {\n const el = elRef.current;\n\n if (!el) {\n return null;\n }\n\n const def = ANIMATIONS[animation] ?? ANIMATIONS['fadeIn']!;\n el.textContent = content;\n\n let chars: HTMLElement[] = [];\n let words: HTMLElement[] = [];\n\n if (splitBy === 'chars') {\n chars = splitChars(el);\n } else if (splitBy === 'words') {\n words = splitWords(el);\n chars = words;\n } else if (splitBy === 'lines') {\n words = splitLines(el);\n chars = words;\n }\n\n const rawColors = Array.isArray(effectColor)\n ? effectColor\n : effectColor\n ? [effectColor]\n : [];\n const effectColors =\n rawColors.length === 1\n ? [rawColors[0]!, rawColors[0]!]\n : rawColors;\n\n const ctx: AnimationContext = {\n chars,\n words,\n el,\n opts: resolvedOpts,\n effectColors,\n };\n const config = def(ctx);\n\n const tl = gsap.timeline({\n paused: true,\n defaults: {\n duration: resolvedOpts.duration,\n ease: resolvedOpts.ease,\n },\n onStart: () => onStartRef.current?.(),\n onComplete: () => onCompleteRef.current?.(),\n onRepeat: () => onRepeatRef.current?.(),\n delay: resolvedOpts.delay,\n repeat: resolvedOpts.repeat,\n yoyo: resolvedOpts.yoyo,\n });\n\n if (config.special) {\n config.special(tl);\n } else if (config.targets && config.from && config.to) {\n tl.fromTo(config.targets, config.from, {\n ...config.to,\n stagger: resolvedOpts.stagger,\n ease: config.overrideEase\n ? config.to.ease\n : resolvedOpts.ease,\n });\n }\n\n return tl;\n }, [animation, content, splitBy, effectColor, resolvedOpts]);\n\n // ── GSAP context ────────────────────────────────────────────────────\n useGSAP(\n () => {\n const el = elRef.current;\n\n if (!el) {\n return;\n }\n\n stRef.current?.kill();\n tlRef.current?.kill();\n\n const tl = buildTimeline();\n tlRef.current = tl;\n\n if (!tl) {\n return;\n }\n\n if (trigger === 'scrollTrigger') {\n stRef.current = ScrollTrigger.create({\n trigger: el,\n start: scrollTriggerOpts?.start ?? 'top 80%',\n end: scrollTriggerOpts?.end ?? 'bottom 20%',\n scrub: scrollTriggerOpts?.scrub ?? false,\n markers: scrollTriggerOpts?.markers ?? false,\n pin: scrollTriggerOpts?.pin ?? false,\n toggleActions:\n scrollTriggerOpts?.toggleActions ??\n 'play none none reverse',\n animation: tl,\n });\n }\n },\n {\n scope: elRef,\n dependencies: [\n animation,\n content,\n trigger,\n splitBy,\n resolvedOpts,\n scrollTriggerOpts,\n buildTimeline,\n ],\n },\n );\n\n // ── Trigger handlers ────────────────────────────────────────────────\n const handleClick = useCallback(() => {\n if (trigger !== 'onClick') {\n return;\n }\n\n tlRef.current?.restart();\n }, [trigger]);\n\n const handleMouseEnter = useCallback(() => {\n if (trigger !== 'onHover') {\n return;\n }\n\n tlRef.current?.play();\n }, [trigger]);\n\n const handleMouseLeave = useCallback(() => {\n if (trigger !== 'onHover') {\n return;\n }\n\n tlRef.current?.reverse();\n }, [trigger]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent) => {\n if (\n trigger === 'onClick' &&\n (e.key === 'Enter' || e.key === ' ')\n ) {\n handleClick();\n }\n },\n [trigger, handleClick],\n );\n\n // ── Ref API ─────────────────────────────────────────────────────────\n useImperativeHandle(ref, () => ({\n play: () => tlRef.current?.play(),\n pause: () => tlRef.current?.pause(),\n reverse: () => tlRef.current?.reverse(),\n restart: () => tlRef.current?.restart(),\n seek: (t: number) => tlRef.current?.seek(t),\n kill: () => {\n tlRef.current?.kill();\n stRef.current?.kill();\n },\n timeline: () => tlRef.current,\n isPlaying: () => tlRef.current?.isActive() ?? false,\n progress: () => tlRef.current?.progress() ?? 0,\n }));\n\n // ── Render ──────────────────────────────────────────────────────────\n const tagStyle: React.CSSProperties = {\n display: 'inline-block',\n cursor: trigger === 'onClick' ? 'pointer' : 'default',\n ...(color ? { color } : {}),\n ...(fontSize ? { fontSize } : {}),\n ...style,\n };\n\n return (\n }\n className={`text-animator ${className}`.trim()}\n style={tagStyle}\n onClick={handleClick}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n aria-label={content}\n role={trigger === 'onClick' ? 'button' : undefined}\n tabIndex={trigger === 'onClick' ? 0 : undefined}\n onKeyDown={trigger === 'onClick' ? handleKeyDown : undefined}\n >\n {content}\n \n );\n },\n);\n\nexport default TextAnimator;\nexport { ANIMATIONS };\n", "type": "registry:ui" } ], "meta": { "category": "animations", "version": "1.0.0" }, "type": "registry:ui" }