The Idea
After finishing some client work, just like everyone else and their grandma, I made an AI app 🙄. Mine writes custom cover letters based on the user's CV and a job description.
When I got around to making the homepage, I knew I wanted to make an emotional impact with my hero section. Something relatable that reminds the user of the frustration of writing cover letters themselves. I came up with this. To see the animation in action, visit covercraft.io.
The section on the right looks like a Google Doc. It shows how someone is trying to write a cover letter but failing to find the right words. They re-write the first sentence repeatedly, but every attempt is more cringe-worthy than the last.
In this article, I will show how I made this text typing effect with framer motion. I will use a lot of different animation techniques. If you are new or need a refresher on certain topics, they are covered in my previous articles. Here are some links:
There is a CodeSandbox at the start and end of the article, so don't worry about copying everything down as you go. Let's get into it!
The Plan
There are 3 parts to this animation:
The cursor - The blinky line at the end of the text to show where the cursor is.
The text - "Dear Hiring Manager" and the awkward sentences that appear letter by letter as if someone was typing.
The layout - A simplified Google doc to act as a container.
We'll create separate react components for each to keep things organised.
The Cursor
In essence, it's just a line that comes and goes. We can create a motion.div
with a width of 1px
and add a variant called blinking
.
import { motion } from "framer-motion";
const cursorVariants = {
blinking: {
opacity: [0, 0, 1, 1],
transition: {
duration: 1,
repeat: Infinity,
repeatDelay: 0,
ease: "linear",
times: [0, 0.5, 0.5, 1]
}
}
};
export default function CursorBlinker() {
return (
<motion.div
variants={cursorVariants}
animate="blinking"
className="inline-block h-5 w-[1px] translate-y-1 bg-slate-900"
/>
);
}
The variant uses keyframes to animate the opacity from 0
to 1
. We use four keyframes and define times
in the transition. This makes the cursor opacity 0
for half the duration and 1
for the other half. Notice how the times
for keyframes two and three are 0.5. That means that the opacity will switch instantly from 0
to 1
instead of fading in and out. This repeats infinitely.
By making the cursor inline-block
instead of block
, it will appear next to our text instead of below. That way, when we add more letters to our text, the cursor will be pushed to the right.
Simple Text Animation with Motion Values
There are two parts to this. I want "Dear Hiring Manager" to animate only once when the component mounts. After that, the awkward sentences should animate in on a new line. We will cover that in the next section.
First, let's create the "Dear Hiring Manager" animation. The idea is this:
Take some text.
Create a count variable.
Animate the count between
0
andtext.length
.Display a substring of text where its length is based on count.
Component Setup
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
import CursorBlinker from "./CursorBlinker";
export default function TextAnim() {
const baseText = "Dear Hiring Manager, ";
const count = useMotionValue(0);
return (
<span className="">
<motion.span>
// Our text goes here
</motion.span>
<CursorBlinker /> // As text grows, the cursor will be "pushed" along
</span>
);
}
Adding our text into a <span>
element will allow our cursor to sit next to it because they are both inline
.
To fully use framer motion, we want to use useMotionValue()
and not useState()
. Motion values use Refs under the hood and don't re-render the entire component every time they change. Motion values can run faster and give us more control by exposing easing functions, delays, loops, etc.
Animation
Now, we can add the animation. Because we want to animate count
only once when the component mounts, we can use useEffect()
with an empty dependency array.
const baseText = "Dear Hiring Manager, " as string;
const count = useMotionValue(0);
useEffect(() => {
const controls = animate(count, baseText.length, {
type: "tween", // Not really needed because adding a duration will force "tween"
duration: 1,
ease: "easeInOut",
});
return controls.stop;
}, []);
Inside useEffect()
, we call framer motion's animate()
function that allows us to imperatively animate a single value. It works like this:
animate(variableToAnimate, variableTargetValue, transitionOptions)
We want to animate count
from 0
(set at initialisation) to baseText.length
. I also added a transition to control how the text will appear. I'm going with a simple "tween" animation and an "easeInOut" easing function for our purposes.
"If you want to get fancy, you could adjust the duration of the animation based on the length of the text, but I'll keep it simple and use one second as a starting point."
return controls.stop
cleans up the animation when the component is unmounted. Don't worry about that too much.
Great, now when our component mounts, count
is animated from 0
to baseText.length
.
Connecting the dots
It's time to connect the baseText
to count
. For that, we need useTransform()
.
useTransform()
can take a motion value as an argument and returns a new, changed motion value. If you haven't used it before, think of useTransform()
as "computed values" or linking two values together. When one changes, the other changes, too. There are two ways we can control how the new motion value changes:
- By adding an input range and an output range:
const newValue = useTransform(count, [0, 1], [0, 100])
// When `count` goes from `0` to `1`, I want `newValue` to go from `0` to `100`.
- By defining a function that runs every time the input changes:
const newValue = useTransform(count, (latest) => {latest * 100})
// When `count` changes, I want `newValue` to be 100 times that of `count`.
We will use the second method like so:
const rounded = useTransform(count, (latest) => Math.round(latest));
const displayText = useTransform(rounded, (latest) =>
baseText.slice(0, latest)
);
We create the rounded
motion value, simply an integer version of our count
. We do this because we can only slice a string by discrete values. Typing half a letter also doesn't make much sense 🤔.
The second motion value we create is the text we will display on the page. It uses the rounded
variable to slice our baseText
into a substring. This part links our animated count
variable to the text we want to show.
And that's it! We've animated some text! Here's everything together:
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
import { useEffect, useState } from "react";
import CursorBlinker from "./CursorBlinker";
export default function TextAnim() {
const baseText = "Dear Hiring Manager, " as string;
const count = useMotionValue(0);
const rounded = useTransform(count, (latest) => Math.round(latest));
const displayText = useTransform(rounded, (latest) =>
baseText.slice(0, latest)
);
useEffect(() => {
const controls = animate(count, baseText.length, {
type: "tween",
duration: 1,
ease: "easeInOut",
});
return controls.stop;
}, []);
return (
<span className="">
<motion.span>{displayText}</motion.span>
<CursorBlinker />
</span>
);
}
Infinite Typing Animation
Okay, it's time for the cringe sentences. We know how to reveal the text, but what if we want to delete the text? And what about typing something different every time?
Here's the gist of it:
We reverse the animation so that
count
goes from0
to1
and then back to0
.We repeat this indefinitely.
We change the text every time the count hits
0
.
You're all grown up now, armed to the teeth with animation knowledge, so here's the entire code for this component. We'll dissect them together below.
import { motion, useMotionValue, useTransform, animate } from "framer-motion";
import { useEffect } from "react";
export default function RedoAnimText() {
const textIndex = useMotionValue(0);
const texts = [
"I am writing to you because I want a job.",
"I am the best candidate for this job.",
"In my grand adventure as a seasoned designer...",
"Knock knock! Who's there? Your new employee!",
"Walking the tightrope balance of project management...",
"I find myself compelled to express my interest due to...",
"My pen (or should I say, keyboard) is at work today because...",
"Inspired by the alluring challenge in the job posting, I am writing...",
"Stirred to my keyboard by the tantalising nature of the role…"
];
const baseText = useTransform(textIndex, (latest) => texts[latest] || "");
const count = useMotionValue(0);
const rounded = useTransform(count, (latest) => Math.round(latest));
const displayText = useTransform(rounded, (latest) =>
baseText.get().slice(0, latest)
);
const updatedThisRound = useMotionValue(true);
useEffect(() => {
animate(count, 60, {
type: "tween",
duration: 1,
ease: "easeIn",
repeat: Infinity,
repeatType: "reverse",
repeatDelay: 1,
onUpdate(latest) {
if (updatedThisRound.get() === true && latest > 0) {
updatedThisRound.set(false);
} else if (updatedThisRound.get() === false && latest === 0) {
if (textIndex.get() === texts.length - 1) {
textIndex.set(0);
} else {
textIndex.set(textIndex.get() + 1);
}
updatedThisRound.set(true);
}
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <motion.span className="inline">{displayText}</motion.span>;
}
Variables
First of all, we have some new variables:
texts
holds all our texts.textIndex
keeps track of which text we're currently on.baseText
is now a motion value derived from thetext
andtextIndex
.updatedThisRound
helps us change the text only once per loop.
Animation
The animation is almost the same as the first, but with a few key differences:
I use
60
as a hardcoded length because a dynamic value won't change between repeats.I added
repeat: Infinity
for an endless loop.repeatType: "reverse"
makes the animation go backwards instead of starting from the beginning. This simulates the person hitting "backspace".repeatDelay: 1
gives the user a second to read the complete sentence before being deleted.
Swapping the Text
The tricky part is swapping the text when count
is 0
. Transition objects have some handy triggers that allow us to call a function during certain stages of our animation. We can use those to increment textIndex
to swap out our current text.
onComplete()
gets triggered at the end of our animation, but since our animation never ends, it never gets called.
We need a trigger that gets called every repeat. There is no onRepeat()
in framer motion, so we must build it ourselves.
onUpdate()
lets us call a function on every frame. But simply checking if latest === 0
and incrementing textIndex
won't work because onUpdate()
is called sixty times per second (or whatever your browser's frame rate is). Since we're waiting at count = 0
for 1 second, we would increment textIndex
sixty times!
That's where updatedThisRound
comes in. It acts as a latch, allowing us to increment textIndex
only once per repeat. It has to be a motion value because we don't want our component to re-render when its value changes. Here's how it works:
onUpdate(latest) {
// If we updated already and we're not at 0 anymore,
// set updatedThisRound to false.
// The next time we hit 0, we will increment.
if (updatedThisRound.get() === true && latest > 0) {
updatedThisRound.set(false);
// If we haven't updated yet and we're at 0,
// increment and set updatedThisRound to true.
} else if (updatedThisRound.get() === false && latest === 0) {
// Set textIndex to 0 if we reach the end of our texts array.
// So we don't run out of silly sentences
if (textIndex.get() === texts.length - 1) {
textIndex.set(0);
} else {
textIndex.set(textIndex.get() + 1);
}
updatedThisRound.set(true);
}
}
And yes, running a bunch of conditionals 60 times per second sends me straight to performance hell, but hey... I want silly sentences. And besides, there's only a little else on my homepage, so I figured it was okay. Let me know if you have a better solution :)
The Layout
Here, we keep it simple. We need a container that looks like a Google Doc. I decided to take a couple of elements like the logo, the document title ("untitled document", of course) and the formatting toolbar buttons.
I used an aspect ratio of 1/1.41, the same as A4. The rest is just CSS.
I added a short enter animation to the layout. The child components come in slightly later with delayChildren
and staggerChildren
.
"use client";
import { DocumentTextIcon } from "@heroicons/react/20/solid";
import { motion } from "framer-motion";
import AnimText from "./AnimText";
const containerVariants = {
hidden: {
opacity: 0,
y: 30
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: "easeOut",
delayChildren: 0.3,
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: {
opacity: 0,
y: 15
},
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: "easeOut"
}
}
};
export default function A4Animation() {
return (
<motion.div className="flex w-full select-none items-center justify-center ">
<motion.div
variants={containerVariants}
animate="visible"
initial="hidden"
className="flex aspect-[1/1.41] h-[500px] flex-col rounded-2xl bg-white p-2"
>
<motion.div
variants={itemVariants}
className="flex items-center space-x-2"
>
<DocumentTextIcon className="h-8 w-8 text-indigo-700" />
<span className="text-slate-700">Untitled document</span>
</motion.div>
<motion.div
variants={itemVariants}
className="flex items-center justify-center"
>
<div className="mt-2 flex space-x-4 rounded-full bg-slate-100 px-8 text-slate-700">
<strong>B</strong>
<span className="font-italic">I</span>
<span className="underline">U</span>
<strong className="underline">A</strong>
</div>
</motion.div>
<motion.span
variants={itemVariants}
className="inline h-full w-full p-8 text-lg text-slate-900"
>
<AnimText />
</motion.span>
</motion.div>
</motion.div>
);
}
Finishing Touches
Below is the CodeSandbox of the entire effect. I added some line breaks in the final version when the first animation ends. There is also a delay to make sure one happens after the other. Check it out on your own time to see how it works. Leave me a comment if you need any help.
Conclusion
Animation complete! Congratulations! 🍾
Thank you for reading. I hope you learned something. As you know, I just launched CoverCraft, and I would be exceedingly happy if you tried it out. It's still early days, but any feedback is welcome. I am thinking about writing a series on how I built CoverCraft. Let me know if you would be interested in something like that.
Have a great day :)