'use client';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import './EchoText.css';
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const directionVectors = {
right: { x: 1, y: 0 },
left: { x: -1, y: 0 },
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
diagonal: { x: 0.72, y: 0.72 }
};
const easing = {
linear: t => t,
'ease-out': t => 1 - Math.pow(1 - t, 3),
'ease-in-out': t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
snappy: t => 1 - Math.pow(1 - t, 5)
};
const DRIFT_PERIOD = 7200;
const DRIFT_IDLE = 2600;
const BURST_LIFE = 1100;
const BURST_GAP = 80;
const readScroll = target =>
target instanceof Element ? [target.scrollLeft || 0, target.scrollTop || 0] : [window.scrollX, window.scrollY];
const EchoText = ({
text = 'Motion Echo',
echoes = 12,
lag = 0.24,
offset = 36,
direction = 'right',
fade = 0.72,
blur = 3,
tint = '#7dd3fc',
echoColors,
echoStyle = 'solid',
echoScale = 1,
mode = 'both',
trail = 'pointer',
clickBurst = true,
cursorRadius = 320,
duration = 900,
ease = 'ease-out',
fontSize = 'clamp(3rem, 9vw, 7rem)',
fontWeight = 800,
color = 'currentColor',
className = '',
style
}) => {
const rootRef = useRef(null);
const burstRef = useRef(null);
const copyRefs = useRef([]);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
const echoCount = prefersReducedMotion ? 0 : clamp(Math.round(echoes), 0, 24);
const echoIndexes = useMemo(() => Array.from({ length: echoCount }, (_, index) => echoCount - index), [echoCount]);
const ramp = Array.isArray(echoColors) && echoColors.length ? echoColors : null;
const canBurst = clickBurst && echoCount > 0;
const echoColor = index => {
if (ramp) {
const depth = echoCount > 1 ? (index - 1) / (echoCount - 1) : 0;
return `color-mix(in srgb, ${ramp[ramp.length - 1]} ${Math.round(depth * 100)}%, ${ramp[0]})`;
}
return tint ? `color-mix(in srgb, ${tint} ${Math.min(72, 18 + index * 5)}%, currentColor)` : undefined;
};
useEffect(() => {
if (!window.matchMedia) return undefined;
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const updateMotionPreference = () => setPrefersReducedMotion(media.matches);
updateMotionPreference();
media.addEventListener?.('change', updateMotionPreference);
return () => media.removeEventListener?.('change', updateMotionPreference);
}, []);
useIsomorphicLayoutEffect(() => {
const root = rootRef.current;
if (!root || prefersReducedMotion) return undefined;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return undefined;
const vector = directionVectors[direction] || directionVectors.right;
const safeOffset = clamp(Number(offset) || 0, 0, 120);
const safeCursorRadius = clamp(Number(cursorRadius) || 320, 40, 1200);
const safeLag = clamp(Number(lag) || 0.16, 0.02, 0.5);
const safeFade = clamp(Number(fade) || 0.64, 0.1, 0.95);
const safeBlur = clamp(Number(blur) || 0, 0, 16);
const safeScale = clamp(Number(echoScale) || 1, 0.25, 2.5);
const safeDuration = Math.max(0, Number(duration) || 0);
const easeFn = easing[ease] || easing['ease-out'];
const entranceEnabled = mode === 'entrance' || mode === 'both';
const liveEnabled = mode === 'pointer' || mode === 'both';
const canHover = Boolean(window.matchMedia?.('(hover: hover) and (pointer: fine)').matches);
const drifting = liveEnabled && trail === 'auto';
const scrolling = liveEnabled && trail === 'scroll';
const following = liveEnabled && canHover && !scrolling;
const driftSpacing = 0.12 + safeLag * 1.1;
const burstReach = clamp(safeOffset / 36, 0.35, 2.5);
const shiftLimit = safeOffset * 4;
const state = {
targetX: 0,
targetY: 0,
lastTargetX: 0,
lastTargetY: 0,
activity: entranceEnabled ? 1 : 0,
positions: Array.from({ length: echoCount + 1 }, (_, index) => {
const entranceAmount = entranceEnabled ? safeOffset * (index + 0.35) : 0;
return { x: vector.x * entranceAmount, y: vector.y * entranceAmount };
}),
bends: Array.from({ length: echoCount + 1 }, () => ({ x: 0, y: 0 })),
startTime: performance.now(),
lastTime: 0,
drift: 0,
phase: 0,
pointerNear: false,
pointerTime: -Infinity,
visible: true,
pushX: 0,
pushY: 0,
velocityX: 0,
velocityY: 0,
bursts: []
};
let frame = 0;
for (let index = 1; index <= echoCount; index += 1) {
const copy = copyRefs.current[index];
if (copy) copy.style.filter = safeBlur > 0 ? `blur(${((safeBlur * index) / echoCount).toFixed(2)}px)` : 'none';
}
const renderBursts = now => {
state.bursts = state.bursts.filter(burst => {
const age = now - burst.start;
let alive = false;
burst.nodes.forEach((node, index) => {
const local = (age - index * BURST_GAP) / BURST_LIFE;
if (local < 1) alive = true;
const progress = clamp(local, 0, 1);
const spread = 1 - Math.pow(1 - progress, 3);
const scale = 1 + spread * 0.34 * burstReach;
const peak = 0.75 * Math.pow(safeFade, index * 0.5);
const opacity = local <= 0 ? 0 : Math.min(1, progress * 8) * Math.pow(1 - progress, 1.5) * peak;
node.style.transform = `translate3d(${burst.x.toFixed(2)}px, ${burst.y.toFixed(2)}px, 0) scale(${scale.toFixed(4)})`;
node.style.opacity = opacity.toFixed(3);
node.style.filter = safeBlur > 0 ? `blur(${(safeBlur * 0.3 * spread).toFixed(2)}px)` : 'none';
});
if (!alive) burst.nodes.forEach(node => node.remove());
return alive;
});
};
const renderFrame = now => {
frame = 0;
const dt = state.lastTime ? clamp(now - state.lastTime, 0, 64) : 16.67;
const step = dt / 16.67;
state.lastTime = now;
const elapsed = now - state.startTime;
const entranceProgress = entranceEnabled && safeDuration > 0 ? clamp(elapsed / safeDuration, 0, 1) : 1;
const entranceRest = entranceEnabled ? 1 - easeFn(entranceProgress) : 0;
const targetVelocity = Math.hypot(state.targetX - state.lastTargetX, state.targetY - state.lastTargetY);
state.lastTargetX = state.targetX;
state.lastTargetY = state.targetY;
if (drifting) {
const away = !following || !state.pointerNear || now - state.pointerTime > DRIFT_IDLE;
state.drift += ((away ? 1 : 0) - state.drift) * (1 - Math.pow(0.965, step));
state.phase += (dt / DRIFT_PERIOD) * Math.PI * 2;
}
if (scrolling) {
const blend = 1 - Math.pow(0.7, step);
state.velocityX += (state.pushX / Math.max(step, 0.25) - state.velocityX) * blend;
state.velocityY += (state.pushY / Math.max(step, 0.25) - state.velocityY) * blend;
state.pushX = 0;
state.pushY = 0;
}
const speed = Math.hypot(state.velocityX, state.velocityY);
const shiftX = clamp(-state.velocityX * 3.2, -shiftLimit, shiftLimit);
const shiftY = clamp(-state.velocityY * 3.2, -shiftLimit, shiftLimit);
const followX = state.targetX * (1 - state.drift);
const followY = state.targetY * (1 - state.drift);
let maxSeparation = 0;
let maxGap = 0;
for (let index = 0; index <= echoCount; index += 1) {
const copy = copyRefs.current[index];
const current = state.positions[index];
const bend = state.bends[index];
if (!copy || !current || !bend) continue;
const depth = echoCount ? index / echoCount : 0;
const entranceAmount = entranceRest * safeOffset * (index + 0.35);
let desiredX = followX + vector.x * entranceAmount;
let desiredY = followY + vector.y * entranceAmount;
if (state.drift > 0.0005) {
const phase = state.phase - index * driftSpacing;
const swing = safeOffset * state.drift * (0.25 + 0.75 * depth);
desiredX += Math.sin(phase) * swing;
desiredY += Math.sin(phase) * Math.cos(phase) * swing * 1.3;
}
const lerp = 1 - Math.pow(1 - clamp(0.34 / (1 + index * safeLag * 4.2), 0.018, 0.36), step);
const bendLerp = 1 - Math.pow(1 - 0.3 / (1 + depth * (1 + safeLag * 8)), step);
current.x += (desiredX - current.x) * lerp;
current.y += (desiredY - current.y) * lerp;
bend.x += (shiftX * depth - bend.x) * bendLerp;
bend.y += (shiftY * depth - bend.y) * bendLerp;
maxGap = Math.max(
maxGap,
Math.abs(desiredX - current.x),
Math.abs(desiredY - current.y),
Math.abs(shiftX * depth - bend.x),
Math.abs(shiftY * depth - bend.y)
);
const x = current.x + bend.x;
const y = current.y + bend.y;
const scale = 1 + (safeScale - 1) * depth;
copy.style.transform = `translate3d(${x.toFixed(3)}px, ${y.toFixed(3)}px, 0) scale(${scale.toFixed(4)})`;
if (index > 0) {
const front = state.positions[0];
maxSeparation = Math.max(maxSeparation, Math.hypot(x - front.x, y - front.y));
}
}
const separationActivity = safeOffset > 0 ? clamp(maxSeparation / (safeOffset * 2.25), 0, 1) : 0;
const targetActivity = safeOffset > 0 ? clamp(targetVelocity / (safeOffset * 0.35), 0, 1) : 0;
const nextActivity = Math.max(
entranceRest,
separationActivity,
targetActivity,
state.drift * 0.9,
clamp(speed / 5, 0, 1)
);
state.activity += (nextActivity - state.activity) * (1 - Math.pow(0.82, step));
renderBursts(now);
const running =
entranceProgress < 1 ||
state.activity > 0.002 ||
maxGap > 0.05 ||
speed > 0.02 ||
state.bursts.length > 0 ||
(drifting && state.visible);
if (!running) state.activity = 0;
for (let index = 1; index <= echoCount; index += 1) {
const copy = copyRefs.current[index];
if (copy) copy.style.opacity = String(Math.pow(safeFade, index) * state.activity);
}
if (running) frame = requestAnimationFrame(renderFrame);
};
const wake = () => {
if (!frame) {
state.lastTime = 0;
frame = requestAnimationFrame(renderFrame);
}
};
const handlePointerMove = event => {
const rect = root.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const deltaX = event.clientX - (rect.left + rect.width / 2);
const deltaY = event.clientY - (rect.top + rect.height / 2);
const distance = Math.hypot(deltaX, deltaY);
const reach = distance > 0 ? clamp(distance / safeCursorRadius, 0, 1) : 0;
const dirX = distance > 0 ? deltaX / distance : 0;
const dirY = distance > 0 ? deltaY / distance : 0;
state.targetX = dirX * reach * safeOffset;
state.targetY = dirY * reach * safeOffset * 0.72;
state.pointerNear = distance <= safeCursorRadius;
state.pointerTime = performance.now();
wake();
};
const handlePointerOut = event => {
if (event.relatedTarget) return;
state.targetX = 0;
state.targetY = 0;
state.pointerNear = false;
wake();
};
const scrollMemory = new WeakMap();
const handleScroll = event => {
const target = event.target;
if (!(target instanceof Document) && !(target instanceof Element && target.contains(root))) return;
const [x, y] = readScroll(target);
const last = scrollMemory.get(target);
scrollMemory.set(target, [x, y]);
if (!last) return;
state.pushX -= x - last[0];
state.pushY -= y - last[1];
wake();
};
const handleClick = event => {
const layer = burstRef.current;
const rect = root.getBoundingClientRect();
if (!layer || !echoCount || !rect.width || !rect.height) return;
const originX = clamp((event.clientX - rect.left) / rect.width, 0, 1) * 100;
const originY = clamp((event.clientY - rect.top) / rect.height, 0, 1) * 100;
const count = clamp(Math.round(echoCount / 3) + 2, 3, 6);
const front = state.positions[0];
const nodes = [];
for (let index = 0; index < count; index += 1) {
const source = copyRefs.current[1 + Math.round((index / Math.max(1, count - 1)) * (echoCount - 1))];
if (!source) continue;
const node = source.cloneNode(true);
node.removeAttribute('data-echo-index');
node.style.transformOrigin = `${originX.toFixed(1)}% ${originY.toFixed(1)}%`;
node.style.opacity = '0';
layer.appendChild(node);
nodes.push(node);
}
state.bursts.push({ start: performance.now(), x: front?.x || 0, y: front?.y || 0, nodes });
while (state.bursts.length > 4) state.bursts.shift().nodes.forEach(node => node.remove());
wake();
};
if (following) {
window.addEventListener('pointermove', handlePointerMove, { passive: true });
document.addEventListener('pointerout', handlePointerOut);
}
if (scrolling) {
scrollMemory.set(document, readScroll(document));
for (let node = root.parentElement; node; node = node.parentElement) scrollMemory.set(node, readScroll(node));
document.addEventListener('scroll', handleScroll, { capture: true, passive: true });
}
if (clickBurst) root.addEventListener('click', handleClick);
let observer;
if (drifting && typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver(entries => {
state.visible = entries[entries.length - 1].isIntersecting;
if (state.visible) wake();
});
observer.observe(root);
}
renderFrame(state.startTime);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerout', handlePointerOut);
document.removeEventListener('scroll', handleScroll, { capture: true });
root.removeEventListener('click', handleClick);
observer?.disconnect();
state.bursts.forEach(burst => burst.nodes.forEach(node => node.remove()));
};
}, [
blur,
clickBurst,
cursorRadius,
direction,
duration,
ease,
echoCount,
echoScale,
fade,
lag,
mode,
offset,
prefersReducedMotion,
trail
]);
const rootStyle = {
fontSize,
fontWeight,
color,
cursor: canBurst ? 'pointer' : undefined,
...style
};
const echoClassName = echoStyle === 'outline' ? 'echo-text__echo echo-text__echo--outline' : 'echo-text__echo';
return (
<span ref={rootRef} className={`echo-text ${className}`.trim()} style={rootStyle}>
{echoIndexes.map(index => (
<span
aria-hidden="true"
className={echoClassName}
data-echo-index={index}
key={`echo-${index}`}
ref={element => {
copyRefs.current[index] = element;
}}
style={{ color: echoColor(index), opacity: 0 }}
>
{text}
</span>
))}
<span aria-hidden="true" className="echo-text__bursts" ref={burstRef} />
<span
className="echo-text__echo echo-text__echo--front"
data-echo-index="0"
ref={element => {
copyRefs.current[0] = element;
}}
>
{text}
</span>
</span>
);
};
export default EchoText;