You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让React组件的useEffect在元素每次进入视口时重新触发文本Scramble动画?

如何让React组件的useEffect在元素每次进入视口时重新触发文本Scramble动画?

我仔细看了你的组件代码,问题核心在于IntersectionObserver的回调逻辑没有处理元素离开视口后的状态重置,以及动画触发的条件不够严谨——导致第一次进入视口触发动画后,后续再进入时无法重新启动。下面是一步步的修复方案,直接对应你的代码修改:

一、修复IntersectionObserver的回调逻辑

原来的Observer只在元素第一次进入时触发动画,但没有处理离开时的状态重置,也没判断当前是否正在动画。修改后,每次元素进入视口且动画未在运行时,都会触发动画;离开时重置状态,确保下次进入能正常触发:

// 替换第一个useEffect中的Observer部分
useEffect(() => {
  if (!startOnView) {
    const headingStartTimeout = setTimeout(() => {
      setIsHeadingAnimating(true);
    }, delay);
    return () => clearTimeout(headingStartTimeout);
  }

  const headingObserver = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        // 仅当动画未运行时,才启动新动画
        if (!isHeadingAnimating) {
          setTimeout(() => {
            setIsHeadingAnimating(true);
          }, delay);
        }
      } else {
        // 元素离开视口时,强制重置动画状态
        setIsHeadingAnimating(false);
        // 可选:如果希望离开时立即恢复原始文本,取消下面的注释
        // setHeadingDisplayText(children.split(""));
      }
    },
    { threshold: 0.1, rootMargin: "-30% 0px -30% 0px" },
  );

  if (headingElementRef.current) {
    headingObserver.observe(headingElementRef.current);
  }

  // 保留disconnect,避免组件卸载时内存泄漏
  return () => headingObserver.disconnect();
}, [delay, startOnView, isHeadingAnimating]); // 注意这里要添加isHeadingAnimating到依赖

⚠️ 关键提醒:必须把isHeadingAnimating加入到第一个useEffect的依赖数组中,这样Observer的回调才能获取到最新的状态值,避免逻辑判断失效。

二、确保动画每次从头开始

原来的动画逻辑中,headingIterationCount.current可能会保留上一次动画的数值,导致后续动画不从头启动。在第二个useEffect开头重置这个计数:

// 替换第二个useEffect的内容
useEffect(() => {
  if (!isHeadingAnimating) return;

  // 重置迭代计数,保证每次动画都从第一个字符开始
  headingIterationCount.current = 0;
  const headingMaxIterations = children.length;
  const headingStartTime = performance.now();
  let headingAnimationFrameId: number;

  const animateHeading = (currentTime: number) => {
    const headingElapsed = currentTime - headingStartTime;
    const headingProgress = Math.min(headingElapsed / duration, 1);
    headingIterationCount.current = headingProgress * headingMaxIterations;

    setHeadingDisplayText((currentText) =>
      currentText.map((letter, index) =>
        letter === " " ? letter :
        index <= headingIterationCount.current ? children[index] :
        characterSet[getRandomInt(characterSet.length)],
      ),
    );

    if (headingProgress < 1) {
      headingAnimationFrameId = requestAnimationFrame(animateHeading);
    } else {
      setIsHeadingAnimating(false);
    }
  };

  headingAnimationFrameId = requestAnimationFrame(animateHeading);
  return () => cancelAnimationFrame(headingAnimationFrameId);
}, [children, duration, isHeadingAnimating, characterSet]);

三、修改后的完整组件代码

"use client";
import { AnimatePresence, motion, MotionProps } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

type CharacterSet = string[] | readonly string[];

interface HyperTextProps extends MotionProps {
  /** The text content to be animated */
  children: string;
  /** Optional className for styling */
  className?: string;
  /** Duration of the animation in milliseconds */
  duration?: number;
  /** Delay before animation starts in milliseconds */
  delay?: number;
  /** Component to render as - defaults to div */
  as?: React.ElementType;
  /** Whether to start animation when element comes into view */
  startOnView?: boolean;
  /** Whether to trigger animation on hover */
  animateOnHover?: boolean;
  /** Custom character set for scramble effect. Defaults to uppercase alphabet */
  characterSet?: CharacterSet;
}

