Skip to content
animorio
IT
All components

The chat that writes its answer

Chat, Text, TransitionsGSAP, CSS onlyAdvancedCompressed weight: Demo 12 KBGSAP 28 KBFreeDownload the zip

The kitchen assistant of a recipe app, in the minimal style: the answer comes in word by word, as a stream, and every word arrives narrow and light on the font’s axes and settles to its normal width and weight. A thin vermilion caret rides the end of the text; before the first word the wait is a hairline that lengthens and then stands up into the caret. Stop leaves the sentence halfway, Regenerate winds the words back from the last one and writes another answer. The script breaks the lines with each word’s final width, so nothing jumps to the next line while it widens.

The code

A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The chat that writes its answer</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/roboto-flex@5.3.0/wdth.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the kitchen assistant of a cooking app. Its answer comes in word by word, as a stream: every word arrives
         narrow and light and settles to its normal width and weight, and a thin vermilion caret rides the end of the
         text. Before the first word the wait is a hairline that lengthens; Stop leaves the sentence where it was,
         Regenerate winds the answer back from its last word and writes another one -->
    <section class="ask" aria-label="Mise, kitchen assistant" data-panel data-autoclic>
      <header class="ask__head">
        <span class="ask__brand">Mise</span>
        <span class="ask__role">Kitchen assistant</span>
        <span class="ask__rule" aria-hidden="true" data-rule></span>
      </header>

      <div class="ask__thread" data-thread>
        <p class="ask__q" data-question>I have two leeks, four eggs and a little parmesan. What can I make tonight?</p>

        <div class="ask__a" aria-busy="false" data-answer>
          <p class="ask__text" aria-hidden="true" data-text></p>
          <span class="ask__caret" aria-hidden="true" data-caret></span>
          <p class="sr" aria-live="polite" data-live></p>

          <div class="ask__after" data-after>
            <button class="regen" type="button" data-regen>
              <svg class="regen__icon" viewBox="0 0 20 20" aria-hidden="true" data-regen-icon>
                <path d="M15.6 7.4A6.2 6.2 0 1 0 16.2 12"/>
                <path d="M16.1 3.6v4.1H12"/>
              </svg>
              <span class="regen__label">Regenerate</span>
            </button>
            <span class="ask__note" data-note>Stopped</span>
          </div>
        </div>
      </div>

      <form class="ask__composer" data-form>
        <div class="field">
          <label class="sr" for="mise-input">Ask Mise a follow-up</label>
          <input class="field__input" id="mise-input" type="text" placeholder="Ask a follow-up" autocomplete="off" maxlength="160" enterkeyhint="send" data-input>
          <button class="act" type="button" aria-label="Send" disabled data-act>
            <span class="act__icons" aria-hidden="true">
              <svg class="act__arrow" viewBox="0 0 24 24" data-arrow><path d="M12 18.4V5.8M6.8 11 12 5.8l5.2 5.2"/></svg>
              <svg class="act__square" viewBox="0 0 24 24" data-square><rect x="7.6" y="7.6" width="8.8" height="8.8" rx="1.6"/></svg>
            </span>
            <span class="act__label" aria-hidden="true" data-act-label>Stop</span>
          </button>
        </div>
      </form>
    </section>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="script.js"></script>
</body>
</html>

Playground

Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.

26 values to change

Or ask Claude Code for it

With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:

Take “The chat that writes its answer” from Animorio and fit it into my site

What it does

On a white background a white box 560 pixels tall and 384 wide, with a thin edge and round corners. At the top Mise, narrow and black, and Kitchen assistant in grey, over a hairline. Under it, the question, large: I have two leeks, four eggs and a little parmesan. What can I make tonight? Then the answer, a leek frittata, with Regenerate under it, and at the bottom the field Ask a follow-up with the round arrow button. Pressing Regenerate the words wind back from the last to the first, narrowing, losing weight and going like a wave running backwards; in their place a thin vermilion line lengthens from the left: the wait. When the answer comes the line runs back and stands up into a thin caret, and the words come in in uneven chunks as in a real stream: each arrives very narrow, light and faint, drops a hair and widens to its width, pushing the others and the caret along. At every new line the caret drops out of sight where it is and comes up again at the end of the new line, never sweeping over the text. Meanwhile the field’s button turns into a black pill with a small square and Stop. Pressing Stop, the sentence stays halfway, the caret turns grey and Regenerate and Stopped appear under it. Once the answer is complete the caret sinks and goes. Typing a question into the field, the old answer winds back, the question comes up into its place and the new answer is written the same way (try Can I do it without eggs?, Which wine goes with it? or How long does it take?).

How it works

Every word is a block with its axes in two variables

Every word of the answer is an inline-block span with font-variation-settings: 'wdth' var(--wd), 'wght' var(--wg) in Roboto Flex (width 25 to 151, weight 100 to 1000). GSAP tweens each word’s two CSS variables from ARRIVE to REST with power3.out and power2.out, the opacity in a moment and a 3 pixel drop with back.out: the word comes in narrow and light and settles. The words are blocks because a transform does nothing on an inline element.

The script breaks the lines, with the final widths

A word that widens after it has come in would push the last one on the line onto the next, halfway through its movement. So the moment a word arrives its settled width is measured on a hidden ruler with the same axes (once per word), and the script keeps count of how long the line is with the words settled: if the new word would not fit, a br goes in before it. While the words are narrow the line is shorter than the measure, so the browser never breaks a line by itself, and what is already written never moves. Whenever the measure changes the line breaks are made again with the new widths.

