Awesome Storytelling By Animating Text With Motion for React
Staggered animations definitely are one of those areas where Motion for React is better at than CSS. Let's explore how we can create a reusable component animation texts together.
... Almost ready!
Adding subtle animations
Incorporating these subtle animations as users scroll through your website can significantly elevate your page’s storytelling. Consider text animations as an example. Instead of animating the entire title in one go, why not opt for a letter-by-letter approach?
Of course, there’s a fine balance between creating an engaging animation and ending up with a cluttered website. In today’s article, I’ll guide you through creating these staggered animations using Motion for React. Just remember, don’t go overboard with it! 😉
Why do we need Motion for React for this?
While CSS boasts an array of impressive features, handling staggered animations isn’t one of its strong suits. If you’re looking to animate individual letters, you’d typically need to assign custom delays using selectors like nth-child()
. However, this can be quite a hassle when you’re unsure about the text’s exact letter count.
And if you want to replicate a typing effect with animation repetition, CSS can present an even greater challenge. This is because it doesn’t allow you to set a specific delay between the end of one animation and the start of the next – you can only set a delay before the animation begins.
Ultimate goal
So, before we dive into the details, what’s the big picture here? I’ve got a vision for creating an AnimatedText component with the following features:
- The ability to handle any text input
- Compatibility with multi-line text
- Staggered letter animations for added flair
- User-friendly animation controls
- Optional animation repetition for that realistic typing effect
- Scroll-triggered animations
- Using the component should be as simple as this:
<AnimatedText text={[ "This is written on", "a typing machine. Tick tick", "tick tack tack...", ]} className="text-4xl" repeatDelay={10000} animation={{ hidden: { opacity: 0, y: 20 }, visible: { opacity: 1, y: 0, transition: { staggerChildren: 0.1 } }, }}/>
Start building the AnimatedText component
Now, let’s roll up our sleeves and begin constructing the AnimatedText
component, one step at a time. Our initial move is to receive a word and break it down into individual letters. We’ll achieve this by employing the split("")
method on the string, which neatly furnishes us with an array of letters.
type AnimatedTextProps = { text: string; el?: keyof JSX.IntrinsicElements; className?: string;};
export const AnimatedText = ({ text, el: Wrapper = "p", className,}: AnimatedTextProps) => { return ( <Wrapper className={className}> <span className="sr-only">{text}</span>
<span aria-hidden> {text.split("").map((char, charIndex) => ( <motion.span key={`${char}-${charIndex}`} className="inline-block"> {char} </motion.span> ))} </span> </Wrapper> );};
Here, we kick off with a few crucial initial steps.
Take a look at line 14, where we render the text within a sr-only
span. This means that the content within this span remains hidden from view but is exclusively accessible to screen readers.
Now, at line 16, you’ll notice the opposite approach – content hidden from screen readers but visible to users. This is the text that gets split into individual letters.
Rendering the text twice serves a specific purpose. Occasionally, when letters are split into separate spans, screen readers may interpret them as an abbreviation rather than a complete word. To address this, I’ve opted to render the full word within an sr-only span. This ensures that what’s read aloud to the user is the complete word or sentence.
Let’s add some animations
Now that we have this foundation, it’s time to add some life to it. Our first step will be to add a subtle fade-in animation to each letter.
export const AnimatedText = ({ text, el: Wrapper = "p", className,}: AnimatedTextProps) => { return ( <Wrapper className={className}> <span className="sr-only">{text}</span>
<span aria-hidden> {word.split("").map((char, charIndex) => ( <motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} key={`${char}-${charIndex}`} className="inline-block" > {char} </motion.span> ))} </span> </Wrapper> );};
That results in the following fade in animation:
Fading..
Boooooring
Well, I’m sure you didn’t start reading this article for a basic fade-in animation – that’s something you can easily achieve with CSS. Let’s take it up a notch and make it stagger!
To accomplish this, we’ll employ animation variants. This approach involves using distinct names for animation states and toggling between them, rather than directly applying styles to initial
and animate
. Our code should be modified to resemble the following:
const defaultAnimation = { hidden: { opacity: 0, }, visible: { opacity: 1, },};
export const AnimatedText = ({ text, el: Wrapper = "p", className,}: AnimatedTextProps) => { return ( <Wrapper className={className}> <span className="sr-only">{text}</span>
<motion.span aria-hidden> {word.split("").map((char, charIndex) => ( <motion.span variants={defaultAnimation} key={`${char}-${charIndex}`} className="inline-block" > {char} </motion.span> ))} </motion.span> </Wrapper> );};
Note the new defaultAnimation
object. Within this object, we’ve defined various animation states. The property names are entirely customizable, and within each state, we specify the styles we want to apply.
Next, we changed our parent element to a motion.span
instead of a standard span
. This change is essential because we intend to apply the animation to the parent container rather than to individual letters. We then remove the initial
and animate
props, and use the variants
prop instead..
At this stage, it won’t be any different to what we had before. However, the magic happens when we add a transition prop to the parent’s variants, instructing it to staggerChildren with a delay of 0.1. It looks something like this:
const defaultAnimation = { hidden: { opacity: 0, }, visible: { opacity: 1, },};
export const AnimatedText = ({ text, el: Wrapper = "p", className,}: AnimatedTextProps) => { return ( <Wrapper className={className}> <span className="sr-only">{text}</span>
<motion.span variants={{ visible: { transition: { staggerChildren: 0.1 } }, hidden: {}, }} initial="hidden" animate="visible" aria-hidden > {word.split("").map((char, charIndex) => ( <motion.span variants={defaultAnimation} key={`${char}-${charIndex}`} className="inline-block" > {char} </motion.span> ))} </motion.span> </Wrapper> );};
What’s happening here? First and foremost, we’re introducing the variants to the parent element, making it aware of their existence. However, we don’t define any styles for the parent because its styles won’t change based on the variants. What we do set, though, is the transition
prop within the visible
state. We tell it to stagger it’s children when applying the variants.
Next, we reintroduce the initial
and animate
props for the parent. Instead of specifying styles, we now assign the variant names as the values.
Thanks to the way Motion operates, the variants we’ve established for the parent will automatically cascade down to its children if they don’t have their own variant definitions. As a result, we no longer need to explicitly pass the variants to the children, and just like that, our letters begin animating one by one!
Fading..
Cool. But if this happens below the fold, you’d never see it..
Now, let’s move on to the next step: ensuring that this animation only kicks in when the text becomes visible within the user’s view. To achieve this, we’ll harness the power of the useInView
hook provided by Motion for React.
const defaultAnimation = {}; // see previous snippet
export const AnimatedText = ({ text, el: Wrapper = "p", className,}: AnimatedTextProps) => { const ref = useRef(null); const isInView = useInView(ref, { amount: 0.5, once: true });
return ( <Wrapper className={className}> <span className="sr-only">{text}</span>
<motion.span ref={ref} variants={{ visible: { transition: { staggerChildren: 0.1 } }, hidden: {}, }} initial="hidden" animate={isInView ? "visible" : "hidden"} aria-hidden > {word.split("").map((char, charIndex) => ( <motion.span variants={defaultAnimation} key={`${char}-${charIndex}`} className="inline-block" > {char} </motion.span> ))} </motion.span> </Wrapper> );};
We add a ref
to the parent element and pass it to the useInView
hook. This hook returns a boolean value, indicating whether the text is currently within the user’s view or not. We leverage this boolean to determine whether the text should undergo animation or not, achieved by setting the appropriate variant in the animate prop. Pretty neat, isn’t it?
Animation Triggering, Just Once
By default, I’ve configured the once boolean in the hook to be true. However, you can set it to false if you’d like the animation to activate every time the text enters the user’s view.
Enabling Animation Looping
As a final touch in the video, we add the looping functionality. To fully understand how this works, I recommend watching the full video. In brief, it entails converting our animation into a controlled animation. This way, we can trigger the animation repeatedly within a specified timeout. The final version of the code would then resemble this:
The final result,even working on multiple lines..
import { motion, useInView, useAnimation, Variant } from "motion/react";import { useEffect, useRef } from "react";
type AnimatedTextProps = { text: string | string[]; el?: keyof JSX.IntrinsicElements; className?: string; once?: boolean; repeatDelay?: number; animation?: { hidden: Variant; visible: Variant; };};
const defaultAnimations = { hidden: { opacity: 0, y: 20, }, visible: { opacity: 1, y: 0, transition: { duration: 0.1, }, },};
export const AnimatedText = ({ text, el: Wrapper = "p", className, once, repeatDelay, animation = defaultAnimations,}: AnimatedTextProps) => { const controls = useAnimation(); const textArray = Array.isArray(text) ? text : [text]; const ref = useRef(null); const isInView = useInView(ref, { amount: 0.5, once });
useEffect(() => { let timeout: NodeJS.Timeout; const show = () => { controls.start("visible"); if (repeatDelay) { timeout = setTimeout(async () => { await controls.start("hidden"); controls.start("visible"); }, repeatDelay); } };
if (isInView) { show(); } else { controls.start("hidden"); }
return () => clearTimeout(timeout); }, [isInView]);
return ( <Wrapper className={className}> <span className="sr-only">{text}</span> <motion.span ref={ref} initial="hidden" animate={controls} variants={{ visible: { transition: { staggerChildren: 0.1 } }, hidden: {}, }} aria-hidden > {textArray.map((line, lineIndex) => ( <span className="block" key={`${line}-${lineIndex}`}> {line.split(" ").map((word, wordIndex) => ( <span className="inline-block" 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> );};
Make sure to watch the full video
That sums up the creation of this animation. Make sure to watch the full video at the top of this page, to get an even better idea of how we construct this animation. We go way more in depth there.
Eager to learn more?
I think this video might be a good fit for you too!