{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "EchoText-TS-CSS",
	"title": "EchoText",
	"description": "Ghosted copies trail behind the text and settle into a single word.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "EchoText.css",
			"target": "@components/EchoText.css",
			"content": ".echo-text {\n  position: relative;\n  display: inline-block;\n  white-space: nowrap;\n  line-height: 0.9;\n  letter-spacing: -0.04em;\n  user-select: none;\n  contain: layout style;\n  font-kerning: normal;\n  text-rendering: geometricPrecision;\n}\n\n.echo-text__echo {\n  position: absolute;\n  inset: 0;\n  display: block;\n  pointer-events: none;\n  transform: translate3d(0, 0, 0);\n  transform-origin: 50% 50%;\n  will-change: transform, opacity;\n  backface-visibility: hidden;\n}\n\n.echo-text__echo--front {\n  position: relative;\n  z-index: 2;\n  opacity: 1;\n  filter: none;\n  text-shadow: 0 0.035em 0 rgba(255, 255, 255, 0.04);\n  will-change: transform;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .echo-text__echo:not(.echo-text__echo--front) {\n    display: none;\n  }\n\n  .echo-text__echo--front {\n    transform: none !important;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "EchoText.tsx",
			"content": "import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react';\n\nimport './EchoText.css';\n\ntype Direction = 'right' | 'left' | 'up' | 'down' | 'diagonal';\ntype Mode = 'entrance' | 'pointer' | 'both';\ntype Ease = 'linear' | 'ease-out' | 'ease-in-out' | 'snappy';\n\ntype Vector = { x: number; y: number };\ntype Position = { x: number; y: number };\n\ntype AnimationState = {\n  targetX: number;\n  targetY: number;\n  lastTargetX: number;\n  lastTargetY: number;\n  activity: number;\n  positions: Position[];\n  startTime: number;\n};\n\nexport interface EchoTextProps {\n  text?: string;\n  echoes?: number;\n  lag?: number;\n  offset?: number;\n  direction?: Direction;\n  fade?: number;\n  blur?: number;\n  tint?: string | false;\n  mode?: Mode;\n  cursorRadius?: number;\n  duration?: number;\n  ease?: Ease;\n  fontSize?: string | number;\n  fontWeight?: string | number;\n  color?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\n\nconst directionVectors: Record<Direction, Vector> = {\n  right: { x: 1, y: 0 },\n  left: { x: -1, y: 0 },\n  up: { x: 0, y: -1 },\n  down: { x: 0, y: 1 },\n  diagonal: { x: 0.72, y: 0.72 }\n};\n\nconst easing: Record<Ease, (t: number) => number> = {\n  linear: t => t,\n  'ease-out': t => 1 - Math.pow(1 - t, 3),\n  'ease-in-out': t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),\n  snappy: t => 1 - Math.pow(1 - t, 5)\n};\n\nconst EchoText: React.FC<EchoTextProps> = ({\n  text = 'Motion Echo',\n  echoes = 12,\n  lag = 0.24,\n  offset = 36,\n  direction = 'right',\n  fade = 0.72,\n  blur = 3,\n  tint = '#7dd3fc',\n  mode = 'both',\n  cursorRadius = 320,\n  duration = 900,\n  ease = 'ease-out',\n  fontSize = 'clamp(3rem, 9vw, 7rem)',\n  fontWeight = 800,\n  color = '#f8fafc',\n  className = '',\n  style\n}) => {\n  const rootRef = useRef<HTMLSpanElement | null>(null);\n  const copyRefs = useRef<Array<HTMLSpanElement | null>>([]);\n  const frameRef = useRef<number | null>(null);\n  const stateRef = useRef<AnimationState | null>(null);\n  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);\n\n  const echoCount = prefersReducedMotion ? 0 : clamp(Math.round(echoes), 0, 24);\n  const copyIndexes = useMemo(() => Array.from({ length: echoCount + 1 }, (_, index) => index), [echoCount]);\n\n  useEffect(() => {\n    if (typeof window === 'undefined' || !window.matchMedia) return;\n\n    const media = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const updateMotionPreference = () => setPrefersReducedMotion(media.matches);\n    updateMotionPreference();\n\n    media.addEventListener?.('change', updateMotionPreference);\n    return () => media.removeEventListener?.('change', updateMotionPreference);\n  }, []);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root || prefersReducedMotion) return;\n\n    const vector = directionVectors[direction] || directionVectors.right;\n    const safeOffset = clamp(Number(offset) || 0, 0, 120);\n    const safeCursorRadius = clamp(Number(cursorRadius) || 320, 40, 1200);\n    const safeLag = clamp(Number(lag) || 0.16, 0.02, 0.5);\n    const safeFade = clamp(Number(fade) || 0.64, 0.1, 0.95);\n    const safeBlur = clamp(Number(blur) || 0, 0, 16);\n    const safeDuration = Math.max(0, Number(duration) || 0);\n    const easeFn = easing[ease] || easing['ease-out'];\n    const entranceEnabled = mode === 'entrance' || mode === 'both';\n    const pointerEnabled = mode === 'pointer' || mode === 'both';\n    const positions = Array.from({ length: echoCount + 1 }, (_, index) => {\n      const entranceAmount = entranceEnabled ? safeOffset * (index + 0.35) : 0;\n      return { x: vector.x * entranceAmount, y: vector.y * entranceAmount };\n    });\n\n    stateRef.current = {\n      targetX: 0,\n      targetY: 0,\n      lastTargetX: 0,\n      lastTargetY: 0,\n      activity: entranceEnabled ? 1 : 0,\n      positions,\n      startTime: performance.now()\n    };\n\n    let canHover = false;\n    let cleanupPointer = () => {};\n\n    if (pointerEnabled && window.matchMedia) {\n      const hoverMedia = window.matchMedia('(hover: hover) and (pointer: fine)');\n      canHover = hoverMedia.matches;\n    }\n\n    const handlePointerMove = (event: PointerEvent) => {\n      const state = stateRef.current;\n      if (!state) return;\n\n      const rect = root.getBoundingClientRect();\n      if (!rect.width || !rect.height) return;\n\n      const centerX = rect.left + rect.width / 2;\n      const centerY = rect.top + rect.height / 2;\n      const deltaX = event.clientX - centerX;\n      const deltaY = event.clientY - centerY;\n      const distance = Math.hypot(deltaX, deltaY);\n      const reach = distance > 0 ? clamp(distance / safeCursorRadius, 0, 1) : 0;\n      const dirX = distance > 0 ? deltaX / distance : 0;\n      const dirY = distance > 0 ? deltaY / distance : 0;\n\n      state.targetX = dirX * reach * safeOffset;\n      state.targetY = dirY * reach * safeOffset * 0.72;\n    };\n\n    const handlePointerLeave = () => {\n      const state = stateRef.current;\n      if (!state) return;\n      state.targetX = 0;\n      state.targetY = 0;\n    };\n\n    if (canHover) {\n      window.addEventListener('pointermove', handlePointerMove, { passive: true });\n      document.addEventListener('pointerleave', handlePointerLeave);\n      cleanupPointer = () => {\n        window.removeEventListener('pointermove', handlePointerMove);\n        document.removeEventListener('pointerleave', handlePointerLeave);\n      };\n    }\n\n    const renderFrame = (now: number) => {\n      const state = stateRef.current;\n      if (!state) return;\n\n      const elapsed = now - state.startTime;\n      const entranceProgress = entranceEnabled && safeDuration > 0 ? clamp(elapsed / safeDuration, 0, 1) : 1;\n      const easedEntrance = easeFn(entranceProgress);\n      const entranceRest = entranceEnabled ? 1 - easedEntrance : 0;\n      const targetVelocity = Math.hypot(state.targetX - state.lastTargetX, state.targetY - state.lastTargetY);\n\n      state.lastTargetX = state.targetX;\n      state.lastTargetY = state.targetY;\n\n      let maxSeparation = 0;\n\n      for (let index = 0; index <= echoCount; index += 1) {\n        const copy = copyRefs.current[index];\n        const current = state.positions[index];\n        if (!copy || !current) continue;\n\n        const entranceAmount = entranceRest * safeOffset * (index + 0.35);\n        const desiredX = state.targetX + vector.x * entranceAmount;\n        const desiredY = state.targetY + vector.y * entranceAmount;\n        const lerp = clamp(0.34 / (1 + index * safeLag * 4.2), 0.018, 0.36);\n\n        current.x += (desiredX - current.x) * lerp;\n        current.y += (desiredY - current.y) * lerp;\n\n        copy.style.transform = `translate3d(${current.x.toFixed(3)}px, ${current.y.toFixed(3)}px, 0)`;\n\n        if (index > 0) {\n          const front = state.positions[0];\n          const separation = front ? Math.hypot(current.x - front.x, current.y - front.y) : 0;\n          maxSeparation = Math.max(maxSeparation, separation);\n          const depth = echoCount ? index / echoCount : 0;\n          copy.style.filter = safeBlur > 0 ? `blur(${(safeBlur * depth).toFixed(2)}px)` : 'none';\n        }\n      }\n\n      const separationActivity = safeOffset > 0 ? clamp(maxSeparation / (safeOffset * 2.25), 0, 1) : 0;\n      const targetActivity = safeOffset > 0 ? clamp(targetVelocity / (safeOffset * 0.35), 0, 1) : 0;\n      const nextActivity = Math.max(entranceRest, separationActivity, targetActivity);\n      state.activity += (nextActivity - state.activity) * 0.18;\n\n      for (let index = 1; index <= echoCount; index += 1) {\n        const copy = copyRefs.current[index];\n        if (!copy) continue;\n        copy.style.opacity = String(Math.pow(safeFade, index) * state.activity);\n      }\n\n      const stillMoving =\n        state.activity > 0.002 ||\n        Math.abs(state.targetX) > 0.01 ||\n        Math.abs(state.targetY) > 0.01 ||\n        entranceProgress < 1 ||\n        canHover;\n\n      if (stillMoving) {\n        frameRef.current = requestAnimationFrame(renderFrame);\n      } else {\n        frameRef.current = null;\n      }\n    };\n\n    frameRef.current = requestAnimationFrame(renderFrame);\n\n    return () => {\n      cleanupPointer();\n      if (frameRef.current) cancelAnimationFrame(frameRef.current);\n      frameRef.current = null;\n      stateRef.current = null;\n    };\n  }, [blur, cursorRadius, direction, duration, ease, echoCount, fade, lag, mode, offset, prefersReducedMotion]);\n\n  const rootStyle: CSSProperties = {\n    fontSize,\n    fontWeight,\n    color,\n    ...style\n  };\n\n  return (\n    <span ref={rootRef} className={`echo-text ${className}`.trim()} style={rootStyle}>\n      {copyIndexes\n        .slice(1)\n        .reverse()\n        .map(index => (\n          <span\n            aria-hidden=\"true\"\n            className=\"echo-text__echo\"\n            data-echo-index={index}\n            key={`echo-${index}`}\n            ref={element => {\n              copyRefs.current[index] = element;\n            }}\n            style={{\n              color: tint ? `color-mix(in srgb, ${tint} ${Math.min(72, 18 + index * 5)}%, ${color})` : color,\n              opacity: 0\n            }}\n          >\n            {text}\n          </span>\n        ))}\n      <span\n        className=\"echo-text__echo echo-text__echo--front\"\n        data-echo-index=\"0\"\n        ref={element => {\n          copyRefs.current[0] = element;\n        }}\n      >\n        {text}\n      </span>\n    </span>\n  );\n};\n\nexport default EchoText;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}