如何在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"> </span> </span> ))} </span> ); })} </motion.span> </Wrapper> ); }; export default AnimatedText;
问题分析
你当前的实现把onAnimationEnd绑定到了每个字符的motion.span上,这会导致每个字符动画结束都触发状态更新,但无法准确判断所有字符动画全部完成的时机。另外,当设置了repeatDelay重复动画时,也没有重置下划线状态的逻辑。
修复方案
我们可以通过以下步骤解决问题:
- 利用
controls.start()返回的Promise,等待整个 stagger 动画完成后再更新下划线状态。 - 在重复动画的逻辑中,先重置下划线状态,确保每次动画循环都能正确触发下划线显示。
- 简化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"> </span> </span> ))} </span> ); })} </motion.span> </Wrapper> ); }; export default AnimatedText;
关键修改说明
- 动画完成时机判断:移除了每个字符上的
onAnimationEnd,改为等待controls.start("visible")的Promise resolve后再设置下划线状态,确保所有 stagger 动画完成后才显示下划线。 - 重复动画状态重置:在每次启动动画(包括重复动画)前,先将
isAnimationEnd设为false,避免下划线在动画过程中提前显示。 - className优化:使用模板字符串简化className拼接,让逻辑更清晰易读。
内容的提问来源于stack exchange,提问作者Kyanoe
相关产品推荐
相关产品推荐

