Alright, here's the deal: I'm building a custom ChatGPT UI in React for private use, and I'd like to have it "think" like it does on the official client.
Exposing this behavior is a clever idea on OpenAI's part: it's anthropomorphism. Through adding a delay between words and a a subtle "typing" animation, the software appears a lot more "magical". It feels like the computer is doing work, it's "thinking" each response, pausing in between words like a human would. This contributes to the allure of ChatGPT: using it feels like talking to a human, because it's not instant like a computer.
However, I don't want to replicate OpenAI's animation one-to-one. I want to make it even more magical and futuristic. This is the end result:
It's surprisingly difficult to execute an animation like this in Framer Motion due to the little details that go into making it feel human:
Let's dive in. The code is raw, unfiltered, and unapologetically messy. As you've come to expect from this blog™️
My first approach was what seemed the easiest: utilizing Framer Motion's staggerChildren to animate the entry of each word after a response. I'd also create a simple <Cursor />
component that would handle the blinking cursor, but that's something we'll discover later.
<motion.div transition={{ staggerChildren: 0.05, }} > {text.split("").map((character, index) => { return ( <motion.span variants={characterAnimation} key={index} > {character} </motion.span> ); })}{" "} <Cursor loading={true} /> </motion.div>
This approach has some benefits: there's no state management on my end, it "just works" when changing the props of a string. This seemed like the most logical first-shot approach at building an animation like this.
I created some variants in Framer Motion to create a cool "magical" animation in between each word. In particular, I'm super excited about a "blur" effect in between words: it's a subtle touch that makes everything feel more high-end.
const characterAnimation = { hidden: { opacity: 0, width: "0px", position: "absolute", y: `10px`, filter: "blur(2px)", }, visible: { opacity: 1, y: `0px`, position: "static", width: "auto", filter: "blur(0px)", }, };
I ended up chalking it down to a problem with Framer Motion, because even when absolutely positioning them, I couldn't figure it out. This was a total dealbreaker for this approach.
A big part of what makes ChatGPT interesting is the delays in between word generation. It makes it feel as if the computer is "thinking", deliberating over the response it's handing down to you.
Using staggerChildren
, I wouldn't be able to finely control the animations between word appearance. There's no way to set a dynamic stagger value, and that's one more total dealbreaker for this approach.
This approach sounds a little crazy, but it felt like the next logical step.
The idea was to manage my own word rendering state.
words
. This contains all words being rendered on the screen.words
array, at a given ms interval.It made sense. So I got it to a point where it's somewhat working, the animation is flawlessly moving the cursor. I managed to tweak the transitions until I got them to a point of satisfaction. I moved quickly and added probabilistic thinking: every X percent of the time it'd slow down and "think" a word through.
The resulting code looked something like this, with bits of the cursor management code sprinkled in:
// should be a useRef let timer: ReturnType<typeof setTimeout> | null = null; useEffect(() => { const timing = randomizer([ { value: 25, probability: 0.8 }, { value: 50, probability: 0.15 }, { value: 65, probability: 0.05 }, ]); // do this by words, not letters if (index < fullText.length) { // manage cursor state setWaiting(false); // go word by word timer = setTimeout(() => { setChars((chars) => [...chars, fullText[index]]); setIndex(index + 1); }, timing); if (timing > 25) setWaiting(true); } return () => { if (timer !== null) clearTimeout(timer); }; }, [fullText, index]); const complete = index !== fullText.length;
The rendering bit came naturally:
<AnimatePresence key="parent"> {chars.map((c, idx) => ( <motion.span key={idx} initial="hidden" animate="visible" variants={characterAnimation} > {c} </motion.span> ))} </AnimatePresence>{" "} <Cursor blink={waiting} loading={index !== fullText.length} />
The good bits? Fine-grained animation management is a solved problem here. I could manage the whole lifecycle and even set state on every iteration. Since new words appearing were tied to a corresponding React re-render, this was an easy approach to reason about. (This is something that we will envy in our final approach.)
State management quickly became unwieldy. There's multiple state arrays holding the final and rendered string, useEffect
hooks everywhere to manage state changes.
How do you manage one or more prop changes while mid-animation? What happens to the words array then? How do you manage the multiple timeouts required to control cursor, thinking and animation state?
In practice, it was a case of "death by a thousand cuts". Edge cases make managing the state for this approach incredibly time-consuming and not worth it.
Ultimately I wasn't willing to spend that much time on this approach for a simple animation. There had to be another way.
This approach seemed promising. After some Googling, I managed to find a CodeSandbox that featured an approach I didn't think of before: creating a wrapper ` component that would:
n
(a component prop) secondsstaggerChildren
, have little state management and we'd also have exit animations per element for free.We'd create a <Delay />
component:
import { useEffect, useState } from "react"; import { motion } from "framer-motion"; export const Delay = ({ children, delay }) => { const [done, setDone] = useState(false); useEffect(() => { const showTimer = setTimeout(() => setDone(true), delay); return () => clearTimeout(showTimer); }); return <motion.span layout="size">{done ? children : null}</motion.span>; };
Note the layout="size"
on the component: this is important to make sure the cursor moves smoothly once the contents of this component render and it gains full width.
On the rendering side, we'd do something like this:
<LayoutGroup id="cursor"> <AnimatePresence key="parent" mode="sync"> <motion.div aria-hidden="true" key="parent" initial="hidden" animate="visible" > {string.split(" ").map((c, idx) => { const probTiming = Number(randomizer(probabilities)); const newTiming = prevTiming.current + probTiming; prevTiming.current = newTiming; return ( <Delay key={idx} delay={newTiming}> <motion.span key={idx} variants={characterAnimation}> {c}{" "} </motion.span> </Delay> ); })} <AnimateCursorOnWait /> </motion.div> </AnimatePresence> </LayoutGroup>
All Delay items are rendered only once, and then their timers kick off individually. It's important to keep in mind that the parent component renders only once, because this matters for the final solution.
This is the biggest drawback of this approach. This means a lot of hackery, especially around the cursor component. You might've noticed there's a separate <AnimateCursorOnWait />
component.
Since we only render once, we need a way to hold state for the animated cursor without triggering a parent component re-render, so we isolate it into its own component.
The single render requirement is incompatible with probabilistic animation timings without a useRef
hack. Since each item is passed a different delay prop, it's possible for all children to kick-off their timers at the same time.
Because they're not direct children of AnimatePresence
, setting the mode="wait"
parameter doesn't do much. In fact, thinking about it now, it's entirely possible AnimatePresence doesn't do much at all here.
To prevent this, we'd need to set a counter ref to keep track of the timings of each rendered component. This way, when the component loops through each word item, the timing passed to the delay component is the sum of all previous timings and its own.
// in the component body -- triggers on each re-render prevTiming.current = 0; // in the render loop const probTiming = Number(randomizer(probabilities)); const newTiming = prevTiming.current + probTiming; prevTiming.current = newTiming;
This relies on the fact that all delay components render once, so we can keep track of the timing sum via a ref. If we used setState, we'd have an infinite rendering loop.
Due to the strict requirement to prevent re-renders, we can't know within the AnimateCursorOnWait
component if the last rendered word triggers a cursor "waiting" animation. This is crucial to make sure that the cursor component can know when to start blinking in between words.
Instead of passing the timings down via props to the cursor (which we can't do), we opt for a more clever approach. Since we're animating the cursor using Framer Motion's layout animations, is there a callback that allows us to know when layout animations start and end?
Effectively so! onLayoutAnimationStart
and onLayoutAnimationComplete
allow us to hook into the layout animation lifecycle. With this information, creating all the animations for the cursor becomes a piece of cake with some timers.
function AnimateCursorOnWait({ loading = false }) { const [waiting, setWaiting] = useState(false); const [finished, setFinished] = useState(false); const thinkingTimer = useRef(); const finishedTimer = useRef(); return ( <motion.div className="inline-flex mb-[-1.75px] relative" layout="position" animate={{ top: 1.75 }} transition={{ layout: { duration: 0.15 }, }} onLayoutAnimationStart={() => { setWaiting(false); if (finishedTimer.current) clearInterval(finishedTimer.current); }} onLayoutAnimationComplete={() => { if (thinkingTimer.current) clearInterval(thinkingTimer.current); if (finishedTimer.current) clearInterval(finishedTimer.current); thinkingTimer.current = setTimeout( () => setWaiting(true), BASE_TIMING + 10 ); finishedTimer.current = setTimeout(() => setFinished(true), 1500); }} > <Cursor blink={waiting} loading={!finished} /> </motion.div> ); }
If the cursor isn't moving for longer than BASE_TIMING
, then we're thinking. If it isn't moving for longer than a couple of seconds, we're probably done writing.
So we've managed to solve all problems with animating both the cursor and the word entry, all the little behaviors that make the product feel magical, and any transitional states between string changes.
This is a hack.