{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-animation",
  "title": "Text Animation",
  "author": "AlgorizeTech",
  "description": "Character and word-level text motion driven by Motion. Use it for hero headlines that need finer control than a whole-block fade.",
  "dependencies": [],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/TextAnimationBlock.tsx",
      "content": "\"use client\";\n\nimport React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { springLinear } from \"./motion-curves\";\n\nexport interface TextAnimationProps extends React.HTMLAttributes<HTMLDivElement> {\n  text: string;\n  variant?:\n    | \"typewriter\"\n    | \"blur-reveal\"\n    | \"wave\"\n    | \"pop-in\"\n    | \"flip\"\n    | \"spin-in\"\n    | \"blur-in-up\";\n  duration?: number;\n  delay?: number;\n  as?: React.ElementType;\n  /**\n   * Swap what a word renders as, keeping its entrance. Word-level variants\n   * only (`pop-in`, `flip`, `blur-in-up`): return the word itself with some\n   * markup, e.g. a coloured span or a word that later changes.\n   */\n  renderWord?: (word: string, index: number) => React.ReactNode;\n}\n\ntype Variant = NonNullable<TextAnimationProps[\"variant\"]>;\ntype TokenVariant = Exclude<Variant, \"blur-reveal\">;\n\n// ==========================================\n// ENTRANCES\n// ==========================================\n// Plain CSS keyframes, no animation library. The springs are the same\n// stiffness / damping curves as before, solved once into CSS `linear()`\n// easings (the analytic spring, sampled at 120 points a second). A spring\n// drives every value of its token — opacity included, where the overshoot\n// simply clamps at 1.\n\ninterface Entrance {\n  from: string;\n  to: string;\n  /** ms */\n  duration: number;\n  easing: string;\n}\n\nconst spring = (stiffness: number, damping: number) =>\n  springLinear({ stiffness, damping }, 120);\n\nconst TOKEN_ENTRANCES: Record<TokenVariant, Entrance> = {\n  typewriter: {\n    from: \"opacity: 0; transform: translateY(10px);\",\n    to: \"opacity: 1; transform: translateY(0px);\",\n    ...spring(100, 12),\n  },\n  wave: {\n    from: \"opacity: 0; transform: translateY(15px);\",\n    to: \"opacity: 1; transform: translateY(0px);\",\n    ...spring(200, 10),\n  },\n  \"pop-in\": {\n    from: \"opacity: 0; transform: translateY(10px) scale(0.5);\",\n    to: \"opacity: 1; transform: translateY(0px) scale(1);\",\n    ...spring(200, 12),\n  },\n  flip: {\n    from: \"opacity: 0; transform: translateY(20px) rotateX(-90deg);\",\n    to: \"opacity: 1; transform: translateY(0px) rotateX(0deg);\",\n    ...spring(200, 15),\n  },\n  \"spin-in\": {\n    from: \"opacity: 0; transform: scale(0) rotate(-180deg);\",\n    to: \"opacity: 1; transform: scale(1) rotate(0deg);\",\n    ...spring(200, 12),\n  },\n  \"blur-in-up\": {\n    from: \"opacity: 0; transform: translateY(20px); filter: blur(8px);\",\n    to: \"opacity: 1; transform: translateY(0px); filter: blur(0px);\",\n    duration: 600,\n    easing: \"cubic-bezier(0, 0, 0.58, 1)\",\n  },\n};\n\n/** Seconds between neighbouring tokens (typewriter's depends on `duration`). */\nconst STAGGER: Record<Exclude<TokenVariant, \"typewriter\">, number> = {\n  wave: 0.05,\n  \"pop-in\": 0.1,\n  flip: 0.1,\n  \"spin-in\": 0.05,\n  \"blur-in-up\": 0.1,\n};\n\n/**\n * Everything waits paused on its first frame until the text scrolls into\n * view, then plays once. `backwards` fill hands over to the resting styles\n * when an entrance ends. Selectors are data attributes, so nothing is added\n * to — or merged away from — the classes you pass.\n */\nconst PAUSED = `\n[data-text-animation-state=\"hidden\"],\n[data-text-animation-state=\"hidden\"] [data-text-animation-token] { animation-play-state: paused; }`;\n\nfunction variantCss(variant: Variant) {\n  if (variant === \"blur-reveal\") {\n    return `\n@keyframes text-animation-blur-reveal {\n  from { opacity: 0; filter: blur(10px); transform: scale(0.95); }\n  to { opacity: 1; filter: blur(0px); transform: scale(1); }\n}\n[data-text-animation=\"blur-reveal\"] { animation: text-animation-blur-reveal 0.5s cubic-bezier(0, 0, 0.58, 1) backwards; }${PAUSED}`;\n  }\n  const entrance = TOKEN_ENTRANCES[variant];\n  if (!entrance) return \"\";\n  const { from, to, duration, easing } = entrance;\n  return `\n@keyframes text-animation-fade { from { opacity: 0; } to { opacity: 1; } }\n@keyframes text-animation-${variant} { from { ${from} } to { ${to} } }\n[data-text-animation=\"${variant}\"] { animation: text-animation-fade 0.3s cubic-bezier(0.25, 0.1, 0.35, 1) backwards; }\n[data-text-animation=\"${variant}\"] [data-text-animation-token] { animation: text-animation-${variant} ${duration}ms ${easing} backwards; }${PAUSED}`;\n}\n\n// Built once per variant, on first use, and shared by every instance.\nconst cssCache: Partial<Record<Variant, string>> = {};\nconst cssFor = (variant: Variant) => (cssCache[variant] ??= variantCss(variant));\n\n/** `delay + index * stagger` seconds, as a CSS time. */\nconst delayAt = (delay: number, index: number, stagger: number) =>\n  `${Math.round((delay + index * stagger) * 100000) / 100}ms`;\n\nexport const TextAnimation = React.forwardRef<\n  HTMLDivElement,\n  TextAnimationProps\n>(\n  (\n    {\n      text,\n      variant = \"blur-reveal\",\n      duration = 0.5,\n      delay = 0,\n      as: Component = \"p\",\n      renderWord,\n      className,\n      ...props\n    },\n    ref\n  ) => {\n    const outerRef = React.useRef<HTMLDivElement | null>(null);\n    const innerRef = React.useRef<HTMLElement | null>(null);\n    const [inView, setInView] = React.useState(false);\n\n    const setOuterRef = React.useCallback(\n      (node: HTMLDivElement | null) => {\n        outerRef.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      },\n      [ref],\n    );\n\n    // Plays once, the first time any part of the text enters the viewport\n    // (threshold 0). A custom `as` that doesn't forward its ref falls back\n    // to observing the wrapper.\n    React.useEffect(() => {\n      if (inView) return;\n      const el = innerRef.current ?? outerRef.current;\n      if (!el || typeof IntersectionObserver === \"undefined\") {\n        setInView(true);\n        return;\n      }\n      const io = new IntersectionObserver((entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) {\n          setInView(true);\n          io.disconnect();\n        }\n      });\n      io.observe(el);\n      return () => io.disconnect();\n    }, [inView]);\n\n    const state = inView ? \"visible\" : \"hidden\";\n\n    const renderAnimation = () => {\n      switch (variant) {\n        case \"typewriter\": {\n          const stagger = duration / text.length;\n          let charIndex = 0;\n\n          return (\n            <Component\n              ref={innerRef}\n              data-text-animation={variant}\n              data-text-animation-state={state}\n              className={cn(\"flex flex-wrap gap-[0.25em]\", className)}\n            >\n              {text.split(\" \").map((word, index) => (\n                <span key={index} className=\"inline-flex\">\n                  {word.split(\"\").map((char, i) => (\n                    <span\n                      key={i}\n                      data-text-animation-token=\"\"\n                      className=\"inline-block\"\n                      style={{ animationDelay: delayAt(delay, charIndex++, stagger) }}\n                    >\n                      {char === \" \" ? \"\\u00A0\" : char}\n                    </span>\n                  ))}\n                </span>\n              ))}\n            </Component>\n          );\n        }\n\n\n\n        case \"blur-reveal\": {\n          return (\n            <Component\n              ref={innerRef}\n              data-text-animation={variant}\n              data-text-animation-state={state}\n              className={className}\n              style={{ animationDuration: `${duration}s`, animationDelay: `${delay}s` }}\n            >\n              {text}\n            </Component>\n          );\n        }\n\n\n\n        case \"wave\":\n        case \"spin-in\": {\n          const stagger = STAGGER[variant];\n          let charIndex = 0;\n\n          return (\n            <Component\n              ref={innerRef}\n              data-text-animation={variant}\n              data-text-animation-state={state}\n              className={cn(\"flex flex-wrap gap-[0.25em]\", className)}\n            >\n              {text.split(\" \").map((word, index) => (\n                <span key={index} className=\"inline-flex\">\n                  {word.split(\"\").map((char, i) => (\n                    <span\n                      key={i}\n                      data-text-animation-token=\"\"\n                      className=\"inline-block\"\n                      style={{ animationDelay: delayAt(delay, charIndex++, stagger) }}\n                    >\n                      {char}\n                    </span>\n                  ))}\n                </span>\n              ))}\n            </Component>\n          );\n        }\n\n        case \"pop-in\":\n        case \"flip\":\n        case \"blur-in-up\": {\n          const stagger = STAGGER[variant];\n\n          return (\n            <Component\n              ref={innerRef}\n              data-text-animation={variant}\n              data-text-animation-state={state}\n              className={cn(\n                \"flex flex-wrap gap-[0.25em]\",\n                variant === \"flip\" && \"perspective-[1000px]\",\n                className,\n              )}\n            >\n              {text.split(\" \").map((word, index) => (\n                <span\n                  key={index}\n                  data-text-animation-token=\"\"\n                  className={cn(\"inline-block\", variant === \"flip\" && \"origin-bottom\")}\n                  style={{ animationDelay: delayAt(delay, index, stagger) }}\n                >\n                  {renderWord ? renderWord(word, index) : word}\n                </span>\n              ))}\n            </Component>\n          );\n        }\n\n        default:\n          return <Component className={className}>{text}</Component>;\n      }\n    };\n\n    return (\n      <div ref={setOuterRef} {...props}>\n        {renderAnimation()}\n        <style>{cssFor(variant)}</style>\n      </div>\n    );\n  }\n);\n\nTextAnimation.displayName = \"TextAnimation\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/TextAnimationBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/motion-curves.ts",
      "content": "/**\n * Motion curves with no animation library behind them.\n *\n * The components here ship as CSS, GSAP or Motion builds that must move\n * identically, so the curves are computed once, from the same numbers, and\n * handed to whichever engine runs them:\n *\n *   - `cubicBezier(x1, y1, x2, y2)` → an easing function (GSAP accepts any\n *     `(progress) => value` as `ease`), exact for Motion's `ease: [..]` arrays\n *     and for CSS `cubic-bezier()`.\n *   - `spring({ stiffness, damping, mass })` → the analytic solution Motion\n *     Motion uses, as an easing function plus the time it takes to settle, or\n *     as a CSS `linear()` string for a fixed start and end.\n *   - `createSpringFollower` → a value that chases a moving target through the\n *     same spring every frame, like Motion's `useSpring`, for pointer-driven\n *     motion that no fixed curve can describe.\n */\n\n// ---------------------------------------------------------------------------\n// Cubic bezier\n// ---------------------------------------------------------------------------\n\n/**\n * The CSS / Motion cubic-bezier as a function of progress. Newton-Raphson\n * with a bisection fallback, the same solver browsers use.\n */\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number) {\n  if (x1 === y1 && x2 === y2) return (t: number) => t;\n\n  const a = (p1: number, p2: number) => 1 - 3 * p2 + 3 * p1;\n  const b = (p1: number, p2: number) => 3 * p2 - 6 * p1;\n  const c = (p1: number) => 3 * p1;\n  const at = (t: number, p1: number, p2: number) =>\n    ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;\n  const slope = (t: number, p1: number, p2: number) =>\n    3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);\n\n  const solveX = (x: number) => {\n    let t = x;\n    for (let i = 0; i < 8; i++) {\n      const s = slope(t, x1, x2);\n      if (Math.abs(s) < 1e-6) break;\n      const err = at(t, x1, x2) - x;\n      if (Math.abs(err) < 1e-7) return t;\n      t -= err / s;\n    }\n    let lo = 0;\n    let hi = 1;\n    t = x;\n    for (let i = 0; i < 30 && hi - lo > 1e-7; i++) {\n      const value = at(t, x1, x2);\n      if (value < x) lo = t;\n      else hi = t;\n      t = (lo + hi) / 2;\n    }\n    return t;\n  };\n\n  return (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : at(solveX(x), y1, y2));\n}\n\n/** Motion's named eases, as the cubic-beziers they are. */\nexport const MOTION_EASE = {\n  linear: [0, 0, 1, 1],\n  easeIn: [0.42, 0, 1, 1],\n  easeOut: [0, 0, 0.58, 1],\n  easeInOut: [0.42, 0, 0.58, 1],\n  circIn: [0.55, 0, 1, 0.45],\n  circOut: [0.55, 0, 0.45, 1],\n  circInOut: [0.85, 0, 0.15, 1],\n  backIn: [0.31, 0.01, 0.66, -0.59],\n  backOut: [0.33, 1.53, 0.69, 0.99],\n  anticipate: [0.66, -0.56, 0.34, 1.35],\n} as const;\n\n/**\n * Motion's default layout transition (`layout` / `layoutId`):\n * `{ duration: 0.45, ease: [0.4, 0, 0.1, 1] }`.\n */\nexport const LAYOUT_TRANSITION = { duration: 0.45, ease: [0.4, 0, 0.1, 1] as const };\n\n// ---------------------------------------------------------------------------\n// Spring\n// ---------------------------------------------------------------------------\n\nexport interface SpringOptions {\n  /** Motion default 100. */\n  stiffness?: number;\n  /** Motion default 10. */\n  damping?: number;\n  /** Motion default 1. */\n  mass?: number;\n  /** Starting velocity, in units of the whole distance per second. */\n  velocity?: number;\n  /** How close to the end counts as settled, as a fraction of the distance. */\n  restDelta?: number;\n}\n\n/**\n * Normalised position (0 → 1) of a spring released from 0 towards 1, at time\n * `t` seconds. The closed-form solution Motion's `spring()` generator\n * uses for the under-, critically and over-damped cases.\n */\nexport function springAt(options: SpringOptions = {}) {\n  const { stiffness = 100, damping = 10, mass = 1, velocity = 0 } = options;\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n  const omega0 = Math.sqrt(stiffness / mass);\n  // Motion's convention: distance still to cover, velocity toward it negated.\n  const delta = 1;\n  const v0 = -velocity;\n\n  if (zeta < 1) {\n    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);\n    return (t: number) => {\n      const envelope = Math.exp(-zeta * omega0 * t);\n      return (\n        1 -\n        envelope *\n          (((v0 + zeta * omega0 * delta) / omegaD) * Math.sin(omegaD * t) +\n            delta * Math.cos(omegaD * t))\n      );\n    };\n  }\n  if (zeta === 1) {\n    return (t: number) => 1 - Math.exp(-omega0 * t) * (delta + (v0 + omega0 * delta) * t);\n  }\n  const omegaD = omega0 * Math.sqrt(zeta * zeta - 1);\n  return (t: number) => {\n    const envelope = Math.exp(-zeta * omega0 * t);\n    const freq = Math.min(omegaD * t, 300);\n    return (\n      1 -\n      (envelope *\n        ((v0 + zeta * omega0 * delta) * Math.sinh(freq) + omegaD * delta * Math.cosh(freq))) /\n        omegaD\n    );\n  };\n}\n\n/** Seconds until the spring stays within `restDelta` of its end. */\nexport function springDuration(options: SpringOptions = {}) {\n  const at = springAt(options);\n  const restDelta = options.restDelta ?? 0.001;\n  const step = 1 / 240;\n  let lastOutside = 0;\n  for (let t = 0; t <= 10; t += step) {\n    if (Math.abs(1 - at(t)) > restDelta) lastOutside = t;\n    else if (t - lastOutside > 0.1) break;\n  }\n  return Math.max(lastOutside + step, step);\n}\n\n/**\n * A spring as a duration and an easing function over it — what GSAP takes:\n *\n *   const { duration, ease } = springEase({ stiffness: 300, damping: 20 });\n *   gsap.to(el, { y: 0, duration, ease });\n *\n * Exact for a fixed start and end; a spring that has to react mid-flight to a\n * new target needs `createSpringFollower` instead.\n */\nexport function springEase(options: SpringOptions = {}) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  return {\n    duration,\n    ease: (progress: number) => (progress >= 1 ? 1 : at(progress * duration)),\n  };\n}\n\n/**\n * A spring as a CSS `linear()` easing and its duration in ms, for\n * `transition` / `animation` with a fixed start and end.\n *\n *   const { easing, duration } = springLinear({ stiffness: 400, damping: 30 });\n *   el.style.transition = `transform ${duration}ms ${easing}`;\n */\nexport function springLinear(options: SpringOptions = {}, pointsPerSecond = 60) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  const count = Math.max(Math.ceil(duration * pointsPerSecond), 8);\n  const points: string[] = [];\n  for (let i = 0; i <= count; i++) {\n    const t = (i / count) * duration;\n    points.push(String(Math.round((i === count ? 1 : at(t)) * 10000) / 10000));\n  }\n  return { easing: `linear(${points.join(\", \")})`, duration: Math.round(duration * 1000) };\n}\n\n// ---------------------------------------------------------------------------\n// Spring follower (useSpring equivalent)\n// ---------------------------------------------------------------------------\n\n/** Something that calls back once per frame with the time since the last, in ms. */\nexport interface FrameTicker {\n  add: (callback: (deltaMs: number) => void) => () => void;\n}\n\n/** requestAnimationFrame as a ticker — the library-free default. */\nexport const rafTicker: FrameTicker = {\n  add(callback) {\n    let last = performance.now();\n    let frame = requestAnimationFrame(function tick(now) {\n      callback(now - last);\n      last = now;\n      frame = requestAnimationFrame(tick);\n    });\n    return () => cancelAnimationFrame(frame);\n  },\n};\n\nexport interface SpringFollower {\n  /** Chase a new target from wherever the value is now, keeping its velocity. */\n  set: (target: number) => void;\n  /** Move to a value instantly, with no motion. */\n  jump: (value: number) => void;\n  get: () => number;\n  destroy: () => void;\n}\n\n/**\n * A value that follows its target through a spring, integrating the same\n * equation of motion Motion's `useSpring` solves — so a pointer-driven spring\n * reads the same in every build. Runs only while moving; `onUpdate` receives\n * each new value.\n */\nexport function createSpringFollower(\n  initial: number,\n  onUpdate: (value: number) => void,\n  options: SpringOptions & { restSpeed?: number; ticker?: FrameTicker } = {},\n): SpringFollower {\n  const { stiffness = 100, damping = 10, mass = 1, ticker = rafTicker } = options;\n  const restDelta = options.restDelta ?? 0.01;\n  const restSpeed = options.restSpeed ?? 0.01;\n  let value = initial;\n  let target = initial;\n  let velocity = 0;\n  let stop: (() => void) | null = null;\n\n  const step = (deltaMs: number) => {\n    // Fixed 1ms sub-steps keep the integration stable for stiff springs and\n    // independent of the frame rate.\n    const steps = Math.min(Math.max(Math.round(deltaMs), 1), 250);\n    for (let i = 0; i < steps; i++) {\n      const force = -stiffness * (value - target) - damping * velocity;\n      velocity += (force / mass) * 0.001;\n      value += velocity * 0.001;\n    }\n    if (Math.abs(target - value) < restDelta && Math.abs(velocity) < restSpeed) {\n      value = target;\n      velocity = 0;\n      stop?.();\n      stop = null;\n    }\n    onUpdate(value);\n  };\n\n  return {\n    set(next) {\n      target = next;\n      if (!stop && value !== target) stop = ticker.add(step);\n    },\n    jump(next) {\n      stop?.();\n      stop = null;\n      value = target = next;\n      velocity = 0;\n      onUpdate(value);\n    },\n    get: () => value,\n    destroy() {\n      stop?.();\n      stop = null;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/motion-curves.ts"
    }
  ],
  "docs": "Named export: TextAnimation. `text` is a plain string it splits internally; `as` sets the rendered element and `delay` / `duration` control timing; `renderWord(word, index)` swaps what a word renders as on the word-level variants (pop-in, flip, blur-in-up) while keeping its entrance. For dependency-free effects, use `animated-typography`.",
  "categories": [
    "typography",
    "marketing"
  ],
  "type": "registry:component"
}
