I'm working with a React/Next.js 15 project using Framer Motion to animate text components. I've created a custom <TextAnimate />
component that mimics the behavior of Magic UI's text animations like blurInUp
, slideUp
, fadeIn
, etc.
The animation works perfectly in most cases. However, on mobile, animations are being retriggered unexpectedly when the browser's URL bar shows or hides due to scrolling. This causes layout shifts and awkward replays of the animation, even though I'm using the whileInView="show"
prop from Framer Motion.
Here’s the relevant part of the code:
<MotionComponent
variants={finalVariants.container}
initial="hidden"
whileInView="show"
exit="exit"
viewport={{ once: false }}
>
{segments.map((segment, i) => (
<motion.span
key={`${by}-${segment}-${i}`}
variants={finalVariants.item}
className="inline-block"
>
{segment}
</motion.span>
))}
</MotionComponent>
I've confirmed that this behavior is linked to viewport height changes caused by the mobile browser UI (URL bar) appearing or disappearing, which seems to retrigger whileInView
.
How can I prevent Framer Motion from re-triggering the animation when the viewport changes due to the mobile browser UI (like the address bar)?
I want the animation to trigger only once when the element first scrolls into view, and not again unless the user navigates away and comes back.
I’m sharing the complete code of the components/magicui/text-animate.tsx
file in case someone might be able to adjust it.
"use client";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, MotionProps, Variants } from "motion/react";
import { ElementType } from "react";
type AnimationType = "text" | "word" | "character" | "line";
type AnimationVariant =
| "fadeIn"
| "blurIn"
| "blurInUp"
| "blurInDown"
| "slideUp"
| "slideDown"
| "slideLeft"
| "slideRight"
| "scaleUp"
| "scaleDown";
interface TextAnimateProps extends MotionProps {
/**
* The text content to animate
*/
children: string;
/**
* The class name to be applied to the component
*/
className?: string;
/**
* The class name to be applied to each segment
*/
segmentClassName?: string;
/**
* The delay before the animation starts
*/
delay?: number;
/**
* The duration of the animation
*/
duration?: number;
/**
* Custom motion variants for the animation
*/
variants?: Variants;
/**
* The element type to render
*/
as?: ElementType;
/**
* How to split the text ("text", "word", "character")
*/
by?: AnimationType;
/**
* Whether to start animation when component enters viewport
*/
startOnView?: boolean;
/**
* Whether to animate only once
*/
once?: boolean;
/**
* The animation preset to use
*/
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 = false,
by = "word",
animation = "fadeIn",
...props
}: TextAnimateProps) {
const MotionComponent = motion.create(Component);
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 };
return (
<AnimatePresence mode="popLayout">
<MotionComponent
variants={finalVariants.container as Variants}
initial="hidden"
whileInView={startOnView ? "show" : undefined}
animate={startOnView ? undefined : "show"}
exit="exit"
className={cn("whitespace-pre-wrap", className)}
viewport={{ once }}
{...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>
);
}
You’re running into a common mobile issue where viewport-based animation triggers (like whileInView) can unstable due to the dynamic nature of the UI (in this case, the mobile browser’s address bar) showing or disappearing. This makes IntersectionObserver behind whileInView to incorrectly think that the element entered or left viewport — and thus starts the animation again.
✅ Recommended Solution: useInView instead whileInView
Instead of using whileInView="show" (which is declarative but fickle when it comes to mobile), leverage Framer Motion’s useInView() hook to programmatically determine when the animation begins.That way, you can retain full control and make sure the animation only occurs once it actually enters the viewport – as opposed to when the browser UI itself moves.
🔧 Modified useInView Implementation
And here's the simplified and focused version of your logic with useInView:
"use client";
import { cn } from "@/lib/utils"; // Assuming you have this utility
import {
AnimatePresence,
motion,
MotionProps,
Variants,
useInView,
} from "framer-motion";
import { ElementType, useRef, ComponentType, ReactNode } from "react";
// Minimal type definitions for illustration
type AnimationType = "text" | "word" | "character" | "line";
type AnimationVariant =
| "fadeIn"
| "blurIn"
| "blurInUp"
// ... add all your other AnimationVariant types
| "scaleDown";
// Placeholder for your detailed variant definitions
const defaultItemAnimationVariants: Record<
AnimationVariant,
{ container?: Variants; item: Variants }
> = {
fadeIn: { item: { hidden: { opacity: 0 }, show: { opacity: 1 } } },
blurInUp: { item: { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0 } } },
// ... add all your other preset variant definitions
};
const defaultItemBaseVariants: Variants = { hidden: { opacity: 0 }, show: { opacity: 1 } };
const defaultContainerVariantsBase = {
hidden: {},
show: { transition: { delayChildren: 0, staggerChildren: 0.05 } },
};
interface TextAnimateProps extends MotionProps {
children: string;
className?: string;
segmentClassName?: string;
delay?: number;
duration?: number;
customItemVariants?: Variants;
as?: ElementType;
by?: AnimationType;
once?: boolean;
animation?: AnimationVariant;
viewportAmount?: number | "some" | "all";
}
export function TextAnimate({
children: textContent,
delay = 0,
duration = 0.5,
customItemVariants,
segmentClassName,
as: ComponentTag = "div",
once = true, // Default to true for "animate once" behavior
by = "word",
animation = "fadeIn",
viewportAmount = 0.2,
className,
...restMotionProps
}: TextAnimateProps) {
const As = ComponentTag || "div";
const ResolvedMotionComponent = (
typeof As === "string"
? motion[As as keyof typeof motion] || motion.div
: motion(As as ComponentType<any>)
) as React.ElementType;
const ref = useRef<Element>(null);
const isInView = useInView(ref, {
once: once, // This 'once' prop directly controls useInView's behavior
amount: viewportAmount,
});
let segments: string[] = [];
switch (by) {
case "word":
segments = textContent.split(/(\s+)/).filter(Boolean);
break;
case "character":
segments = textContent.split("").filter(Boolean);
break;
case "line":
segments = textContent.split("\n").filter(Boolean);
break;
default:
segments = [textContent];
break;
}
const validSegments = segments.filter((s) => s.trim() !== "" || s === " ");
const nonEmptySegmentsCount = validSegments.length || 1;
const itemVariantsToUse = customItemVariants
? customItemVariants
: defaultItemAnimationVariants[animation]?.item || defaultItemBaseVariants;
const containerVariants: Variants = {
hidden: {
...defaultContainerVariantsBase.hidden,
...defaultItemAnimationVariants[animation]?.container?.hidden,
},
show: {
...defaultContainerVariantsBase.show,
...(defaultItemAnimationVariants[animation]?.container?.show as object),
transition: {
...(defaultContainerVariantsBase.show?.transition as object),
...((
defaultItemAnimationVariants[animation]?.container?.show as {
transition?: object;
}
)?.transition as object),
delayChildren: delay,
staggerChildren: nonEmptySegmentsCount > 0 ? duration / nonEmptySegmentsCount : 0,
},
},
};
const animateState = isInView ? "show" : "hidden";
return (
<AnimatePresence>
<ResolvedMotionComponent
ref={ref}
initial="hidden"
animate={animateState} // Animation is directly controlled by isInView state
exit="hidden"
variants={containerVariants}
className={cn("whitespace-pre-wrap", className)}
{...restMotionProps}
>
{validSegments.map((segment, i) => (
<motion.span
key={`${by}-${segment}-${i}`}
variants={itemVariantsToUse}
className={cn(
by === "line" ? "block" : "inline-block",
segment === " " ? "whitespace-pre" : "",
segmentClassName
)}
>
{segment === " " ? "\u00A0" : segment}
</motion.span>
))}
</ResolvedMotionComponent>
</AnimatePresence>
);
}
✅ Benefits of this approach
📝 Note