◆

EchoText

Ghosted copies trail behind the text and settle into a single word.

Original docs ↗
Live preview
Loading demo…

Installation

npx shadcn@latest add https://reactbits.dev/r/EchoText-TS-TW.json

Source

'use client';

import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react';

type Direction = 'right' | 'left' | 'up' | 'down' | 'diagonal';
type Mode = 'entrance' | 'pointer' | 'both';
type Ease = 'linear' | 'ease-out' | 'ease-in-out' | 'snappy';

type Vector = { x: number; y: number };
type Position = { x: number; y: number };

type AnimationState = {
  targetX: number;
  targetY: number;
  lastTargetX: number;
  lastTargetY: number;
  activity: number;
  positions: Position[];
  startTime: number;
};

export interface EchoTextProps {
  text?: string;
  echoes?: number;
  lag?: number;
  offset?: number;
  direction?: Direction;
  fade?: number;
  blur?: number;
  tint?: string | false;
  mode?: Mode;
  cursorRadius?: number;
  duration?: number;
  ease?: Ease;
  fontSize?: string | number;
  fontWeight?: string | number;
  color?: string;
  className?: string;
  style?: CSSProperties;
}

const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);

const directionVectors: Record<Direction, Vector> = {
  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: Record<Ease, (t: number) => number> = {
  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 EchoText: React.FC<EchoTextProps> = ({
  text = 'Motion Echo',
  echoes = 12,
  lag = 0.24,
  offset = 36,
  direction = 'right',
  fade = 0.72,
  blur = 3,
  tint = '#7dd3fc',
  mode = 'both',
  cursorRadius = 320,
  duration = 900,
  ease = 'ease-out',
  fontSize = 'clamp(3rem, 9vw, 7rem)',
  fontWeight = 800,
  color = '#f8fafc',
  className = '',
  style
}) => {
  const rootRef = useRef<HTMLSpanElement | null>(null);
  const copyRefs = useRef<Array<HTMLSpanElement | null>>([]);
  const frameRef = useRef<number | null>(null);
  const stateRef = useRef<AnimationState | null>(null);
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  const echoCount = prefersReducedMotion ? 0 : clamp(Math.round(echoes), 0, 24);
  const copyIndexes = useMemo(() => Array.from({ length: echoCount + 1 }, (_, index) => index), [echoCount]);

  useEffect(() => {
    if (typeof window === 'undefined' || !window.matchMedia) return;

    const media = window.matchMedia('(prefers-reduced-motion: reduce)');
    const updateMotionPreference = () => setPrefersReducedMotion(media.matches);
    updateMotionPreference();

    media.addEventListener?.('change', updateMotionPreference);
    return () => media.removeEventListener?.('change', updateMotionPreference);
  }, []);

  useEffect(() => {
    const root = rootRef.current;
    if (!root || prefersReducedMotion) return;

    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 safeDuration = Math.max(0, Number(duration) || 0);
    const easeFn = easing[ease] || easing['ease-out'];
    const entranceEnabled = mode === 'entrance' || mode === 'both';
    const pointerEnabled = mode === 'pointer' || mode === 'both';
    const positions = Array.from({ length: echoCount + 1 }, (_, index) => {
      const entranceAmount = entranceEnabled ? safeOffset * (index + 0.35) : 0;
      return { x: vector.x * entranceAmount, y: vector.y * entranceAmount };
    });

    stateRef.current = {
      targetX: 0,
      targetY: 0,
      lastTargetX: 0,
      lastTargetY: 0,
      activity: entranceEnabled ? 1 : 0,
      positions,
      startTime: performance.now()
    };

    let canHover = false;
    let cleanupPointer = () => {};

    if (pointerEnabled && window.matchMedia) {
      const hoverMedia = window.matchMedia('(hover: hover) and (pointer: fine)');
      canHover = hoverMedia.matches;
    }

    const handlePointerMove = (event: PointerEvent) => {
      const state = stateRef.current;
      if (!state) return;

      const rect = root.getBoundingClientRect();
      if (!rect.width || !rect.height) return;

      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;
      const deltaX = event.clientX - centerX;
      const deltaY = event.clientY - centerY;
      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;
    };

    const handlePointerLeave = () => {
      const state = stateRef.current;
      if (!state) return;
      state.targetX = 0;
      state.targetY = 0;
    };

    if (canHover) {
      window.addEventListener('pointermove', handlePointerMove, { passive: true });
      document.addEventListener('pointerleave', handlePointerLeave);
      cleanupPointer = () => {
        window.removeEventListener('pointermove', handlePointerMove);
        document.removeEventListener('pointerleave', handlePointerLeave);
      };
    }

    const renderFrame = (now: number) => {
      const state = stateRef.current;
      if (!state) return;

      const elapsed = now - state.startTime;
      const entranceProgress = entranceEnabled && safeDuration > 0 ? clamp(elapsed / safeDuration, 0, 1) : 1;
      const easedEntrance = easeFn(entranceProgress);
      const entranceRest = entranceEnabled ? 1 - easedEntrance : 0;
      const targetVelocity = Math.hypot(state.targetX - state.lastTargetX, state.targetY - state.lastTargetY);

      state.lastTargetX = state.targetX;
      state.lastTargetY = state.targetY;

      let maxSeparation = 0;

      for (let index = 0; index <= echoCount; index += 1) {
        const copy = copyRefs.current[index];
        const current = state.positions[index];
        if (!copy || !current) continue;

        const entranceAmount = entranceRest * safeOffset * (index + 0.35);
        const desiredX = state.targetX + vector.x * entranceAmount;
        const desiredY = state.targetY + vector.y * entranceAmount;
        const lerp = clamp(0.34 / (1 + index * safeLag * 4.2), 0.018, 0.36);

        current.x += (desiredX - current.x) * lerp;
        current.y += (desiredY - current.y) * lerp;

        copy.style.transform = `translate3d(${current.x.toFixed(3)}px, ${current.y.toFixed(3)}px, 0)`;

        if (index > 0) {
          const front = state.positions[0];
          const separation = front ? Math.hypot(current.x - front.x, current.y - front.y) : 0;
          maxSeparation = Math.max(maxSeparation, separation);
          const depth = echoCount ? index / echoCount : 0;
          copy.style.filter = safeBlur > 0 ? `blur(${(safeBlur * depth).toFixed(2)}px)` : 'none';
        }
      }

      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.activity += (nextActivity - state.activity) * 0.18;

      for (let index = 1; index <= echoCount; index += 1) {
        const copy = copyRefs.current[index];
        if (!copy) continue;
        copy.style.opacity = String(Math.pow(safeFade, index) * state.activity);
      }

      const stillMoving =
        state.activity > 0.002 ||
        Math.abs(state.targetX) > 0.01 ||
        Math.abs(state.targetY) > 0.01 ||
        entranceProgress < 1 ||
        canHover;

      if (stillMoving) {
        frameRef.current = requestAnimationFrame(renderFrame);
      } else {
        frameRef.current = null;
      }
    };

    frameRef.current = requestAnimationFrame(renderFrame);

    return () => {
      cleanupPointer();
      if (frameRef.current) cancelAnimationFrame(frameRef.current);
      frameRef.current = null;
      stateRef.current = null;
    };
  }, [blur, cursorRadius, direction, duration, ease, echoCount, fade, lag, mode, offset, prefersReducedMotion]);

  const rootStyle: CSSProperties = {
    fontSize,
    fontWeight,
    color,
    ...style
  };

  return (
    <span
      ref={rootRef}
      className={`relative inline-block whitespace-nowrap select-none leading-[0.9] tracking-[-0.04em] [contain:layout_style] [font-kerning:normal] [text-rendering:geometricPrecision] ${className}`.trim()}
      style={rootStyle}
    >
      {copyIndexes
        .slice(1)
        .reverse()
        .map(index => (
          <span
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 block origin-center transform-gpu backface-hidden will-change-[transform,opacity]"
            data-echo-index={index}
            key={`echo-${index}`}
            ref={element => {
              copyRefs.current[index] = element;
            }}
            style={{
              color: tint ? `color-mix(in srgb, ${tint} ${Math.min(72, 18 + index * 5)}%, ${color})` : color,
              opacity: 0
            }}
          >
            {text}
          </span>
        ))}
      <span
        className="pointer-events-none relative z-[2] block transform-gpu text-shadow-[0_0.035em_0_rgba(255,255,255,0.04)] will-change-transform"
        data-echo-index="0"
        ref={element => {
          copyRefs.current[0] = element;
        }}
      >
        {text}
      </span>
    </span>
  );
};

export default EchoText;