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

如何在Framer Motion动画完成后触发回调实现指定单词下划线

解决Framer Motion动画完成后为指定单词添加下划线的问题

我有一个名为AnimatedText的React组件,需求是当动画完成后,为underlineLines属性中指定索引的单词添加下划线。我尝试使用onAnimationEnd函数,但没有产生任何变化。以下是我的组件代码:

"use client";
import { motion, useInView, Variant, useAnimationControls } from "framer-motion";
import { useEffect, useRef, useState } from "react";

type AnimatedTextProps = {
    text: string | string[];
    el?: keyof JSX.IntrinsicElements;
    className?: string;
    once?: boolean;
    repeatDelay?: number;
    underlineLines?: number[];
    animation?: {
        hidden: Variant;
        visible: Variant;
    };
};

const defaultAnimations = {
    hidden: {
        opacity: 0,
        y: 5,
    },
    visible: {
        opacity: 1,
        y: 0,
        transition: {
            duration: 0.05,
            ease: "easeOut",
        },
    },
};

const AnimatedText = ({ text, el: Wrapper = "p", className, once, repeatDelay, underlineLines, animation = defaultAnimations }: AnimatedTextProps) => {
    const textArray = Array.isArray(text) ? text : [text];
    const controls = useAnimationControls();
    const ref = useRef(null);
    const isInView = useInView(ref, { amount: 0.1, once });
    const [isAnimationEnd, setIsAnimationEnd] = useState(false);

    useEffect(() => {
        let timeout: NodeJS.Timeout;
        const show = () => {
            controls.start("visible");
            if (repeatDelay) {
                timeout = setTimeout(async () => {
                    await controls.start("hidden");
                    controls.start("visible");
                }, repeatDelay);
            }
        };
        isInView ? show() : controls.start("hidden");

        return () => clearTimeout(timeout);
    }, [isInView]);

    return (
        <Wrapper className={className}>
            <span className="sr-only">{textArray.join(" ")}</span>
            <motion.span
                ref={ref}
                initial="hidden"
                animate={controls}
                variants={{
                    visible: { transition: { staggerChildren: 0.05 } },
                    hidden: {},
                }}
                aria-hidden
            >
                {textArray.map((line, lineIndex) => {
                    return (
                        <span className="block" key={`${line}-${lineIndex}`}>
                            {line.split(" ").map((word, wordIndex) => (
                                <span
                                    className={
                                        underlineLines?.includes(wordIndex) && isAnimationEnd
                                            ? "inline-block q-underline q-underline-black q-underline-start"
                                            : "inline-block q-underline q-underline-black"
                                    }
                                    key={`${word}-${wordIndex}`}
                                >
                                    {word.split("").map((char, charIndex) => (
                                        <motion.span
                                            key={`${char}-${charIndex}`}
                                            className="inline-block"
                                            variants={animation}
                                            onAnimationEnd={() => {
                                                setIsAnimationEnd(true);
                                            }}
                                        >
                                            {char}
                                        </motion.span>
                                    ))}
                                    <span className="inline-block">&nbsp;</span>
                                </span>
                            ))}
                        </span>
                    );
                })}
            </motion.span>
        </Wrapper>
    );
};

export default AnimatedText;

问题分析

你当前的实现把onAnimationEnd绑定到了每个字符的motion.span上,这会导致每个字符动画结束都触发状态更新,但无法准确判断所有字符动画全部完成的时机。另外,当设置了repeatDelay重复动画时,也没有重置下划线状态的逻辑。

修复方案

我们可以通过以下步骤解决问题:

  1. 利用controls.start()返回的Promise,等待整个 stagger 动画完成后再更新下划线状态。
  2. 在重复动画的逻辑中,先重置下划线状态,确保每次动画循环都能正确触发下划线显示。
  3. 简化className拼接逻辑,提升代码可读性。

修改后的完整代码:

"use client";
import { motion, useInView, Variant, useAnimationControls } from "framer-motion";
import { useEffect, useRef, useState } from "react";

type AnimatedTextProps = {
    text: string | string[];
    el?: keyof JSX.IntrinsicElements;
    className?: string;
    once?: boolean;
    repeatDelay?: number;
    underlineLines?: number[];
    animation?: {
        hidden: Variant;
        visible: Variant;
    };
};

const defaultAnimations = {
    hidden: {
        opacity: 0,
        y: 5,
    },
    visible: {
        opacity: 1,
        y: 0,
        transition: {
            duration: 0.05,
            ease: "easeOut",
        },
    },
};

const AnimatedText = ({ text, el: Wrapper = "p", className, once, repeatDelay, underlineLines, animation = defaultAnimations }: AnimatedTextProps) => {
    const textArray = Array.isArray(text) ? text : [text];
    const controls = useAnimationControls();
    const ref = useRef(null);
    const isInView = useInView(ref, { amount: 0.1, once });
    const [isAnimationEnd, setIsAnimationEnd] = useState(false);

    useEffect(() => {
        let timeout: NodeJS.Timeout;
        const show = async () => {
            // 开始动画前重置下划线状态
            setIsAnimationEnd(false);
            // 等待整个 stagger 动画完成
            await controls.start("visible");
            // 所有字符动画完成后开启下划线
            setIsAnimationEnd(true);
            
            if (repeatDelay) {
                timeout = setTimeout(async () => {
                    // 重复动画前重置状态
                    setIsAnimationEnd(false);
                    await controls.start("hidden");
                    await controls.start("visible");
                    setIsAnimationEnd(true);
                }, repeatDelay);
            }
        };
        isInView ? show() : controls.start("hidden");

        return () => clearTimeout(timeout);
    }, [isInView, repeatDelay, controls]);

    return (
        <Wrapper className={className}>
            <span className="sr-only">{textArray.join(" ")}</span>
            <motion.span
                ref={ref}
                initial="hidden"
                animate={controls}
                variants={{
                    visible: { transition: { staggerChildren: 0.05 } },
                    hidden: {},
                }}
                aria-hidden
            >
                {textArray.map((line, lineIndex) => {
                    return (
                        <span className="block" key={`${line}-${lineIndex}`}>
                            {line.split(" ").map((word, wordIndex) => (
                                <span
                                    className={`inline-block q-underline q-underline-black ${
                                        underlineLines?.includes(wordIndex) && isAnimationEnd
                                            ? "q-underline-start"
                                            : ""
                                    }`}
                                    key={`${word}-${wordIndex}`}
                                >
                                    {word.split("").map((char, charIndex) => (
                                        <motion.span
                                            key={`${char}-${charIndex}`}
                                            className="inline-block"
                                            variants={animation}
                                        >
                                            {char}
                                        </motion.span>
                                    ))}
                                    <span className="inline-block">&nbsp;</span>
                                </span>
                            ))}
                        </span>
                    );
                })}
            </motion.span>
        </Wrapper>
    );
};

export default AnimatedText;

关键修改说明

  • 动画完成时机判断:移除了每个字符上的onAnimationEnd,改为等待controls.start("visible")的Promise resolve后再设置下划线状态,确保所有 stagger 动画完成后才显示下划线。
  • 重复动画状态重置:在每次启动动画(包括重复动画)前,先将isAnimationEnd设为false,避免下划线在动画过程中提前显示。
  • className优化:使用模板字符串简化className拼接,让逻辑更清晰易读。

内容的提问来源于stack exchange,提问作者Kyanoe

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.01 12:30:55