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

Framer Motion打开菜单时whileInView标题动画意外重置问题

问题原因
  • 核心是对viewport={{ once: true }}的作用存在理解偏差。这个配置只会让「元素进入视口时触发动画」的逻辑执行1次,不会锁定动画结束后的最终状态。whileInView本身的逻辑是实时和元素的视口可见性绑定的:只要元素因为任何原因(弹出全屏菜单抽屉、布局偏移、父级添加transform属性)被判定为不在视口内,就会立刻回退到initial定义的hidden状态。你打开的菜单抽屉一般是fixed定位全屏覆盖,会直接导致原有页面的标题元素被判定为不可见,这就是触发重置的直接原因。
  • 额外的写法问题:你给每个单字母的<motion.span>都单独加了viewport检测配置,打开抽屉时所有字母会同时判定为不可见,集体回到translateY(100%)的初始状态;外层包裹的AnimatePresence在这里完全没有作用,反而会增加不必要的重渲染判定。
修复方案

不要依赖whileInView做一次性入场动画,改用useInView钩子手动控制动画状态,一旦元素进入过视口就永久锁定为动画完成状态,不会因为后续视口变化重置。
修改后的完整代码如下:

import { motion, useInView } from "framer-motion";
import { useRef } from "react";

function AnimatedHeading(props) {
    const {
        text,
        stagger = 0.015,
        delay = 0.5
    } = props || {}

    // 绑定元素ref,检测进入视口状态,once保证只触发一次
    const headingRef = useRef(null);
    const isInView = useInView(headingRef, { 
        once: true,
        margin: "0px 0px -100px 0px" // 可调整触发阈值,负值表示提前触发
    });

    // 单词包裹组件,防止单词内部断行
    const WordWrap = (props) => {
        return <span className="whitespace-nowrap">{props.children}</span>;
    }

    // 拆分文本为单词、单字母数组
    const splitWords = text.split(" ");
    const words = [];
    for (const [, item] of splitWords.entries()) {
        words.push(item.split(""));
    }

    // 每个单词末尾追加不换行空格
    words.map((word) => {
        return word.push("\u00A0");
    });

    const variants = {
        hidden: {
            y: '100%',
            transition: { ease: [0.455, 0.03, 0.515, 0.955], duration: 0.85 }
        },
        animate: {
            y: '0%',
            transition: { ease: [0.455, 0.03, 0.515, 0.955], duration: 0.75 }
        }
    };
    

    return (
        <>
            <span className="sr-only">{text}</span>
            <motion.span
                ref={headingRef}
                className="inline-block"
                initial="hidden"
                // 手动绑定动画状态,进入过视口就永久保持完成态
                animate={isInView ? "animate" : "hidden"}
                transition={{
                    delayChildren: delay,
                    staggerChildren: stagger
                }}
            >
                {words.map((word, n) => (
                    <WordWrap key={n}>
                        {word.flat().map((char, index) => (
                            <span key={index} className="overflow-hidden pb-1 inline-block">
                                <motion.span
                                    key={`animated-heading-${char}-${index}`}
                                    variants={variants}
                                    className="relative inline-block overflow-visible"
                                >
                                    {char}
                                </motion.span>
                            </span>
                        ))}
                    </WordWrap>
                ))}
            </motion.span>
        </>
    )
}
关键修改点
  • 移除所有whileInView属性和子元素多余的viewport配置,删掉无用的AnimatePresence包裹
  • 用useRef绑定标题容器,通过useInView检测元素可见性,once: true参数会在元素第一次进入视口后永久把isInView锁定为true,后续不管打开菜单还是布局偏移都不会改变状态
  • 改用animate属性手动控制动画状态,只要isInView为true就一直保持animate的最终样式,彻底解决重置问题
  • useInView的margin参数可以调整动画触发的视口阈值,示例里的-100px表示元素距离视口底部还有100px时就触发动画,可根据实际需求调整。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.31 10:15:37