I’m experiencing layout shifts in my React application, particularly when animating components in order. The issue arises when I try to animate Heading with a typing animation and then animate the body text with a fade-in effect. While the animations work fine individually, when used together, I notice that the content “jerks” and resizes during the animations, even though I’ve attempted to fix the layout with min-height.
Here is the scenario:
Heading uses a typing animation where the text appears one character at a time.
Body text uses a fade-in animation.
When both of these components are animated in sequence, there is a noticeable layout shift, and the content seems to resize and move around as the animations proceed.
What I’ve tried:
Adding min-height to prevent content from resizing, but this hasn’t fully solved the issue.
The problem does not occur when the animations are run without order (i.e., both components are animated simultaneously).
What I want to achieve:
I want to animate the content in order (first the heading, then the body text) without causing layout shifts or resizing.
I would like to understand if there’s a better way to manage the animation order or any other workaround to avoid these shifts.
import React, { useState } from "react";
import { motion } from "framer-motion";
import Heading from "../Heading/Heading";
import BodyText from "../BodyText/BodyText";
import AnimatedBackground from "../../utilities/AnimatedBackground/AnimatedBackground";
import Marquee from "../Marquee/Marquee";
import { theme } from "../../theme";
const slideContent = {
title: "Innovative Solutions Through",
subtitle: "Custom Software Development",
description:
"Empowering businesses with tailored, cutting-edge tech solutions that transform ideas into impactful realities.",
};
const Hero = () => {
const [animationStep, setAnimationStep] = useState(0);
const handleTitleComplete = () => setAnimationStep(1);
const handleSubtitleComplete = () => setAnimationStep(2);
const handleDescriptionComplete = () => setAnimationStep(3);
return (
<AnimatedBackground
className={`hero-section relative flex flex-col justify-between w-full min-h-screen`}
>
<div
className={` ${theme.layoutPages.paddingHorizontal} w-full grid grid-cols-12 items-center text-center py-6 relative z-10 flex-grow`}
>
<div className="col-span-12">
<motion.div
>
{/* Title Animation */}
<div className="min-h-[60px]">
<Heading
text={slideContent.title}
isAnimate={animationStep === 0}
onAnimationComplete={handleTitleComplete}
size="text-50px"
/>
</div>
{/* Subtitle Animation */}
<div className="min-h-[80px]">
{animationStep >= 1 && (
<Heading
text={slideContent.subtitle}
color="text-neon"
size="text-60px"
centered={true}
isAnimate={animationStep === 1}
onAnimationComplete={handleSubtitleComplete}
/>
)}
</div>
{/* Description Animation */}
<div className="min-h-[80px]">
{animationStep >= 2 && (
<BodyText
text={slideContent.description}
centered={true}
className="md:px-40"
isAnimate={animationStep === 2}
onAnimationComplete={handleDescriptionComplete}
/>
)}
</div>
</motion.div>
</div>
</div>
{/* Marquee Component */}
<Marquee />
</AnimatedBackground>
);
};
export default Hero;
import React from 'react';
import PropTypes from 'prop-types';
import { motion } from 'framer-motion';
import useTypingAnimation from '../../utilities/Animations/useTypingAnimation.js';
const Heading = ({
text,
spanText = '',
spanColor = 'text-neon',
color = 'text-white',
size = 'text-50px',
centered = true,
fontFamily = 'font-monument',
fontWeight = 'font-normal',
isAnimate = true,
order = 0,
speedMultiplier = 0.7,
onAnimationComplete,
className = '',
breakSpan = false,
}) => {
const parts = spanText ? text.split(spanText) : [text];
const { controls, ref, characterVariants } = useTypingAnimation({
text,
isAnimate,
order,
speedMultiplier,
});
// Split text into words while preserving spaces
const splitIntoWords = (string) => {
return string.split(/(s+)/).filter(word => word.length > 0);
};
// Split words into characters for animation
const splitWordIntoChars = (word) => {
return word.split('').map((char) => (char === ' ' ? 'u00A0' : char));
};
const totalLength = text.length;
let charCount = 0;
// Render without animations if isAnimate is false
if (!isAnimate) {
return (
<h1
className={`${centered ? 'text-center' : ''} ${color} ${size} ${fontFamily} ${fontWeight} ${className}`}
style={{
wordBreak: 'normal',
overflowWrap: 'break-word',
whiteSpace: 'pre-wrap'
}}
>
{parts[0]}
{spanText && (
<>
{!parts[0].endsWith(' ') && ' '}
<span className={`${spanColor} ${breakSpan ? 'block' : 'inline'}`}>
{spanText}
</span>
{!parts[1]?.startsWith(' ') && ' '}
</>
)}
{parts[1]}
</h1>
);
}
// Render with animations if isAnimate is true
return (
<h1
ref={ref}
className={`${centered ? 'text-center' : ''} ${color} ${size} ${fontFamily} ${fontWeight} ${className}`}
style={{
wordBreak: 'normal',
overflowWrap: 'break-word',
whiteSpace: 'pre-wrap'
}}
>
{/* First part */}
{splitIntoWords(parts[0]).map((word, wordIndex, wordArray) => (
<span
key={`word-1-${wordIndex}`}
style={{ display: 'inline-block' }}
>
{splitWordIntoChars(word).map((char, charIndex) => {
const currentCharIndex = charCount++;
return (
<motion.span
key={`char-1-${wordIndex}-${charIndex}`}
custom={currentCharIndex}
initial="hidden"
animate={controls}
variants={characterVariants}
style={{ display: 'inline-block' }}
onAnimationComplete={
currentCharIndex === totalLength - 1 && onAnimationComplete
? onAnimationComplete
: undefined
}
>
{char}
</motion.span>
);
})}
</span>
))}
{/* Span text */}
{spanText && (
<>
{!parts[0].endsWith(' ') && (
<span style={{ display: 'inline-block' }}>{'u00A0'}</span>
)}
<span
className={`${spanColor} ${breakSpan ? 'block' : 'inline-block'}`}
>
{splitIntoWords(spanText).map((word, wordIndex) => (
<span
key={`word-span-${wordIndex}`}
style={{ display: 'inline-block' }}
>
{splitWordIntoChars(word).map((char, charIndex) => {
const currentCharIndex = charCount++;
return (
<motion.span
key={`char-span-${wordIndex}-${charIndex}`}
custom={currentCharIndex}
initial="hidden"
animate={controls}
variants={characterVariants}
style={{ display: 'inline-block' }}
onAnimationComplete={
currentCharIndex === totalLength - 1 && onAnimationComplete
? onAnimationComplete
: undefined
}
>
{char}
</motion.span>
);
})}
</span>
))}
</span>
{!parts[1]?.startsWith(' ') && (
<span style={{ display: 'inline-block' }}>{'u00A0'}</span>
)}
</>
)}
{/* Second part */}
{parts[1] && splitIntoWords(parts[1]).map((word, wordIndex) => (
<span
key={`word-2-${wordIndex}`}
style={{ display: 'inline-block' }}
>
{splitWordIntoChars(word).map((char, charIndex) => {
const currentCharIndex = charCount++;
return (
<motion.span
key={`char-2-${wordIndex}-${charIndex}`}
custom={currentCharIndex}
initial="hidden"
animate={controls}
variants={characterVariants}
style={{ display: 'inline-block' }}
onAnimationComplete={
currentCharIndex === totalLength - 1 && onAnimationComplete
? onAnimationComplete
: undefined
}
>
{char}
</motion.span>
);
})}
</span>
))}
</h1>
);
};
Heading.propTypes = {
text: PropTypes.string.isRequired,
spanText: PropTypes.string,
spanColor: PropTypes.string,
color: PropTypes.string,
size: PropTypes.string,
centered: PropTypes.bool,
fontFamily: PropTypes.string,
fontWeight: PropTypes.string,
isAnimate: PropTypes.bool,
order: PropTypes.number,
onAnimationComplete: PropTypes.func,
className: PropTypes.string,
breakSpan: PropTypes.bool,
};
export default Heading;
import React from 'react';
import PropTypes from 'prop-types';
import { motion } from 'framer-motion';
import useFadeInAnimation from '../../utilities/Animations/useFadeInAnimation';
const BodyText = ({
text,
color = 'text-white',
size = 'text-35px',
lineHeight = 'leading-normal',
fontFamily = 'font-mulish',
fontWeight = 'font-extralight',
centered = true,
isAnimate = true,
delay = 0,
onAnimationComplete = null,
className = '',
}) => {
// Initialize animation hooks
const { controls, ref, fadeInVariants } = useFadeInAnimation({ isAnimate, delay });
// Render without animation if `isAnimate` is false
if (!isAnimate) {
return (
<p
className={`${centered ? 'text-center' : ''} ${color} ${size} ${lineHeight} ${fontFamily} ${fontWeight} ${className}`}
>
{text}
</p>
);
}
// Render with animation
return (
<motion.p
ref={ref}
initial="hidden"
animate={controls}
variants={fadeInVariants}
onAnimationComplete={onAnimationComplete}
style={{ position: 'relative' }} // Ensure position relative to avoid layout shift
layout={true} // Prevent layout changes
className={`${centered ? 'text-center' : ''} break-words ${color} ${size} ${lineHeight} ${fontFamily} ${fontWeight} ${className}`}
>
{text}
</motion.p>
);
};
BodyText.propTypes = {
text: PropTypes.string.isRequired,
color: PropTypes.string,
size: PropTypes.string,
lineHeight: PropTypes.string,
fontFamily: PropTypes.string,
fontWeight: PropTypes.string,
centered: PropTypes.bool,
isAnimate: PropTypes.bool,
delay: PropTypes.number,
onAnimationComplete: PropTypes.func,
className: PropTypes.string,
};
export default BodyText;
import { useAnimation } from 'framer-motion';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
const useTypingAnimation = ({ text, isAnimate, speedMultiplier = 0.8 }) => {
const controls = useAnimation();
const [ref, inView] = useInView({
triggerOnce: true,
threshold: 0.5,
});
const characterDelay = 0.06 * speedMultiplier;
useEffect(() => {
if (inView && isAnimate) {
controls.start('visible');
}
}, [inView, isAnimate, controls]);
const characterVariants = {
hidden: {
opacity: 0,
},
visible: i => ({
opacity: 1,
transition: {
delay: i * characterDelay,
duration: 0.05 * speedMultiplier,
},
}),
};
return { controls, ref, characterVariants };
};
export default useTypingAnimation;
import { useAnimation } from 'framer-motion';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
const useFadeInAnimation = ({ isAnimate = true, delay = 0.3, duration = 1, threshold = 0.5 }) => {
const controls = useAnimation();
const [ref, inView] = useInView({
triggerOnce: true, // Only animate once when in view
threshold, // Controls when the animation is triggered (default 50% visibility)
});
useEffect(() => {
if (inView && isAnimate) {
controls.start('visible');
}
}, [inView, isAnimate, controls]);
const fadeInVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { delay, duration, ease: 'easeInOut',
},
},
};
return { controls, ref, fadeInVariants };
};
export default useFadeInAnimation;