lunato

One function. That’s the API.

lunato is the motion layer for AI interfaces. Bind an element once, and whenever its text changes, streamed, rewritten or corrected, only the words, digits and icons that changed will move.

bun add lunato

With an agent

Pick your agent, copy the prompt and paste it in. It installs lunato, finds the text in your app that changes in place and binds it. Agents can read the whole reference as plain text at llms.txt.

morphChanges(target)

Starts watching an element. Change its content however you like, with textContent, innerHTML or a framework render, and the change morphs.

target
An element, a CSS selector, or null, which is ignored so it can sit in a ref.
returns
A function that stops watching and puts the element back as it was.
import { morphChanges } from "lunato";

const stop = morphChanges("#price");
price.textContent = "$24"; // the 0 rolls to a 4
stop(); // back to a plain element

Word roll

Mark the element data-lunato="roll" and it rolls by word instead: words pair by position, and each word that changed rises away as its replacement bubbles up from below. Made for thinking states and statuses. The mark is read at every change, so it can be set or cleared at any time. Text on more than one line always morphs, and reduced motion crossfades either way.

<span ref={morphChanges} data-lunato="roll">{status}</span>

Feel

Playful is the default: the roll bubbles, leans and bobs, and the morph lands with a small overshoot. Add data-lunato-feel="calm" for the same motion without the play: words rise in and out whole, at their own size and upright, with no overshoot. It works with either.

<span ref={morphChanges} data-lunato="roll" data-lunato-feel="calm">{status}</span>

In your framework

There’s no wrapper component and nothing to call on update. Render into an element as usual and bind it once.

React 18

React 18 never calls a ref’s cleanup, so bind in an effect there.

const ref = useRef(null);
useEffect(() => morphChanges(ref.current), []);

<span ref={ref}>{price}</span>

Next.js

In the App Router, bind from a client component, one that starts with "use client". A ref is a function, and a server component can’t pass one.

Svelte 4

Svelte 4 has no attachments, so bind with an action.

<script>
  import { morphChanges } from "lunato";
  const morph = (node) => ({ destroy: morphChanges(node) });
</script>

<span use:morph>{price}</span>

Anything else

Import lunato/element and wrap the text in <lunato-text>. With no build step, load it from a CDN.

<script type="module" src="https://unpkg.com/lunato/dist/element.js"></script>
<lunato-text>$240</lunato-text>

Recipes for AI interfaces

Every recipe is the same move: render into an element as you already do, and bind it once. They are written in React; in Vue, Svelte, Solid or plain JS, bind the same element the way your framework does. The icons are yours.

A streamed answer

Bind the element the answer streams into. Each new word rises in as one piece, and when the model revises what it already wrote, only the revised words move.

<p ref={morphChanges}>{answer}</p>

What the model is doing

One line the model keeps rewriting. The words it keeps stay still, a count rolls, and the dots can reshape into a check when it is done.

<span ref={morphChanges}>
  {done ? <CheckIcon /> : <DotsIcon />} {status}
</span>
// "Reading 4 sources" → "Reading 9 sources": only the 4 rolls

Send becomes stop

Swap the icon inside a bound element and it morphs instead of popping.

<button onClick={busy ? stop : send}>
  <span ref={morphChanges}>{busy ? <StopIcon /> : <SendIcon />}</span>
</button>

A token count

Digits roll like an odometer, so a count climbing while the answer streams stays readable.

<span ref={morphChanges} style={{ fontVariantNumeric: "tabular-nums" }}>
  {tokens.toLocaleString()} tokens
</span>

A chat that names itself

The title moves from its placeholder to the name the model gives it.

<h1 ref={morphChanges}>{chat.title ?? "New chat"}</h1>

Regenerate and rewrite

Render the new version into the same element. The words both versions share hold their place, so the reader sees what changed.

<p ref={morphChanges}>{versions[current]}</p>
<span ref={morphChanges}>{current + 1} / {versions.length}</span>

Live captions

Speech recognition revises its guess as someone talks. Write each guess into a bound element and the corrections move, not the whole line. Plain JS, with the browser's own recogniser.

const caption = document.querySelector("#caption");
morphChanges(caption);

const listen = new (window.SpeechRecognition ?? window.webkitSpeechRecognition)();
listen.continuous = true;
listen.interimResults = true;
listen.onresult = (e) => {
  caption.textContent = [...e.results].map((r) => r[0].transcript).join(" ");
};
listen.start();

What moves

Styling

The element’s own CSS is the look, and the animation copies its font, colour and position. The one choice is how it moves: the morph, or the word roll.

Accessibility

The real text never leaves the page, so screen readers, search, find and selection all see a plain element. The animation is drawn in an aria-hidden layer on top. With reduced motion turned on, changes simply fade. Add aria-live="polite" to the element if its changes should be announced. A visually hidden label inside the element stays hidden, and a print shows the real text.

Limits

Browsers

Chrome 113, Safari 17.2 and Firefox 112, or later: the ones with linear() easing. Anywhere else, the server and jsdom included, the text shows as written and nothing moves, so there is nothing to guard in your own code.