Text animations with Motion
With this hook, you can run text animations with ease. Split on character, word or even a line of text. Heck, you can even combine them all!
import { stagger } from "motion/react";import { useEffect } from "react";
// Get the hook via right below this code block 👇import { useSplitTextAnimation } from "./_use-split-text-animation";
export const App = () => { const [scope, animate] = useSplitTextAnimation({ splitOn: ["line", "word", "char"], });
useEffect(() => { animate([ [ ".word", { opacity: [0, 1], x: [20, 0], y: [0, 20, -20, 0], }, { delay: stagger(0.01) }, ], [ ".line", { x: [0, 20, -20, 0], }, { delay: stagger(0.05) }, ], [ ".char", { scale: [1, 1.5, 1], }, { delay: stagger(0.01), duration: 0.5 }, ], ]); }, [scope, animate]);
return ( <div> <p className="text-xl opacity-0 aria-[label]:opacity-100 md:text-3xl" ref={scope} > With this hook, you can run text animations with ease. Split on character, word or even a line of text. Heck, you can even combine them all! </p> </div> );};
Members only recipe
This recipe is only available to paying members. Either as part of PRO, or by purchasing this recipe individually.
I’ve covered Split Text Animations before, but those tutorials had a major downside: You had to manually split the text and handle accessibility yourself. Painful.
This hook handles all that for you — and more. You can split text by characters, words, lines, or any combination you like.
Default usage
This hook builds on top of Motion’s useAnimate()
and returns:
scope
: The ref to attach to the element you want to splitanimate
: Theanimate
function, just likeuseAnimate()
would returnisSplit
: A boolean to check whether the text has been split (most of the times not needed)
By default, the hook splits by word, and each word gets the class .word. You can target these directly in your animations.
6 collapsed lines
import { useEffect } from "react";import { stagger } from "motion/react";
import { useSplitTextAnimation } from "./_use-split-text-animation";
export const App = () => { const [scope, animate, isSplit] = useSplitTextAnimation();
useEffect(() => { animate( ".word", { opacity: [0, 1], x: [20, 0], }, { delay: stagger(0.15) }, ); }, []);3 collapsed lines
return <div ref={scope}>Hello beautiful animation</div>;};
Options
The hook accepts a configuration object with the following properties:
Defaults to "word"
.
It is recommended to not split on more elements than needed, as this will unnecessarily increase the amount of DOM elements.
The class name to use for the words, defaults to "word"
.
The class name to use for the lines, defaults to "line"
.
The class name to use for the characters, defaults to "char"
.
});
Targeting split elements
Each split piece gets a class name. All of them can be targeted in your animations.
.word
for words.line
for lines.char
for characters
Want custom class names? Pass them in the configuration object.
const [scope, animate] = useSplitTextAnimation({ splitOn: ["line", "char"],});
animate([ [ ".line", { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.15), duration: 0.2 }, ], [ ".char", { scale: [1, 1.5, 1], rotate: [0, 10, 0] }, { delay: stagger(0.01), { delay: stagger(0.01) }}, ],]);
Fading in text – initial styles
The hook does not set initial styles for you. This means if you want to fade in the text, you are responsible for hiding the text initially.
The best way to do this is based on whether the aria-label
attribute is present as you see in the example below. This makes sure the text is invisible until the split completes, then Motion takes over.
<div class="opacity-0 aria-[label]:opacity-100"> The text to be split </div>
/** Assuming you want to split .content */ .content { opacity: 0; } .content[aria-label] { opacity: 1; }
Notes
Accessibility
The hook automatically:
- Sets aria-label to the original text content.
- Ensures screen readers read the full text, not individual words/characters.
HTML inside text won’t work (yet)
This hook works with plain text only — if your text includes HTML (like <strong>
or <span>
inside), it will not work.
More examples
Animate When In View
Combine with useInView()
for view-based animations:
Optionally you can pass {once: true}
to the useInView()
hook to have the animation trigger only once.
const [scope, animate] = useSplitTextAnimation();const isInView = useInView(scope);
useEffect(() => { if (isInView) { animate(".word", { opacity: [0, 1], x: [20, 0] }, { delay: stagger(0.15) }); }}, [isInView]);
Words slide up
const [scope, animate] = useSplitTextAnimation({ splitOn: "word",});
useEffect(() => { animate( ".word", { opacity: [0, 1], y: [20, 0] }, { delay: stagger(0.04), duration: 0.3 }, );}, []);
Words slide in from right
const [scope, animate] = useSplitTextAnimation({ splitOn: "word",});
useEffect(() => { animate( ".word", { opacity: [0, 1], x: [20, 0] }, { delay: stagger(0.04), duration: 0.3 }, );}, []);
Masked letters slide down
By adding overflow: clip
to every word, you can mask each letter. It will then appear as if the letters are sliding down out of nowhere.
const [scope, animate] = useSplitTextAnimation({ splitOn: ["word", "char"],});
useEffect(() => { animate( ".char", { y: ["-100%", 0] }, { delay: stagger(0.03), duration: 0.3 }, );}, []);
return ( <div ref={scope} className="text-xl font-bold opacity-0 aria-[label]:opacity-100 md:text-4xl [&_.word]:overflow-clip" > Sprinkling some animations </div>);
.content { opacity: 0;}
.content[aria-label] { opacity: 1;}
.word { overflow-clip: clip;}
const [scope, animate] = useSplitTextAnimation({ splitOn: ["word", "char"],});
useEffect(() => { animate( ".char", { y: ["-100%", 0] }, { delay: stagger(0.03), duration: 0.3 }, );}, []);
return ( <div ref={scope} className="content"> Sprinkling some animations </div>);
Rotate and slide up lines
const [scope, animate] = useSplitTextAnimation({ splitOn: "line",});
useEffect(() => { animate([ [ ".line", { opacity: [0, 1], rotate: ["5deg", 0], y: [50, 0] }, { delay: stagger(0.2), duration: 0.25, opacity: { duration: 0.4 } }, ], ]);}, []);
return ( <div ref={scope} className="text-xl opacity-0 aria-[label]:opacity-100 md:text-3xl [&_.line]:origin-left" > Sprinkling some animations on the web is a great way to make it more engaging. </div>);
.content { opacity: 0; overflow-clip: clip;}
.content[aria-label] { opacity: 1;}
.line { transform-origin: left;}
const [scope, animate] = useSplitTextAnimation({ splitOn: "line",});
useEffect(() => { animate([ [ ".line", { opacity: [0, 1], rotate: ["5deg", 0], y: [50, 0] }, { delay: stagger(0.2), duration: 0.25, opacity: { duration: 0.4 } }, ], ]);}, []);
return ( <div ref={scope} className="content"> Sprinkling some animations on the web is a great way to make it more engaging. </div>);
Loop the animation
I mean, why not? If you want you can also loop the animation endlessly.
const [scope, animate] = useSplitTextAnimation({ splitOn: "char",});
useEffect(() => { animate( ".char", { y: [0, 20, -20] }, { delay: stagger(0.15), duration: 2, repeat: Infinity, repeatDelay: 0, ease: "easeInOut", repeatType: "mirror", }, );}, []);