如何阻止Framer Motion动画因移动端URL栏显隐重复触发?
解决移动端URL栏显隐导致Framer Motion动画重复触发问题
核心问题分析
移动端浏览器URL栏的显隐会修改视口高度,Framer Motion的whileInView会基于这个变化重新计算元素可见性,即使元素始终在视口内,也会被误判为“重新进入视口”,从而重复触发动画。
解决方案
1. 结合once与路由状态重置
将once设为true确保动画只触发一次,同时监听Next.js路由变化,在用户离开页面时重置动画状态,返回时能重新触发。
2. 使用状态跟踪动画触发状态
用React状态记录动画是否已经完成,避免视口变化导致的重复触发。
3. 优化视口检测配置
调整viewport的margin参数,降低视口变化的敏感度。
修改后的完整组件代码
"use client"; import { cn } from "@/lib/utils"; import { AnimatePresence, motion, MotionProps, Variants } from "motion/react"; import { ElementType, useEffect, useRef, useState } from "react"; import { usePathname } from "next/navigation"; type AnimationType = "text" | "word" | "character" | "line"; type AnimationVariant = | "fadeIn" | "blurIn" | "blurInUp" | "blurInDown" | "slideUp" | "slideDown" | "slideLeft" | "slideRight" | "scaleUp" | "scaleDown"; interface TextAnimateProps extends MotionProps { children: string; className?: string; segmentClassName?: string; delay?: number; duration?: number; variants?: Variants; as?: ElementType; by?: AnimationType; startOnView?: boolean; once?: boolean; animation?: AnimationVariant; } const staggerTimings: Record<AnimationType, number> = { text: 0.06, word: 0.05, character: 0.03, line: 0.06, }; const defaultContainerVariants = { hidden: { opacity: 1 }, show: { opacity: 1, transition: { delayChildren: 0, staggerChildren: 0.05, }, }, exit: { opacity: 0, transition: { staggerChildren: 0.05, staggerDirection: -1, }, }, }; const defaultItemVariants: Variants = { hidden: { opacity: 0 }, show: { opacity: 1 }, exit: { opacity: 0 }, }; const defaultItemAnimationVariants: Record< AnimationVariant, { container: Variants; item: Variants } > = { fadeIn: { container: defaultContainerVariants, item: { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0, transition: { duration: 0.3 }, }, exit: { opacity: 0, y: 20, transition: { duration: 0.3 }, }, }, }, blurIn: { container: defaultContainerVariants, item: { hidden: { opacity: 0, filter: "blur(10px)" }, show: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.3 }, }, exit: { opacity: 0, filter: "blur(10px)", transition: { duration: 0.3 }, }, }, }, blurInUp: { container: defaultContainerVariants, item: { hidden: { opacity: 0, filter: "blur(10px)", y: 20 }, show: { opacity: 1, filter: "blur(0px)", y: 0, transition: { y: { duration: 0.3 }, opacity: { duration: 0.4 }, filter: { duration: 0.3 }, }, }, exit: { opacity: 0, filter: "blur(10px)", y: 20, transition: { y: { duration: 0.3 }, opacity: { duration: 0.4 }, filter: { duration: 0.3 }, }, }, }, }, blurInDown: { container: defaultContainerVariants, item: { hidden: { opacity: 0, filter: "blur(10px)", y: -20 }, show: { opacity: 1, filter: "blur(0px)", y: 0, transition: { y: { duration: 0.3 }, opacity: { duration: 0.4 }, filter: { duration: 0.3 }, }, }, }, }, slideUp: { container: defaultContainerVariants, item: { hidden: { y: 20, opacity: 0 }, show: { y: 0, opacity: 1, transition: { duration: 0.3 }, }, exit: { y: -20, opacity: 0, transition: { duration: 0.3 }, }, }, }, slideDown: { container: defaultContainerVariants, item: { hidden: { y: -20, opacity: 0 }, show: { y: 0, opacity: 1, transition: { duration: 0.3 }, }, exit: { y: 20, opacity: 0, transition: { duration: 0.3 }, }, }, }, slideLeft: { container: defaultContainerVariants, item: { hidden: { x: 20, opacity: 0 }, show: { x: 0, opacity: 1, transition: { duration: 0.3 }, }, exit: { x: -20, opacity: 0, transition: { duration: 0.3 }, }, }, }, slideRight: { container: defaultContainerVariants, item: { hidden: { x: -20, opacity: 0 }, show: { x: 0, opacity: 1, transition: { duration: 0.3 }, }, exit: { x: 20, opacity: 0, transition: { duration: 0.3 }, }, }, }, scaleUp: { container: defaultContainerVariants, item: { hidden: { scale: 0.5, opacity: 0 }, show: { scale: 1, opacity: 1, transition: { duration: 0.3, scale: { type: "spring", damping: 15, stiffness: 300 }, }, }, exit: { scale: 0.5, opacity: 0, transition: { duration: 0.3 }, }, }, }, scaleDown: { container: defaultContainerVariants, item: { hidden: { scale: 1.5, opacity: 0 }, show: { scale: 1, opacity: 1, transition: { duration: 0.3, scale: { type: "spring", damping: 15, stiffness: 300 }, }, }, exit: { scale: 1.5, opacity: 0, transition: { duration: 0.3 }, }, }, }, }; export function TextAnimate({ children, delay = 0, duration = 0.3, variants, className, segmentClassName, as: Component = "p", startOnView = true, once = true, by = "word", animation = "fadeIn", ...props }: TextAnimateProps) { const MotionComponent = motion.create(Component); const pathname = usePathname(); const [animationTriggered, setAnimationTriggered] = useState(false); const prevPathname = useRef(pathname); let segments: string[] = []; switch (by) { case "word": segments = children.split(/(\s+)/); break; case "character": segments = children.split(""); break; case "line": segments = children.split("\n"); break; case "text": default: segments = [children]; break; } const finalVariants = variants ? { container: { hidden: { opacity: 0 }, show: { opacity: 1, transition: { opacity: { duration: 0.01, delay }, delayChildren: delay, staggerChildren: duration / segments.length, }, }, exit: { opacity: 0, transition: { staggerChildren: duration / segments.length, staggerDirection: -1, }, }, }, item: variants, } : animation ? { container: { ...defaultItemAnimationVariants[animation].container, show: { ...defaultItemAnimationVariants[animation].container.show, transition: { delayChildren: delay, staggerChildren: duration / segments.length, }, }, exit: { ...defaultItemAnimationVariants[animation].container.exit, transition: { staggerChildren: duration / segments.length, staggerDirection: -1, }, }, }, item: defaultItemAnimationVariants[animation].item, } : { container: defaultContainerVariants, item: defaultItemVariants }; useEffect(() => { if (prevPathname.current !== pathname) { setAnimationTriggered(false); prevPathname.current = pathname; } }, [pathname]); return ( <AnimatePresence mode="popLayout"> <MotionComponent variants={finalVariants.container as Variants} initial="hidden" whileInView={startOnView && !animationTriggered ? "show" : undefined} animate={startOnView ? undefined : "show"} exit="exit" className={cn("whitespace-pre-wrap", className)} viewport={{ once, margin: "-50px 0px" }} onAnimationComplete={() => setAnimationTriggered(true)} {...props} > {segments.map((segment, i) => ( <motion.span key={`${by}-${segment}-${i}`} variants={finalVariants.item} custom={i * staggerTimings[by]} className={cn( by === "line" ? "block" : "inline-block whitespace-pre", by === "character" && "", segmentClassName, )} > {segment} </motion.span> ))} </MotionComponent> </AnimatePresence> ); }
关键修改点说明
- 默认
once设为true:确保动画仅在首次进入视口时触发一次。 - 路由监听:使用
usePathname跟踪路由变化,当用户离开页面后返回时,重置动画触发状态,允许动画重新触发。 - 动画状态跟踪:通过
useState记录动画完成状态,避免视口变化时重复触发whileInView。 - 视口margin优化:设置
margin: "-50px 0px",扩大元素的有效可见范围,减少URL栏变化导致的误触发。
内容的提问来源于stack exchange,提问作者Sofiane R
相关产品推荐
相关产品推荐

