alex
VERY interesting!
Sam Selikoff shared a slick demonstration of a React hook for animating text streamed from an LLM recently. It caught my eye for a couple reasons. First, it looks great. Second, one of my eternal pet projects is TypeIt, used to create very similar sorts of animations.
I couldn't help but create an example of my own using TypeIt, and it turned out to be pretty straightforward – so straightforward that I had time to write up this post exploring it some more.
Everything you see here will remain in a React context (we'll use the typeit-react package), but it could be set up just as easily with another framework (or none at all). First, we're going to create a fake streaming API. Normally, this would be connected to real LLM. I'm too cheap for that.
function chunkText(text, chunkSize = 3) {
const chunks = [];
for (let i = 0; i < text.length; i += chunkSize) {
chunks.push(text.slice(i, i + chunkSize));
}
return chunks;
}
export async function streamText() {
const text = 'Bunch of text...';
const chunks = chunkText(text);
async function* generateStream() {
for (const chunk of chunks) {
await new Promise((resolve) => setTimeout(resolve, Math.random() * 50));
yield chunk;
}
}
return {
textStream: generateStream()
};
}
Next, let's scaffold a useAnimatedText()
hook to house all the typing business. It'll be very much inspired by the API Sam uses in his example. You could do whatever you like.
import { useEffect, useState } from 'react';
import TypeIt from 'typeit-react';
export function useAnimatedText() {
const [text, setText] = useState('');
const el = (
<TypeIt options={{ cursor: false }}></TypeIt>
);
return [el, setText];
}
Notice this is returning two things – the JSX we'll want to render, as well as a function for updating the rendered text. We'll flesh this out more in a bit.
Finally, here's the shell of the app itself:
import { streamText } from './streamText';
import { useAnimatedText } from './useAnimatedText';
export default function App() {
const [animatedText, setText] = useAnimatedText();
async function go() {
const { textStream } = await streamText();
for await (const textPart of textStream) {
setText(textPart);
}
}
return (
<div>
{animatedText}
<button onClick={go} >
Generate
</button>
</div>
);
}
The word-by-word animation animation style is the most similar to the one used by ChatGPT to generate chunks of text, so we'll start with that. First, we need to first make it possible to give text to the TypeIt instance whenever it's streamed. We'll use a bit of state to make it available as a variable:
import { useEffect, useState } from 'react';
import TypeIt from 'typeit-react';
export function useAnimatedText() {
const [instance, setInstance] = useState(null);
const [text, setText] = useState('');
const el = (
<TypeIt options={{ cursor: false }}
getAfterInit={(i) => {
setInstance(i);
return i;
}}
></TypeIt>
);
return [el, setText];
}
Next up, we need to pass text to the instance whenever it's changed. Regrettably, that means reaching for useEffect()
(I'm so sorry, @DavidKPiano):
import { useEffect, useState } from 'react';
import TypeIt from 'typeit-react';
export function useAnimatedText() {
const [instance, setInstance] = useState(null);
const [text, setText] = useState('');
useEffect(() => {
if (!instance) return;
instance.type(text, { instant: true }).flush();
}, [text]);
const el = (
<TypeIt options={{ cursor: false }}
getAfterInit={(i) => {
setInstance(i);
return i;
}}
></TypeIt>
);
return [el, setText];
}
Let's break apart that instance.type()
line. First, the .type()
method will queue up any text you give it, and passing { instant: true }
will cause it to be typed as one, single string (not character-by-character). Simple enough.
The .flush()
method is a little special. Normally, TypeIt holds a queue of items it needs to process (kicked off by .go()
), which then allows the animation to be replayed if needed. But we're typing content on-the-fly. Instead, using .flush()
will throw away your queue after the items are processed, making it great for a use case like this. Here's the final result:
If a letter-by-letter animation style is preferred, it's as simple as removing the { instant: true }
and adjusting the speed as needed:
I'm glad I tinkered with this a bit – it actually spawned an improvement to TypeIt's library itself. If you're interested in seeing how you might implement other typing-related animations with TypeIt, or if you have any suggestions to make it even better, reach out on X!
Alex MacArthur is a software engineer working for Dave Ramsey in
Nashville-ish, TN.
Soli Deo gloria.
Get irregular emails about new posts or projects.
No spam. Unsubscribe whenever.VERY interesting!