The rhythm of the stream

The words’ times are worked out beforehand, with a random number seeded by the text (the same answer always has the same rhythm): SPEED words a second on average, sometimes two in one chunk, a breath after commas and a longer one after full stops. Every word is a call on a GSAP timeline: Stop kills the timeline and the words already on their way finish settling.

One pixel for the line and the caret

The waiting line and the caret are the same element, 1 pixel by 1, stretched by its transform every frame. A value m goes from 0 (the line lying from the start of the row, as long as the wait wants) to 1 (the caret standing at the end of the text): in the first half the line’s tip runs back, in the second the line stands up with a back.out, a pixel too far and back. The caret follows the end of the last word, measured every frame, on a spring; on its own line it never falls behind (the words push it), and when the line changes it shrinks to nothing where it is and is born again at the end of the new line, so it never crosses the text.

Stop, Regenerate and a new question

The field’s button turns into Stop widening with a back.out, the arrow leaves upwards and the square comes in. Stop leaves the sentence as it is, turns the caret grey and shows Regenerate and Stopped. Regenerate moves to the next answer in ANSWERS: the words go back to their arrival values from the last to the first, staggered, and the waiting line starts again. A question typed into the field looks for a word of FOLLOW_UPS in it (otherwise the answer is OTHER).

Keys, screen readers and the preview

The words coming in are aria-hidden: the whole answer goes into an aria-live region only when it finishes or stops, and meanwhile the answer’s box is aria-busy. Esc stops the answer; if Regenerate had the focus when it goes, the focus moves to Stop, and back to Regenerate when the answer ends. A click from a script on the box (the library’s preview) is a tour of every state: Regenerate, the wait, the other answer stopped halfway through a sentence, a pause, Regenerate again and the first answer written to the end, which is how it started.

Values to change

NameFileDefaultWhat it changes
SPEEDscript.js13The words per second of the stream, on average (they come in uneven chunks, like a real one).
SETTLEscript.js0.55The seconds a word takes to go from narrow and light to its normal shape.
ARRIVEscript.js[40, 190]The width and the weight a word arrives at (width 25 to 151, weight 100 to 1000).
RESTscript.js[100, 400]The width and the weight it settles at.
WAITscript.js0.9The seconds of the thin line that lengthens before the first word.
QUESTIONscript.js'I have two leeks, four eggs and a little parmesan. What can I make tonight?'The question asked to the assistant.
ANSWERSscript.js['A leek frittata. …', 'Baked eggs on soft leeks. …']The answers to the question: Regenerate writes the next one.
FOLLOW_UPSscript.js[['egg', 'Then make it a leek risotto. …'], …]A word to look for in a question typed into the field, and the answer to it.
OTHERscript.js'I can help with what is on your counter: …'The answer to anything else.

Try them in the Playground

When to use it

For the assistant of an app or a website: recipes, travel, customer help, a product’s documentation, help in the settings. It suits any place where a long answer has to arrive bit by bit without making the page jump. Colours in the variables, question and answers in the values block; with a real model, call the function that adds a word for every chunk that comes in.

On phones

The box takes the width of the screen less 32 pixels (358 at 390), the question goes down to 18 pixels and the lines are broken again for the new measure; the words come in and settle the same way. The field is 16 pixels, so Safari on iPhone does not zoom the page, and the keyboard shows its send key. Stop and Regenerate are at least 34 pixels tall; under a finger Regenerate widens and takes weight as under the pointer.

With reduced motion

The words still arrive one by one, but already at their width and weight, with a short fade; the waiting line appears still, the caret jumps to the end of the text with no spring, Regenerate and a new question fade the answer out and the button changes without widening.

Pitfalls

  • With the words widening inside ordinary text, the last one on a line came in narrow, fitted, and widened until it jumped to the next line halfway through. The script now places the line breaks with the settled widths, measured on a hidden ruler.
  • The ruler read the space between words on the first ResizeObserver call, before Roboto Flex had arrived: the fallback face’s space changed the line breaks, and at the end of the tour the answer did not come back the same. It is now measured only after document.fonts.load.
  • The waiting line was still lying down when the first word arrived and cut through it like a strikethrough: now the line runs back and stands up, and the first word comes a third of a second later.
  • Going from line to caret the width and the height changed together, and halfway there was a solid red rectangle: now it first shortens, then stands up.
  • With a vertical spring too, the caret going to a new line crossed the last words of the line diagonally: now it goes out where it is and is born again at the end of the new line; on its own line it cannot fall behind the text, so it never cuts through a widening word.
  • A tween made inside a GSAP call only starts on the next frame: the caret's loop saw it still, switched off, and the caret stayed lit after the end. The loop now stops after a few quiet frames.
  • The preview's tour stopped the answer after 48% of the words, right on a full stop: Stop now falls on the first word past a third of the answer that has no punctuation on it or on the two after it, halfway through a sentence.

Libraries

GSAP 3.15.0, Roboto Flex (font variabile, con gli assi della larghezza e del peso) 5.3.0.

Compressed with gzip, the three demo files weigh 12 KB; the libraries: GSAP 28 KB.

Testing

Tested on 16 October 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: To do

License

Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.

All components