const DEFAULT_CHARACTER_SET = Object.freeze(
  "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""),
) as readonly string[];

const getRandomInt = (max: number): number => Math.floor(Math.random() * max);

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export function HyperTextHeading({
  children,
  className,
  duration = 800,
  delay = 0,
  as: Component = "div",
  startOnView = false,
  animateOnHover = true,
  characterSet = DEFAULT_CHARACTER_SET,
  ...props
}: HyperTextProps) {
  const HeadingMotionComponent = motion.create(Component, { forwardMotionProps: true });

  const [headingDisplayText, setHeadingDisplayText] = useState<string[]>(() =>
    children.split(""),
  );
  const [isHeadingAnimating, setIsHeadingAnimating] = useState(false);
  const headingIterationCount = useRef(0);
  const headingElementRef = useRef<HTMLElement>(null);

  const handleHeadingAnimationTrigger = () => {
    if (animateOnHover && !isHeadingAnimating) {
      headingIterationCount.current = 0;
      setIsHeadingAnimating(true);
    }
  };

  // Handle animation start based on view or delay
  useEffect(() => {
    if (!startOnView) {
      const headingStartTimeout = setTimeout(() => {
        setIsHeadingAnimating(true);
      }, delay);
      return () => clearTimeout(headingStartTimeout);
    }

    const headingObserver = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          if (!isHeadingAnimating) {
            setTimeout(() => {
              setIsHeadingAnimating(true);
            }, delay);
          }
        } else {
          setIsHeadingAnimating(false);
          // 可选:离开时立即恢复原始文本
          // setHeadingDisplayText(children.split(""));
        }
      },
      { threshold: 0.1, rootMargin: "-30% 0px -30% 0px" },
    );

    if (headingElementRef.current) {
      headingObserver.observe(headingElementRef.current);
    }

    return () => headingObserver.disconnect();
  }, [delay, startOnView, isHeadingAnimating]);

  // Handle scramble animation
  useEffect(() => {
    if (!isHeadingAnimating) return;

    headingIterationCount.current = 0;
    const headingMaxIterations = children.length;
    const headingStartTime = performance.now();
    let headingAnimationFrameId: number;

    const animateHeading = (currentTime: number) => {
      const headingElapsed = currentTime - headingStartTime;
      const headingProgress = Math.min(headingElapsed / duration, 1);
      headingIterationCount.current = headingProgress * headingMaxIterations;

      setHeadingDisplayText((currentText) =>
        currentText.map((letter, index) =>
          letter === " " ? letter :
          index <= headingIterationCount.current ? children[index] :
          characterSet[getRandomInt(characterSet.length)],
        ),
      );

      if (headingProgress < 1) {
        headingAnimationFrameId = requestAnimationFrame(animateHeading);
      } else {
        setIsHeadingAnimating(false);
      }
    };

    headingAnimationFrameId = requestAnimationFrame(animateHeading);
    return () => cancelAnimationFrame(headingAnimationFrameId);
  }, [children, duration, isHeadingAnimating, characterSet]);

  return (
    <HeadingMotionComponent
      ref={headingElementRef}
      className={cn("overflow-hidden py-2 text-4xl font-bold", className)}
      onMouseEnter={handleHeadingAnimationTrigger}
      {...props}
    >
      <AnimatePresence>
        {headingDisplayText.map((letter, index) => (
          <motion.span
            key={index}
            className={cn("font-mono", letter === " " ? "w-3" : "")}
          >
            {letter.toUpperCase()}
          </motion.span>
        ))}
      </AnimatePresence>
    </HeadingMotionComponent>
  );
}

这样修改后,你的组件就能在元素每次进入视口时重新触发Scramble动画了,同时保留了hover触发的功能,也不会有内存泄漏的问题。

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 14:08:17